Firebase vs Prisma: One Replaces Your Backend, One Replaces Your Queries
Firebase replaces your backend; Prisma replaces your queries. Audited against 6 RLS policies, 6 SECURITY DEFINER functions and one TOCTOU bug that shipped.
Firebase and Prisma are not alternatives. Firebase replaces your backend — database, auth, storage, functions. Prisma replaces your queries: it is a TypeScript ORM over a relational database you still have to choose, host and secure. Comparing them as competitors answers the wrong question. The real one is how much of a backend you want to own.
The short answer
Choose Firebase when you want identity, file storage and serverless functions to arrive with the database, and a schemaless document store is acceptable. Choose Prisma when your data is relational, your team wants a generated, type-safe client over SQL, and you are willing to bring auth, storage and a server layer yourself.
The trap is a third option that most comparisons leave out, and it is the one this storefront runs: relational Postgres with no ORM at all. Not because ORMs are bad — because the thing this schema needed most, neither product puts in the TypeScript layer.
The comparison
| Firebase | Prisma | |
|---|---|---|
| What it is | A backend suite (Firestore, Auth, Storage, Functions, Hosting) | A client library — ORM, schema DSL, migration tool |
| Brings a database | Yes, Firestore/RTDB | No (Prisma Postgres is a separate hosted product) |
| Data model | Schemaless documents | Relational tables from a declared schema.prisma |
| Joins | None — denormalise or read twice | Standard SQL joins, via relation fields |
| Type safety | Hand-written interfaces over DocumentData | Generated client, types derived from the schema |
| Row-level authorization | Security Rules, evaluated per document by the service | None of its own — your where clauses, or database RLS it does not model |
| 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 | prisma migrate, generated from schema diffs |
| Transactions | Per-document atomicity; multi-document needs a transaction API or function | Interactive transactions, real and good |
| Vendor exit | Export Firestore, then rewrite rules and queries | Drop Prisma, keep the database — it was always just SQL |
That last row is the structural difference. Dropping Firebase means rewriting an application. Dropping Prisma means writing queries by hand against a database you already own.
What this is audited against
This storefront's commerce backend is 8 SQL files under supabase/migrations/, 271 lines total: 6 tables, 7 columns referencing the auth user table, 8 CHECK constraints, 6 indexes. Beside it sits a pure decision engine, src/lib/entitlements.ts — 75 lines, zero dependencies, no database access at all. The storefront is 111 products and 337 blog posts.
Small, in other words. Nothing below is a complaint about either product's ceiling; it is a list of four things this schema does, which are concrete questions to put to each.
Requirement 1: authorization that holds for callers who do not exist yet
Six lines of this schema are the entire read-authorization model:
create policy "own profile" on public.profiles for select using ((select auth.uid()) = id);
create policy "own orders" on public.orders for select using ((select auth.uid()) = user_id);
create policy "own slots" on public.entitlement_slots for select using ((select auth.uid()) = user_id);
create policy "own downloads" on public.download_events for select using ((select auth.uid()) = user_id);
create policy "own deliveries" on public.download_deliveries for select using ((select auth.uid()) = user_id);
create policy "own refund requests" on public.refund_requests for select using ((select auth.uid()) = user_id);
Row-level security is enabled on all six tables, and every policy is select only — read your own rows, write nothing. The property that matters is not brevity. It is that the rule holds for every caller forever, including code written next year by someone who never reads this file, and including a query with a forgotten where.
Put that requirement to each product:
- Firebase has a real answer: Security Rules are declarative, service-evaluated, and genuinely comparable to RLS in spirit. Two caveats. They are a separate language with its own semantics, and the Admin SDK bypasses them entirely — so any server code you write is outside the model, which is precisely where the mistakes live.
- Prisma has no answer in its own layer. Authorization is whatever your
whereclause says, enforced exactly as well as the newest query remembers. Postgres will still enforce RLS if the connecting role is subject to it, but the generated client does not model the policy: you open a transaction, set a session variable, and nothing in the types tells you whether you did. The safety is real and invisible, which is the worst combination for a thing you need to audit.
This is the requirement that decided it here. A generated client makes queries type-safe; it does not make them authorized. Six lines of SQL do, for every caller, forever.
Requirement 2: one operation that is atomic under a provider's retries
A purchase webhook must create an order row and the entitlement slots it grants — together, exactly once, under a payment provider that will deliver the same event twice. Here it is one call:
create or replace function public.create_order_with_slots(...)
returns table(order_id uuid, created boolean)
language plpgsql security definer set search_path = public, pg_temp as $$
begin
insert into public.orders (ls_order_id, ...)
values (p_ls_order_id, ...)
on conflict (ls_order_id) do nothing returning id into v_order_id;
if v_order_id is null then -- replay: the order already exists
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;
return query select v_order_id, true;
end; $$;
revoke execute on function public.create_order_with_slots(...) from anon, authenticated, public;
Idempotency is the unique key plus on conflict do nothing — not an application-level "have I seen this event?" check, which is itself a race. Six functions in this schema are security definer with execute revoked from the client roles, so the only caller is server code holding the service key.
Here Prisma is strong, and this is where it earns its reputation: interactive transactions give you the same atomicity in TypeScript, with the unique constraint still doing the idempotency work. You would write this as a transaction rather than a stored function, and it would be correct. The thing you cannot reproduce is the second half — revoke execute. A Prisma client connects as a role; it does not give you a privilege boundary around a named operation.
Firebase needs a Cloud Function for this, because Firestore's atomicity is per-document and these are two collections. That function is code you write, test and keep correct — and orphaned child documents are the characteristic Firestore bug for exactly this shape.
Requirement 3: the defect we actually shipped
The most useful thing in this schema is a bug. The download endpoint enforced an hourly per-user limit with a check-then-act: read the recent count, compare it to the limit, then insert the audit row in a separate statement. Under concurrency, N parallel requests all read the same count, all passed < limit, and all inserted. The cap was bypassable by clicking fast — CWE-367, and it shipped.
The fix is 0007_atomic_download_rate_limit.sql: count and conditional insert in one security definer function, serialized per user with a transaction-scoped advisory lock, returning the new count or -1 when over the limit, inserting nothing in that case.
-- Serialize concurrent downloads for THIS user so the count+insert is atomic.
-- Transaction-scoped: released on commit. Keyed on the user-id hash, so
-- different users never contend.
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text, 0));
Now ask what each product does with that defect:
- Prisma would have had the same bug, written the same way —
count()thencreate()is the natural ORM shape, and it reads correctly. The fix inside Prisma is a serializable transaction with retry handling, or$executeRawfor the advisory lock. Doable; you are writing raw SQL through the ORM to get there. - Firebase would have had it too. A Security Rule cannot count a user's writes in the last hour, so the limit lives in a Cloud Function, and the check-then-act is just as available there.
Neither product prevents this class of bug. What prevents it is putting the invariant in one place the database enforces — and that is the recurring reason this backend is SQL files rather than a client library. Gated file downloads in Next.js covers the route this protects.
Where Prisma's actual selling point lands
Type safety, and it is not a small thing. The honest comparison is Prisma's generated client against the alternative we chose: types generated from the live schema by the Supabase CLI, consumed by supabase-js. Both derive TypeScript from the database rather than from a hand-written interface, which is the property that matters. Prisma's is more ergonomic — relation traversal, a nicer query builder, and migrations generated from a schema diff instead of hand-written SQL.
What we gave up to skip it: that ergonomics, and prisma migrate. What we kept: CHECK constraints, RLS policies, security definer functions and advisory locks — written directly, in the language that enforces them, with nothing in between to express them through. For 271 lines and 8 migrations, that trade is obvious. At 50 tables and a team of ten, a schema DSL and generated migrations start to pay for themselves, and the RLS story becomes something you design around rather than a reason to decline.
See Supabase vs Prisma for that trade in detail, and generating Supabase types for the no-ORM half.
The decision rule
Ask where your authorization rules live, and count how many places can write a given row.
If the answer is "one server, and the rules are in its code", Prisma is a good choice and the database is yours. If it is "many clients, and the rules must be enforced by the service", Firebase's Security Rules are built for that shape — but check whether your server code bypasses them, because the Admin SDK does.
If the answer is "the rules must hold for callers I have not written yet", put them in the database. Then the ORM question becomes a question about developer ergonomics rather than about safety, which is the right size for it.
And if you need the whole bundle from one vendor, neither of these is your shortlist: that is Firebase vs Postgres territory, or Firebase vs PlanetScale when the database half is what is in question.
Troubleshooting and migration notes
| Situation | What goes wrong | What to do |
|---|---|---|
| Prisma over RLS-enabled Postgres | Policies silently do nothing, or block everything | Check whether the connecting role is BYPASSRLS; set the session variable inside the transaction, per query |
| Firestore → Prisma | Documents have no stable relational key | Mint surrogate keys at export; do not reuse document IDs as foreign keys without a uniqueness check |
| Prisma → Firestore | Joins have nowhere to go | Denormalise at write time, and accept each copy as a consistency job |
| Webhook delivered twice | Duplicate orders and entitlements | Unique key on the provider's ID + conflict-ignoring insert; never an application-level seen-check |
| Rate limit enforced in app code | Check-then-act race under concurrency | One atomic statement, or a lock taken before the read — our 0007 migration is this fix |
| Admin SDK used for user-facing reads | Security Rules bypassed, all rows readable | Keep user reads on the client SDK path; treat admin credentials as server-only |
prisma migrate on a database with policies | Generated SQL drops or reorders policy-bearing objects | Keep policies in checked-in migrations and review every generated diff |
| Auth user deleted | Orphaned rows, or deleted audit history | Decide cascade vs set null per table — our 7 user FKs are split deliberately |
Frequently asked questions
Is Prisma a replacement for Firebase? No. Prisma replaces the data-access layer; Firebase is auth, storage, functions and hosting as well as a database. Replacing Firebase with Prisma means choosing a database, hosting it, adding an auth provider, adding file storage and writing a server — four decisions, not one. Budget for the auth migration first; it touches every page.
Can Prisma enforce row-level security? Postgres enforces it; Prisma does not model it. If the role your client connects as is subject to RLS, policies apply — but you must set the session variable identifying the current user inside each transaction, and the generated types will not tell you if you forgot. Treat it as a database feature you are using through Prisma, not a Prisma feature.
Does Firebase have anything like a CHECK constraint?
Not in the store. Validation lives in Security Rules or application code, so it is duplicated per write path and enforced only as well as the newest path remembers. Our 8 CHECK constraints — including the entitlement-kind allowlist — are enforced once, for every writer, including a writer added next year.
We just need a typed database client behind Next.js. Which one? Then you are not really choosing between these two. You want a SQL database plus the auth you already have, and the decision is whether a generated ORM client or generated schema types fit your team. Convex vs Prisma covers the reactive-backend alternative to both.
Templates in this post
ASoc Beacon (a mobile-device-management site with enrolment, compliance and remote control), ASoc Beaker (a science-lab services site) and ASoc Blueprint (an app-development agency site with services, process and case studies) ship with no backend compiled in — either column above drops in behind them.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
