Firebase vs PlanetScale: One Is a Backend, One Is Only a Database
Firebase bundles auth, storage and functions; PlanetScale is MySQL alone. Audited against 271 lines of production SQL, 7 foreign keys and 8 CHECK constraints.
Most Firebase-versus-PlanetScale comparisons line the two up in a feature grid as if they were competitors. They are not the same kind of product: Firebase is a bundle — database, auth, file storage, functions, messaging — while PlanetScale is a database and nothing else. Picking between them is really picking how many vendors your backend has.
The short answer
Choose Firebase when you want identity, storage and serverless functions to arrive with the database and you can accept a schemaless document store. Choose PlanetScale when your data is relational, you want MySQL with branch-and-deploy-request migrations, and you are willing to bring your own auth, your own file storage and your own server layer.
The useful question is not which is better. It is: what does my application need from a backend, and how much of that list does each one cover? This post answers it against a real list — the 271 lines of SQL this storefront's commerce runs on.
The comparison
| Firebase | PlanetScale | |
|---|---|---|
| What it is | A backend suite (Firestore/RTDB, Auth, Storage, Functions, Hosting) | A managed database (MySQL via Vitess; Postgres also offered) |
| Data model | Schemaless documents | Relational tables with a declared schema |
| Joins | None — you denormalise or read twice | Standard SQL joins |
| Foreign keys | Not a concept | Supported, with the usual sharding caveats |
| Constraints | Validation lives in rules or app code | CHECK, UNIQUE, NOT NULL in the schema |
| Row-level authorization | Security Rules, evaluated per document | None — it lives in your application's WHERE clauses |
| Identity | Firebase Auth, included | Not included |
| File storage | Cloud Storage, included | Not included |
| Server logic | Cloud Functions, included | Not included |
| Migrations | Nothing to migrate; shape drift is a runtime surprise | Database branches and deploy requests, reviewed and non-blocking |
| Free tier | Yes (usage-based above it) | Removed in 2024 |
| Vendor exit | Export Firestore, then rewrite rules and queries | Dump and restore into any MySQL |
Both vendors reprice often enough that any figure here would be stale before you read it; check their own pages. Model the dimension that scales for you — document reads for Firestore, connections and storage for PlanetScale.
What this is audited against
This storefront runs neither. It runs Postgres on Supabase, which is why it can be specific: the requirements below are not hypothetical, they are the things its commerce schema actually does, and each one is a concrete question to put to either product.
The whole backend is eight numbered SQL files under supabase/migrations/ — 271 lines, six tables, and a pure decision engine beside them in src/lib/entitlements.ts (75 lines, zero dependencies). The storefront itself is 111 products across 139 framework editions. That is the scale this schema was sized for, and it is small; nothing below is a complaint about either vendor's ceiling.
Requirement 1: a foreign key into the identity table
Seven columns in this schema point at the user table. Here is one of the tables that holds them, as it stands after 0006 swapped the kind allowlist:
create table public.entitlement_slots (
id uuid primary key default gen_random_uuid(),
order_id uuid not null references public.orders(id) on delete cascade,
user_id uuid references auth.users(id) on delete set null,
claim_email extensions.citext not null,
kind text not null check (kind in ('template_single','all_templates','all_access')),
...
);
Read the two references lines as a policy statement, because that is what they are. Deleting an order deletes the entitlement slots it created (cascade): a slot with no order is meaningless. Deleting a user does not delete their slots, it nulls the link (set null): the purchase still happened, the audit row still has to exist, and the email on the row is what a buyer re-redeems against. Those two different answers are enforced by the database, once, for every caller forever.
Now put that requirement to each product:
- Firebase gives you the identity service in the same bundle — a real advantage — but Firestore has no foreign keys, so "delete the order, cascade to its slots" becomes a Cloud Function you write, test and keep correct. Orphaned documents are the characteristic Firestore bug for exactly this reason.
- PlanetScale gives you the foreign key and no identity table to point it at. You bring an auth provider, and the user ID in your database is now a string copied from another system with nothing enforcing that it exists.
Neither covers the pair. That is the shape of most real comparisons between these two: each one owns half of what you need.
Requirement 2: the schema as the validation layer
Eight CHECK constraints in these migrations, and they are not decoration. This one is the entitlement allowlist:
alter table public.entitlement_slots
drop constraint if exists entitlement_slots_kind_check;
alter table public.entitlement_slots
add constraint entitlement_slots_kind_check
check (kind in ('template_single', 'all_templates', 'all_access'));
That is the whole of 0006_pricing_v2_slot_kinds.sql. A pricing change retired one slot kind and added another, and the migration is a constraint swap — fifteen lines, reviewed in one sitting, applied once. From then on, no code path can write a slot kind the entitlement engine does not understand, including code someone writes against this database next year.
On a document store, that migration does not exist. There is nothing to alter, which reads as a feature until you notice the corollary: there is also nothing stopping a write of the retired kind, and the failure surfaces later, in a reader, as a slot that grants nothing. The validation has to move into Security Rules or application code, where it is duplicated per write path and enforced only as well as the newest path remembers.
PlanetScale covers this requirement properly, and its branch-plus-deploy-request workflow is genuinely better than ours — ours is eight files applied through a CLI. Eight files have never needed a lock-free migration. At a hundred times the write volume that calculus inverts, which is precisely what PlanetScale is built for. (For the Postgres-versus-MySQL half of that trade, Supabase vs PlanetScale does the port line by line.)
Requirement 3: one order and its slots, or neither
A purchase webhook has to create an order row and the entitlement slots that order grants — together, exactly once, under retries from a payment provider that will absolutely deliver the same event twice. Here is how that is spelled:
insert into public.orders (ls_order_id, ls_customer_id, email, tier, total_cents, currency, status, user_id, raw)
values (p_ls_order_id, p_ls_customer_id, p_email::extensions.citext, p_tier, p_total_cents, p_currency, 'paid', p_user_id, p_raw)
on conflict (ls_order_id) do nothing returning id into v_order_id;
if v_order_id is null then
select id into v_order_id from public.orders where ls_order_id = p_ls_order_id;
return query select v_order_id, false; return;
end if;
foreach v_kind in array p_slot_kinds loop
insert into public.entitlement_slots (order_id, user_id, claim_email, kind, status)
values (v_order_id, p_user_id, p_claim_email::extensions.citext, v_kind, 'active');
end loop;
Two things are load-bearing. The unique constraint on the provider's order ID plus on conflict do nothing makes idempotency a property of the schema rather than a flag the webhook handler remembers to check — a duplicate delivery returns created: false and writes nothing. And the order and its slots are one transaction, so there is no window in which a buyer has an order and no entitlement.
Firestore has transactions and batched writes, so this is expressible — but the uniqueness guarantee has to become a document ID you compose from the provider's order ID, which is the standard trick and worth knowing before you need it. PlanetScale is plain MySQL here: INSERT ... ON DUPLICATE KEY UPDATE or an INSERT IGNORE, inside a transaction, and the constraint does the work.
Requirement 4: a private file bucket, with expiring links
Everything this storefront sells is a zip. It lives in a private bucket, and the only way to a byte of it is a short-lived signed URL issued after an authorization check:
const { data, error } = await admin.storage
.from("releases")
.createSignedUrl(path, SIGNED_URL_TTL_SECONDS, { download: downloadName });
That is from src/app/api/download/route.ts, and the decision in front of it is authorizeDownload(slots, target) from the pure engine — re-run per request, never trusted from the client.
- Firebase covers this: Cloud Storage with Security Rules, or signed URLs from the Admin SDK.
- PlanetScale does not store files at all. You add S3, R2 or Supabase Storage, which is fine — it is a third vendor on the invoice and a second set of credentials to rotate.
Adding up the list
| Requirement | Firebase | PlanetScale |
|---|---|---|
| Relational integrity (FKs, cascades) | Write a function | Built in |
| Declared schema + constraints | App-level | Built in, with reviewed migrations |
| Joins for a multi-table decision | Denormalise | Built in |
| Identity | Built in | Bring your own |
| Row-level read authorization | Security Rules | Application WHERE clauses |
| File storage with signed URLs | Built in | Bring your own |
| Server-side logic | Cloud Functions | Bring your own |
| Atomic multi-row write | Transactions, with care | Transactions |
Read the two columns as costs rather than scores. Firebase's cost is paid in code: functions to maintain integrity the database will not, rules to enforce shapes the store does not. PlanetScale's cost is paid in vendors: auth, storage and a server layer you assemble. Neither cost is hidden, and both are survivable — what kills projects is not noticing which one you signed up for.
The decision rule
Ask what the hardest question your data has to answer looks like. Ours is "does this person own this edition of this product right now", which reads three tables and is a select — so the whole backend is 271 lines and the decision engine is 75. If your hardest question is shaped like that, you want the relational column, and PlanetScale is a serious answer to it once you have an auth story.
If your hardest question is "sync this user's offline edits across three devices", the relational column is the wrong one and Firebase's mobile SDKs are the reason it exists. That axis is worth being honest about: nothing in Postgres or MySQL competes with a decade of Firestore's offline persistence.
And if the answer is "I need both halves from one vendor", neither of these is the shortlist — that is the bundled-Postgres category, which is Firebase vs Postgres territory, or Firebase vs Convex if a document store is still on the table.
Troubleshooting and migration notes
| Situation | What goes wrong | What to do |
|---|---|---|
| Moving Firestore → PlanetScale | Documents have no stable relational key | Mint surrogate keys during export; do not reuse document IDs as foreign keys without a uniqueness check |
| Moving PlanetScale → Firestore | Joins have nowhere to go | Denormalise at write time, and accept that each denormalised copy is now a consistency job |
| Webhook delivered twice | Duplicate orders, duplicate entitlements | A unique key on the provider's ID plus a conflict-ignoring insert — not an application-level "have I seen this?" check |
| Auth user deleted | Orphaned rows, or deleted audit history | Decide cascade versus set null per table, deliberately; our seven FKs are split 3/4 on purpose |
| PlanetScale with no server layer | Database credentials end up in a client | Keep the database behind a server; a managed MySQL has no row-level security to fall back on |
| Firestore rules allow a write a function also validates | Clients can bypass the validation | Deny direct client writes to that collection; the function becomes the only path |
| Schema drift noticed by a reader | A retired enum value written months ago | Put the allowlist in a CHECK where the store supports one; otherwise validate at every write boundary |
| Slow query on real data | Missing index — both products need them | Add it; our download audit carries (user_id, created_at) for exactly this |
Frequently asked questions
Is PlanetScale a drop-in replacement for Firebase? No, and it does not claim to be. PlanetScale replaces Firestore. It does not replace Firebase Auth, Cloud Storage or Cloud Functions, so a migration in that direction is three or four decisions, not one. Budget for the auth migration first — it is the one that touches every page.
Does PlanetScale support foreign keys? Yes, with the caveat that comes with any Vitess-sharded deployment: referential integrity is straightforward within a shard and needs design attention across shards. For a schema at this size it is a non-issue; at the scale where sharding matters, foreign-key placement becomes part of the sharding design.
Which one is cheaper? Unanswerable in the abstract, because they meter different things — Firestore bills document reads, PlanetScale bills storage and throughput. The reliable method is to count the operations your hottest page actually performs, then price that. A client-first Firestore app makes far more reads than a server-rendered one; our download route makes two queries and one insert per download.
We just need a database behind Next.js. Does this comparison even apply? Then you are probably not choosing between these two. You want a SQL database plus whatever auth you already have, and the real decision is Postgres versus MySQL and who hosts it. Our stack is the Postgres answer; Supabase vs Neon is the Postgres-to-Postgres version of this audit.
Templates in this post
ASoc Remit (a digital-payments landing page built around a transactions dashboard preview), ASoc Script (an AI copywriting product page with an interactive generator hero) and ASoc Seeker (an AI keyword-generator marketing page) are front ends with no backend assumptions compiled in — either column above drops in behind them.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
