Building a Firestore-Compatibility Shim So a Live Migration Didn't Require Rewriting Every API Route
Migrating off Firebase didn't have to mean rewriting every API route. Emulating the old interface on top of the new database turned a multi-week rewrite into one file.
Migrating ShabelleHub off Firebase meant moving the database. It didn't have to mean rewriting every API route that talked to it — and the difference between those two projects was the difference between a weekend of work and a multi-week rewrite with a much larger blast radius for new bugs.
The site had dozens of API routes and data-access functions built directly against the Firestore client SDK: .collection('posts').doc(slug).get(), .where('status', '==', 'published').limit(10), that kind of chained, document-oriented query syntax. Firestore and Postgres are not the same kind of database — one is a schemaless document store, the other is relational with actual SQL underneath. Swapping the database out from under that code without touching the code itself sounds like it shouldn't be possible. It mostly was.
The Real Problem Wasn't the Database, It Was the Call Sites
Rewriting the database layer is the contained, well-understood part of a migration like this — design a schema, write the queries, move the data. The expensive, error-prone part is everywhere else: every API route, every page's getStaticProps, every admin panel function that had learned to speak Firestore's specific method-chaining dialect. On a project this size, that's not three or four call sites. It's dozens, scattered across the entire codebase, each one a chance to introduce a subtle bug during a mechanical find-and-replace that isn't actually mechanical once you're staring at the two hundredth call site at midnight from a phone screen.
The insight that made the migration tractable was separating two questions that are easy to conflate: *where does the data live* and *what does the code that reads it look like*. Those don't have to change at the same time. If the second one can stay exactly the same, the first one becomes a much smaller, much safer project.
Emulating an Interface Instead of Replacing It
The shim's job was to expose the same shape Firestore's client SDK exposes — .collection(name), .doc(id), .where(field, op, value), .get(), .set() — while translating every one of those calls into an actual Postgres query against Supabase underneath. Existing code calling db.collection('posts').where('status', '==', 'published').limit(10).get() didn't need to know or care that the thing on the other end of db was now a thin wrapper generating a SELECT statement instead of a Firestore document read.
A simplified sketch of the shape:
function makeFirestoreShim(supabase) {
return {
collection(table) {
let filters = [];
let limitVal = null;
const builder = {
where(field, op, value) {
filters.push({ field, op, value });
return builder;
},
limit(n) {
limitVal = n;
return builder;
},
doc(id) {
return {
async get() {
const { data } = await supabase
.from(table).select('*').eq('id', id).single();
return { exists: !!data, data: () => data };
},
async set(value) {
return supabase.from(table).upsert({ id, doc: value });
},
};
},
async get() {
let query = supabase.from(table).select('*');
for (const f of filters) {
query = query.eq(`doc->>${f.field}`, f.value);
}
if (limitVal) query = query.limit(limitVal);
const { data } = await query;
return { empty: data.length === 0, docs: data.map(toSnapshot) };
},
};
return builder;
},
};
}
That's a simplification of the real thing — the actual shim had to handle Firestore's snap.data() pattern, timestamp conversion, and a handful of query operators the codebase actually used — but the shape holds: same method names, same chaining, same return shapes, completely different engine doing the work behind it.
Why the Schema Underneath Mattered
None of this works cleanly if the Postgres schema fights the shape the shim is trying to emulate. The tables were designed with a deliberate id text plus doc jsonb pattern — a primary key column alongside a single JSONB column holding the rest of the document, structurally close to what a Firestore document actually looks like: a flat bag of fields under an ID, not a rigid set of typed relational columns. That similarity is what let the shim translate .doc(id).set(value) into a straightforward upsert({ id, doc: value }) instead of having to decompose an arbitrary document shape into a dozen separate typed columns on every write.
The tradeoff is real and worth naming honestly: a JSONB blob column isn't as strictly typed or as query-optimizable as first-class relational columns would be, and some of that flexibility gets paid for later — the referential-integrity gaps this schema has run into elsewhere on the site are a direct consequence of the same design choice. But for the specific job of making a Firestore-shaped shim work well enough to leave application code untouched during a live migration, it was close to the only schema shape that made the shim simple instead of a second, harder rewrite hiding behind the first one.
What This Bought, Concretely
Every lib/cms/*.js data-access module got rewritten against Supabase directly and given identical exported function signatures to what it replaced — same function names, same parameters, same return shapes — so files that imported from those modules needed zero changes regardless of which layer they were calling into underneath. API routes calling into the shim needed no changes at all. The actual database swap — schema design, RLS setup, Auth configuration, the migration of the data itself — stayed contained to the layer meant to absorb that complexity, instead of leaking into every route that happened to touch the database.
The Honest Limit of This Approach
A compatibility shim is a deliberate, temporary bridge, not a permanent architecture. It buys time and safety during a migration by making the new backend look like the old one from the call site's perspective — but every call site still carries Firestore's document-store assumptions baked into how it queries data, which is a ceiling on how well it can use anything Postgres does better: joins, aggregate queries, real relational integrity. The shim was the right tool for getting off Firebase without a multi-week rewrite happening under time pressure. It was never meant to be the final state, and treating it as one would trade a fast, safe migration for a slow, permanent tax on every query written afterward.
Takeaway: when a migration's real risk is hundreds of scattered call sites rather than the data itself, consider emulating the old interface on top of the new backend instead of rewriting every caller at once. It's a deliberate, temporary shim, not a final architecture — but it can turn a project that touches everything into one that touches one file.
📬 Get the latest AI tool reviews
Expert picks and comparisons, weekly. No spam.