Migrating Away from Supabase — A Practical Field Report
What actually happened when we replaced Supabase with a Git-First CMS: the decision, the migration steps, what broke, and what we gained.
ADR-008 on jootacee.com deprecated Supabase in favor of a Git-First CMS architecture. This is the honest account of that migration: why the decision was made, how the data moved, what broke during the transition, and whether the tradeoffs were worth it three months later.
Contents
The Setup
Supabase was the original persistence layer for jootacee.com. It stored journal articles, project metadata, resource links, and CMS configuration. The choice made sense at the start: Postgres with a generous free tier, REST and realtime APIs out of the box, an admin UI for data entry, and solid TypeScript client support.
The problem emerged not from Supabase's quality — it's a good product — but from the fundamental mismatch between a database-backed CMS and a statically exported Next.js site.
The Three Problems
Problem 1: When do you fetch? Static export means no server runtime. Data must be fetched either at build time or client-side. Build-time fetches from Supabase add a network dependency to CI — if Supabase is having an incident, the build fails. Client-side fetches mean the content isn't in the initial HTML, which hurts SEO and performance. There's no clean middle ground.
Problem 2: Content is not versionable. Git gives you history, diffing, branching, and peer review for free. A Postgres row gives you none of these unless you build an audit log system yourself. When I wanted to review what changed in an article last month, I was looking at Supabase's row-level timestamp — not a diff. When I wanted to draft a new article without publishing it, I needed a draft boolean column and filtering logic everywhere. In Git, that's just a branch.
Problem 3: The admin panel complexity. The CMS admin panel reads from and writes to Supabase. With static export and no API routes, writes require a separate serverless function or direct client-side calls to the Supabase REST API. The authentication, the RLS policies, the data validation — all of it lived in Supabase. Moving to a static export meant either keeping this serverless complexity or rethinking the model entirely.
ADR-008 chose to rethink the model.
The Decision
Git becomes the canonical content source. Content lives in src/content/ as MDX and JSON files committed to the repository. The admin panel, in its current form, becomes read-only for content until a proper Content API (planned for Phase 3 on the VPS backend) is implemented.
The immediate tradeoffs were clear:
- We gain: Content history in Git, content diff review in pull requests, no network dependency in CI, content portable to any future system, SEO-friendly static rendering of content
- We lose: Browser-based content editing (temporarily), real-time content updates without a deploy, non-developer content authors (permanently, unless Phase 3 ships)
For a developer portfolio with one author, the loss is acceptable. For a content team of five, it would not be.
The Migration Steps
Step 1: Export all Supabase data. Used the Supabase dashboard to export all tables as CSV, then wrote a migration script to transform each row into the target file format:
// scripts/migrate-articles.mjs
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { parse } from 'csv-parse/sync';
const rows = parse(readFileSync('./exports/articles.csv'), { columns: true });
for (const row of rows) {
const frontmatter = [
'---',
`slug: ${row.slug}`,
`title: "${row.title.replace(/"/g, '\\"')}"`,
`date: "${row.created_at}"`,
`category: ${row.category || 'essays'}`,
'---',
'',
].join('\n');
const content = frontmatter + row.body;
const path = `./src/content/articles/${row.slug}.mdx`;
writeFileSync(path, content, 'utf-8');
console.log(`Written: ${path}`);
}
Step 2: Define the content schema. Added JSON Schema definitions in src/content/_schema/ and Zod schemas in src/lib/content/schema.ts. The Zod schema validates every piece of content at build time — missing required fields, invalid dates, or unknown category values fail the build rather than silently producing malformed pages.
Step 3: Build the content readers. Functions in src/lib/content/ read MDX files from the filesystem using Node's fs module (available only during build via RSC or generateStaticParams). The reader parses frontmatter, validates against the Zod schema, and returns typed content objects.
Step 4: Wire up the routes. Updated generateStaticParams in article routes to read slugs from the filesystem instead of querying Supabase. Updated page components to receive content as props from generateStaticParams data, not from a client-side fetch.
Step 5: Freeze Supabase. Added an ESLint rule that flags any new import from @supabase/supabase-js or src/lib/supabase/. The existing Supabase code stays in the codebase but is unreachable from any new code path.
What Broke
Three things broke during the migration that required debugging:
Image URLs. Article images stored in Supabase Storage used signed URLs. After migration, those URLs were hardcoded in the MDX body content. The signed URLs expire. Solution: downloaded all images to public/content/images/ and ran a find-replace across all MDX files to update the URLs.
Date formatting inconsistencies. Supabase stored timestamps with timezone (2026-01-15T10:23:45+00:00). The migration script normalized these to 2026-01-15T00:00:00.000Z but several rows had NULL dates. The Zod schema's z.string().datetime() validator caught these immediately at build time, which was the correct behavior.
Slug conflicts. Two articles had the same slug with different capitalizations in Supabase (my-article and My-Article). The filesystem is case-insensitive on macOS (where development happened) but case-sensitive on Linux (where CI runs). The second file silently overwrote the first during migration. Added a post-migration validation script that detected duplicate slugs.
What Improved
The build is now deterministic. Supabase network calls are gone from CI. A content change is a pull request that gets reviewed and tested the same as a code change. Article drafts are branches. Publishing is merging to main.
The content files are also significantly smaller than the Supabase rows. Supabase stores row metadata, user audit information, and internal Postgres overhead. An MDX file is just the content.
Search and analysis are now trivially easy: grep -r "keyword" src/content/ finds all articles mentioning a term. No Supabase query, no API call, no rate limiting.
Three Months Later
The migration was worth it. The constraint of "content changes require a deploy" has not been a problem in practice. The site deploys in under two minutes from a push to main. Content updates are not urgent enough to make two minutes feel slow.
The one real cost is that non-technical content editing requires a GitHub account and comfort with MDX. For this site, that's acceptable. For any site where a content team needs to publish without developer involvement, a Git-First CMS without a web editor is a dead end. Phase 3's Content API on the VPS is meant to address this — a small Express API that accepts POST requests, writes MDX files, and commits to Git. Until then, the constraint is real and should not be sold as a feature.