When I first started organizing my writing projects, I noticed something: every article was living in a different place. Some were local files, others were drafts lost in my notes, and a few were somewhere in the cloud with no structure. It was not sustainable, and more importantly it was not scalable. I wanted to manage my posts the way I manage my code, with control, traceability, and safety. That is when I realized GitHub was the perfect fit.

What follows is the version I actually run today, after a few rounds of simplification. The first iteration had more moving parts (two branches, a separate drafts repo, an access switch), and every one of those parts was a thing that could drift out of sync. The current design is smaller and calmer: one source for content, and a little bit of metadata that decides what readers see.

Why GitHub

GitHub might seem like an odd choice for a blog. It is built for version control and collaboration, not for publishing prose. But if you think about what good content management actually requires, the match is natural. It is free, it is private when you want it to be, and it is version-controlled by design. Each post is a Markdown file, easy to edit, diff, and review, and each commit is a snapshot of the article's evolution from rough draft to polished piece.

There is also the practical side. With GitHub I can rely on its API to fetch files straight from a repository, so I do not need an expensive headless CMS or an external database. I fetch content securely through an access token and render it in my own frontend. And it leaves a clean path to future automation: it is easy to imagine GitHub Actions that validate metadata or trigger a deploy when a post lands, all without leaving my development environment.

The single source of truth

The whole system rests on one decision: there is exactly one place content lives. A single private repository, on its main branch, with each post sitting at posts/<series>/<file>.md. The repository is private, so nobody can browse or fetch the raw files directly. Access happens only through the frontend, which authenticates with a GitHub token stored as an environment variable.

That is the part I simplified the most. I used to keep a second drafts branch (and at one point a whole separate draft repository) and a BLOG_ACCESS switch that told the app which branch to read. It worked, but it meant the "same" post could exist in two places, and keeping them straight was busywork. So I collapsed it. Now the app always reads main, and the question of whether a post is a private draft, a scheduled future piece, or live is answered entirely by the post's metadata, not by where the file is stored.

The environment configuration is correspondingly short:

BLOG_STORAGE=github            # "local" or "github"
BLOG_SOURCE_GITHUB=https://api.github.com/repos/:user/:repo/contents/posts
BLOG_GITHUB_TOKEN=<your-token> # read access to the private repo

There is no branch selector anymore, because there is only one branch to read.

Fetching and decoding posts

When the app needs a post, it calls a single function that hides all of this. It builds the GitHub API URL for the file on main, fetches it with the token, and decodes the response, since GitHub returns file contents Base64-encoded.

import { BlogPost, BlogStorageType } from "@/types";

export async function getPostContent(
  params: BlogPost,
  storage: BlogStorageType = process.env.BLOG_STORAGE as BlogStorageType
) {
  const { href, id } = params;
  const path = href || id;

  try {
    if (storage === "github") {
      const url = `${process.env.BLOG_SOURCE_GITHUB}/${path}.md?ref=main`;
      const res = await fetch(url, {
        headers: {
          Authorization: `Bearer ${process.env.BLOG_GITHUB_TOKEN}`,
          Accept: "application/vnd.github.v3+json",
        },
      });
      if (!res.ok) throw new Error(`Failed to fetch ${path} from GitHub`);
      const json = await res.json();
      return Buffer.from(json.content, "base64").toString("utf-8");
    }
    return null;
  } catch (err) {
    console.error(err);
    return null;
  }
}

From this point on, nothing downstream needs to know where the file came from. The decoded Markdown is treated exactly as if it had been read from disk, so the rendering pipeline stays identical whether I am previewing locally or serving in production. That transparency is the point: abstract away the source, and everything else gets simpler.

Metadata is the control panel

If content lives in the posts repo, the behavior of each post lives in the site repo, as a small metadata record. Here is a representative entry:

{
  id: "la-1",
  slug: "la-geometry-of-linearity",
  collection: "Linear Algebra",
  level: "i-want-to-learn",
  title: "The Geometry of Linearity: Why Vector Spaces Matter",
  category: "Mathematics",
  description: "Start the series with the fundamental object of linear algebra.",
  date: "2027-04-11",
  readTime: "10 min read",
  tags: ["Linear Algebra", "Vector Spaces", "Mathematics"],
  status: "published",
  href: "linear-algebra/math-la1",
  hidden: false,
}

This record is what lets the system index posts, build collections, filter by topic, and estimate reading time. The href is also the path to the Markdown file, which is how the fetch function knows what to load. But the three fields that matter most for publishing are status, date, and hidden, because together they replace everything the old branch-and-access machinery used to do.

The rule is small enough to state in full. A post is live when it is status: "published", not hidden, and its date has arrived. A post is a private draft when its status is "draft", in which case it is invisible in production no matter what (handy while I am still writing it). And a post is scheduled when it is published with a date in the future: it stays hidden until it is within two weeks of that date, at which point it shows up as "Coming Soon", and it goes live on the day itself. That two-week window is a single constant, so "coming soon" always means soon, not months away. Setting hidden: true pulls any post offline regardless of the rest.

Scheduling, then, is not a separate feature. It is just choosing a date. I can write ten posts, give them dates spread across the next year, and the site will reveal them on cadence with no further action from me.

For local work there is one override: a development-only flag that reveals everything, drafts and far-future posts included, so I can preview a piece exactly as it will render before anyone else can see it. In production that flag is off, so the metadata rules above are the only thing that decides visibility.

How a request resolves

Putting it together, loading a post is a short, predictable path. The page looks up the post's metadata by slug, asks the shared visibility helper whether this post should be visible right now, and if so fetches its Markdown from main and renders it. If the post does not exist, is still a draft, or is scheduled too far ahead, the reader gets a graceful "Not Found" instead. Crucially, the content fetch only happens for a post the site has already decided is visible, so a draft's text is never even requested in production, and because the repo is private it is not reachable directly either.

Keeping that visibility logic in one shared place is what prevents the classic bug where a post shows as "coming soon" on one page but 404s on another. There is a single source of truth for what is visible, mirroring the single source of truth for the content itself.

Why this model works

Using GitHub as a CMS sounds unconventional, but it fits how a developer thinks. It brings traceability, since every edit has a timestamp, an author, and a commit message. It is reproducible, since I can rebuild the entire history from commits alone. It is secure, since access runs through a personal token against a private repo. And it is structured, with Markdown staying the source of truth for content while a small metadata record carries the behavior.

The deeper lesson is one I keep relearning: the best version of this system was the one with the fewest parts. Every layer I removed (the second branch, the draft repo, the access switch, the multi-source fallback) was a layer that could fail or fall out of sync. What is left fits the principle of single responsibility cleanly: content lives in one repository, visibility is decided by metadata, and rendering happens in the frontend. Draft, schedule, publish, all of it is just text, commits, and three fields.

Looking ahead

There is still more to do. A natural next step is a GitHub Action that validates metadata on every push, so a post with a missing date or a malformed entry never reaches main. I would also like a small dashboard to manage drafts and the publishing calendar visually, while keeping GitHub as the underlying store. The same model could serve internal documentation, research notes, or lightweight course material just as well.

In the end the choice of GitHub was not really about convenience. It was about control, and about keeping the system small enough that the control stays real. The same principles that make version control essential for code, clarity, reversibility, and a single source of truth, turn out to be just as valuable for ideas.