Git-First CMS Architecture Explained
Traditional headless CMS databases create operational coupling that undermines static site generation. Moving content to Git eliminates the coupling…
An architectural decision record expanded into a full field report: the motivations, tradeoffs, implementation details, and operational consequences of migrating a Next.js portfolio site from Supabase-backed content to Git-committed MDX files.
Contents
We deprecated Supabase. Not because Supabase is bad — it is an excellent product. We deprecated it because we had been using it for a purpose it was not the right tool for, and the cost of that mismatch had been accumulating quietly for months before we named it.
This is the architecture decision record for ADR-008, expanded with the context and operational consequences that never fit in a formal ADR.
The Problem We Were Solving
The original architecture used Supabase as the backend for journal content. Articles were stored in a journal_posts table. The admin panel could create, edit, and publish articles. The public site fetched published articles from Supabase at build time via server-side data fetching.
On paper, this makes sense. A CMS with a database backend is a well-understood pattern. The problem was the mismatch with our constraints:
- The site uses
output: 'export'— fully static HTML generation, no server runtime - The build must produce the same output on every CI run (reproducibility)
- Article versioning should be visible in Git history, not buried in database change logs
- Articles are technical documents that benefit from code review, not just editorial review
With Supabase as the content source, every build was coupled to the availability and state of an external service. If Supabase was unreachable, the build failed. If someone published an article while CI was running, different builds produced different outputs. The database was a build-time dependency, not a runtime dependency — and that is a fundamentally different (and worse) operational model.
What "Git-First" Actually Means
Git-first means the repository is the canonical source of truth for all content. Every article is a committed MDX file. Every edit is a commit. Every review is a pull request. Publication is a merge to main followed by a Cloudflare Pages deploy.
The implications compound:
- Build reproducibility — a specific git commit hash produces a deterministic build output, always
- Versioning for free —
git log src/content/articles/my-post.mdxshows the full history of every edit - Rollback for free —
git revertundoes a publication in one command, and the next deploy reflects it - No external service dependency at build time — the build is hermetic
- Code review for content — the same PR process that reviews code changes can review article drafts
What it does not mean is that the admin interface disappears. The admin interface still exists. In the transition, it became read-only — it can display content metrics and analytics, but it cannot write new content. Writing happens through the file system and Git. Phase 3 of the CMS migration will restore write capability through a Content API on the VPS that commits directly to the repository.
The Migration
Moving from Supabase to Git content required:
1. MDX Frontmatter Schema
Every article needed a consistent frontmatter schema that captured what the database schema had captured. The mapping was mostly straightforward:
---
slug: my-article-slug
title: Article Title
excerpt: One-line description for cards and RSS
abstract: Longer abstract for article detail pages
date: "2026-06-10T00:00:00.000Z"
category: research | essays | opinion | news
depth: quick-read | deep-read
tags:
- tag-one
- tag-two
readTime: 10
coverImage: /og/en/git-first-cms-architecture.png
---
The only field that required design work was status. In the database, articles had a status field that was set to published, draft, or archived. In Git-first, the status is implicit: committed to main = published, branch = draft, deleted = archived. We kept an optional status field for cases where we want a committed file that is not publicly visible — useful for content under review that has already been merged to main.
2. Content Loaders
The build-time content loading moved from Supabase client calls to file system reads with gray-matter parsing. The loader produces the same typed output that the Supabase version produced, so component code required zero changes:
// src/lib/content/loaders.ts
import { readFileSync, readdirSync } from 'fs'
import { join } from 'path'
import matter from 'gray-matter'
const ARTICLES_DIR = join(process.cwd(), 'src', 'content', 'articles')
export function loadArticles(): Article[] {
const files = readdirSync(ARTICLES_DIR).filter(f => f.endsWith('.mdx'))
return files
.map(filename => {
const raw = readFileSync(join(ARTICLES_DIR, filename), 'utf-8')
const { data } = matter(raw)
return parseArticleFrontmatter(data, filename)
})
.filter(a => a.status !== 'draft')
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}
3. Scripts That Used Supabase
The RSS generator and OG image generator both fetched from Supabase. Both were rewritten to read from MDX. The Supabase fetch was replaced with the same gray-matter file reader, and the output was identical. These scripts are now hermetic — they run without network access, which is correct for build-time scripts.
The Tradeoffs
Git-first is not strictly better than database-backed in all dimensions. The honest tradeoffs:
Better: Build reproducibility, versioning, rollback, no external service dependency, code review for content, offline editing
Worse: Non-technical editors cannot self-serve (they need to understand Markdown and Git, or use a Git-backed CMS UI like Netlify CMS or Tina), real-time content preview requires running the build, structured queries across content require file system traversal rather than SQL
For a developer portfolio where the author is the only editor, the tradeoffs strongly favor Git-first. For a multi-editor publication where marketing team members need to publish autonomously, a database-backed CMS with a proper editorial interface is the right tool.
Phase 3 — Restoring Write Capability
The current state is read-only admin. Phase 3 will add a Content API on the Hostinger VPS that:
- Accepts authenticated write requests (article create/update) from the admin UI
- Commits the MDX file to the repository via the GitHub API
- Triggers a Cloudflare Pages deploy via webhook
This restores the full authoring workflow — write from the admin panel, content goes to Git, site redeploys automatically — while keeping Git as the canonical source of truth. The VPS is a write proxy, not the content store. If the VPS goes down, the site continues to serve content. The repository is always the authority.
The operational model is: GitHub is the database. Cloudflare Pages is the delivery layer. The VPS is the write API. No single point of failure can take down both the site and the ability to publish corrections.
Lessons
The lesson is not "databases are bad for content." The lesson is that the canonical source of truth for a static site should be compatible with the static site's operational model. Our static site has no server runtime and needs hermetic builds. Those requirements make Git a better fit than a database for this specific context.
The broader pattern: before choosing infrastructure, state your constraints explicitly. "Static site that must build without network access" is a constraint that directly determines what content source is appropriate. Most teams skip the constraint definition and jump to tool selection, which is why they end up rebuilding the architecture later.