Migrating from MongoDB to Postgres with Feature Flags
By Nitheesh Rajendran · · 7 min read
CodeForge AI is a coding-interview practice platform built as a single Next.js 15 (App Router) application. It has a Monaco-based editor, an online compiler for 12 languages, contests, spaced-repetition revision and an AI mentor. For its first versions, all of its data lived in MongoDB, accessed through Mongoose. Version 3.0.0 moved the whole backend to Supabase: Postgres for data, Supabase Auth for sign-in, and Supabase Storage for uploads.
This article walks through how that migration was structured, with the real code, because the interesting part is not that it moved but how it could move without a big-bang rewrite.
The constraint: one codebase, live users
The app has 33 data models, and most of them reference each other. Users own submissions, notes, bookmarks and progress. Submissions reference questions. Discussions reference users and questions. A single "stop the world, copy everything, switch" migration would have meant one risky deploy covering the entire product.
The plan in MIGRATION.md split the work into phases instead:
- Foundation: Supabase project, clients and a core schema.
- A data-access pattern, proven on one small module (user feedback).
- The full schema: all 33 tables.
- Port each module to read and write through the new pattern.
- Move authentication to Supabase Auth.
- Move file storage.
- Backfill the data.
- Cut over.
Phases 1 to 4 had one rule: merging the migration code must change nothing in production.
The flag that made that possible
Every module chooses its backend through one small function:
export type DataBackend = "mongo" | "supabase";
export function backendFor(module: string): DataBackend {
const per = process.env[`DATA_BACKEND_${module.toUpperCase()}`];
const value = (per ?? process.env.DATA_BACKEND ?? "mongo").toLowerCase();
return value === "supabase" ? "supabase" : "mongo";
}
The lookup order is:
DATA_BACKEND_<MODULE>=supabasemoves a single module, for exampleDATA_BACKEND_FEEDBACK.DATA_BACKEND=supabasemoves everything.- Otherwise the answer is
mongo.
Because the default is mongo, new code could be merged to the main branch constantly while production kept running on MongoDB. Nothing changed until someone set an environment variable in Vercel. At the time of writing, 24 distinct module names are passed to backendFor across 56 source files.
One repository, two backends
Route handlers do not touch Mongoose models directly. They call a repository function, and the repository picks the backend. Here is the shape of the first module that was ported, user feedback (trimmed):
const be = () => backendFor("feedback");
export async function createFeedback(input: FeedbackInput): Promise<void> {
if (be() === "supabase") {
const { error } = await supabaseAdmin().from("feedback").insert({
type: input.type,
title: input.title,
description: input.description,
email: input.email ?? "",
user_id: toUuidOrNull(input.userId),
});
if (error) throw new Error(error.message);
return;
}
await connectDB();
await Feedback.create({ /* same fields, camelCase */ });
}
Both branches take the same input and return the same output, so the rest of the app cannot tell which database answered. Feedback was a good pilot: it is small and has no complicated references. It proved the pattern end to end before it was applied to the 20-odd other modules.
Schema decisions that paid off later
The Postgres schema followed a few fixed conventions:
- Every table has a
uuidprimary key and alegacy_mongo_id text uniquecolumn. That column is what makes the later backfill re-runnable and lets old ObjectId references be translated. - Embedded Mongo sub-documents (
stats,preferences,test_cases,test_results) becamejsonbcolumns, which avoided redesigning them into tables. - Columns use
snake_case, with the data layer mapping to the app's camelCase. - Row-level security is enabled with no policies, so only the service-role backend can read or write. Anonymous clients get nothing.
The ObjectId problem
A Mongo ObjectId is a 24-character hex string. It does not fit a uuid column. While users were still stored in MongoDB, session.user.id was still an ObjectId, so any Supabase table with a link to a user had nothing valid to store. The code handles that explicitly:
export function toUuidOrNull(id?: string | null): string | null {
return id && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) ? id : null;
}
If the id is already a uuid it is used. Otherwise the link is stored as null and re-linked during the users backfill.
Why users had to move first
The migration notes call authentication "the critical path". Almost every module filters by the current user's id, and the "interconnected core" (users, questions, submissions, bookmarks, notes, progress, discussions and the AI tables) references itself. Its Supabase reads only work once all of it has been backfilled, so those modules could not be flipped one at a time. They flip together.
Standalone modules with no cross-references, such as feedback and job applications, were backfilled and switched on their own.
The backfill: idempotent, dependency-ordered, dry-run by default
The generic backfill script (scripts/backfill/all.mjs) follows a few rules:
- It runs in dry-run mode by default and only writes with
--apply. - It is idempotent. It reuses ids already assigned to a legacy document, so it can be re-run safely.
- It walks the tables in dependency order, so a referenced row exists before anything that points to it.
- It keeps an id map from each legacy ObjectId to the new uuid. For every document it renames reference fields (
userbecomesuser_id,questionbecomesquestion_id), translates them through the map, and drops fields that have no column.
The function that cleans each value before it goes into a jsonb column shows how much detail the job involves:
function clean(v, idMap) {
if (isOid(v)) { const h = v.toString(); return idMap.get(h) ?? h; }
if (v instanceof Date) return v.toISOString();
if (typeof v === "string") return v.replace(/\u0000/g, ""); // Postgres text rejects null bytes
if (Array.isArray(v)) return v.map((x) => clean(x, idMap));
if (v && typeof v === "object") { /* recurse into every key */ }
return v;
}
The null-byte line is the kind of problem you only find with real data: MongoDB accepts strings containing \u0000, and Postgres text columns do not.
The authentication switch
Moving sign-in was the largest change, and it followed a different strategy from the data. The plan was a hard switch in one deploy, with no half-migrated state:
- Supabase Auth owns the
auth.userstable. The app'spublic.userstable became a profile table whoseidequalsauth.users.id. - NextAuth was replaced by Supabase session cookies. The middleware now validates the session with
getClaims(), and the admin check readsapp_metadata.role. - Existing bcrypt password hashes were imported, which meant users did not have to reset their passwords.
- Google and GitHub sign-in were re-linked by email on a user's next OAuth login.
- The rollback plan was to revert the deploy, because MongoDB stayed intact as a fallback.
The cutover was a short runbook: backfill users, backfill everything else, set DATA_BACKEND=supabase and the Supabase variables in Vercel, deploy, and then check sign-in, sign-up, Google, GitHub, password reset, the admin gate and a protected route.
Timeline
According to the git history, Phase 1 was committed on 12 July 2026 and release v3.0.0 ("Supabase platform + passkeys + auth redesign") on 13 July 2026. The migration notes record the auth work passing typecheck, lint and 100 tests before it went out.
What broke, and what it teaches
Even with the flag and the backfill in place, some code still assumed the old id format. Around the cutover, the history contains a fix named "accept uuid ids in request schemas (was ObjectId-only length(24))" and a documentation update noting that the OpenAPI id fields are now Supabase uuids. Both come from the same cause: an identifier's format leaks into more places than the database. Request validation had assumed 24-character ids, and the API docs described them.
The migration notes also carry a reminder worth repeating: credentials shared in plain text during setup should be treated as exposed and rotated.
A checklist for your own migration
- Put the backend choice behind one function with a safe default, so merging is never the same as switching.
- Prove the pattern on one small, standalone module first.
- Keep a legacy-id column so the data copy can be re-run and references can be translated.
- Backfill in dependency order, with a dry-run mode and idempotent writes.
- Move identity first, because everything else references it.
- Search for every place an id's format is assumed: validation schemas, API docs, URLs and tests.
- Keep the old database intact until the new one has proven itself.
Source and authorship
This article describes the author's own project, CodeForge AI. Code excerpts are shortened from its repository, which is MIT licensed.