Skip to main content
ASoc
Comparison

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.

The ASoc Team12 min read

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

FirebasePrisma
What it isA backend suite (Firestore, Auth, Storage, Functions, Hosting)A client library — ORM, schema DSL, migration tool
Brings a databaseYes, Firestore/RTDBNo (Prisma Postgres is a separate hosted product)
Data modelSchemaless documentsRelational tables from a declared schema.prisma
JoinsNone — denormalise or read twiceStandard SQL joins, via relation fields
Type safetyHand-written interfaces over DocumentDataGenerated client, types derived from the schema
Row-level authorizationSecurity Rules, evaluated per document by the serviceNone of its own — your where clauses, or database RLS it does not model
IdentityFirebase Auth, includedNot included
File storageCloud Storage, includedNot included
Server logicCloud Functions, includedNot included
MigrationsNothing to migrate; shape drift is a runtime surpriseprisma migrate, generated from schema diffs
TransactionsPer-document atomicity; multi-document needs a transaction API or functionInteractive transactions, real and good
Vendor exitExport Firestore, then rewrite rules and queriesDrop 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 where clause 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() then create() is the natural ORM shape, and it reads correctly. The fix inside Prisma is a serializable transaction with retry handling, or $executeRaw for 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

SituationWhat goes wrongWhat to do
Prisma over RLS-enabled PostgresPolicies silently do nothing, or block everythingCheck whether the connecting role is BYPASSRLS; set the session variable inside the transaction, per query
Firestore → PrismaDocuments have no stable relational keyMint surrogate keys at export; do not reuse document IDs as foreign keys without a uniqueness check
Prisma → FirestoreJoins have nowhere to goDenormalise at write time, and accept each copy as a consistency job
Webhook delivered twiceDuplicate orders and entitlementsUnique key on the provider's ID + conflict-ignoring insert; never an application-level seen-check
Rate limit enforced in app codeCheck-then-act race under concurrencyOne atomic statement, or a lock taken before the read — our 0007 migration is this fix
Admin SDK used for user-facing readsSecurity Rules bypassed, all rows readableKeep user reads on the client SDK path; treat admin credentials as server-only
prisma migrate on a database with policiesGenerated SQL drops or reorders policy-bearing objectsKeep policies in checked-in migrations and review every generated diff
Auth user deletedOrphaned rows, or deleted audit historyDecide 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.

Keep reading

Comparison11 min read

Gumroad Alternative: The Six Jobs You Take Back In-House

A hosted storefront does six jobs. Another one changes the fee; owning your storefront takes them back as code — 1,607 lines here, file by file.

Read more