Firebase vs. Postgres: Six RLS Policies a Client Can Never Reach
Grep-verified: zero client components query a table directly, so this schema's six row-level-security policies defend a path the browser never uses.
Firestore secures data with rules evaluated on every client read; Postgres secures it with row-level security evaluated by the database. Most comparisons stop at "document versus relational" and leave it there. This storefront's own schema makes a sharper point: zero of its client-side code ever queries a table directly, so its six row-level-security policies are defending a read path that no browser request can reach in the first place — a security posture Firestore's client-first model can't produce even in principle.
The short answer
Firestore is a NoSQL document database with real-time listeners and a security-rules language evaluated per document, per client request. Postgres is relational, and — as this app runs it, through Supabase — is queried only from server code, with row-level security as a second layer behind a boundary the client never crosses. Pick Firestore when the client needs to read data directly and update live (chat, presence, collaborative editing). Pick a server-fetched Postgres model when you can afford to route every read through your own backend and would rather not reason about per-document security rules at all.
The comparison that matters
| Firestore | Postgres via Supabase (this app) | |
|---|---|---|
| Data shape | Collections of schema-flexible JSON documents | Relational tables, schema enforced by migrations |
| Who queries the database | The client SDK, directly, from the browser | Nothing — every read happens in a Server Component or Server Action; the browser never holds a database credential |
| Security enforcement | Firestore Security Rules, evaluated per document on every client read | 6 row-level-security policies, evaluated by Postgres — but only reachable if something queries the table, and nothing client-side does |
| Real-time updates | onSnapshot() listeners, built in | None — pages are prerendered or re-fetched on navigation; no live subscription exists in this app |
| Complex queries | Limited joins; denormalization is the standard fix | Full SQL — a real join across orders, entitlement_slots and profiles is one query |
| Where "auth check" lives | Inside the security rule itself, evaluated per request | In auth.uid() inside the policy, and only exercised by server code that already knows who's asking |
The "who queries the database" row is the one this repo can speak to with a grep, not an opinion.
Six policies, and a query surface that never reaches them
This schema's row-level security is standard shape — six for select policies, one per table, comparing the authenticated user's ID (the tables themselves are walked through in what a database schema actually is):
-- supabase/migrations/0001_commerce_init.sql
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);
-- supabase/migrations/0008_refund_requests.sql
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);
On a Firestore-shaped app, six rules like these would be the entire server-side security story — the client SDK queries orders directly, and the rule is the only thing standing between a signed-in user and someone else's row. Here, that is not what's happening. Grep this repo's two client components that import a Supabase client at all:
src/components/organisms/Header.tsx: .then((supabase) => {
src/components/organisms/Header.tsx: void supabase.auth.getUser().then(({ data }) => {
src/components/organisms/Header.tsx: } = supabase.auth.onAuthStateChange((_event, session) => {
src/components/molecules/BuyButton.tsx: const supabase = await loadSupabaseClient();
src/components/molecules/BuyButton.tsx: } = await supabase.auth.getUser();
Every call is .auth.getUser() or .auth.onAuthStateChange() — identity only. There is no .from("orders"), no .from("profiles"), nowhere in src/components. Every actual table read in this app happens in a Server Component or a Server Action, using src/lib/supabase/server.ts, on a connection the browser never touches. The six policies above are real and correctly written, but in this app's current shape they are defense-in-depth for a query path that does not exist on the client — not the load-bearing security boundary a Firestore rule would be.
Why that split exists: three clients, one job each
src/lib/supabase/client.ts — browser client, identity only (getUser, onAuthStateChange)
src/lib/supabase/server.ts — server client, used by every actual data read
src/lib/supabase/admin.ts — service-role client, bypasses RLS for audited writes
src/lib/supabase/lazyClient.ts — dynamic import so the browser client's ~68 KiB doesn't ship to pages with no account UI
There is a billing consequence to that split as well, since Firestore charges per document read and a client-first app makes far more of them — the same "which meter is running" question Firebase Hosting cost works through for static files, where storage turns out to be free at this scale and transfer is the line that bills.
A Firestore app cannot make this split even if it wanted to: the whole point of the client SDK is that the browser is the primary query path, so the security rule has to do the job a server boundary does here. That is a real advantage when the client genuinely needs to read live — a chat thread, a presence indicator, a collaborative document — because there is no server round-trip to write. It is not an advantage for a storefront whose reads are "does this user own this order," which server code can answer once and hand down as already-scoped JSON.
Where Firestore actually wins the argument
To be specific about what "real-time, built in" buys you: onSnapshot() pushes a diff to every subscribed client the moment a document changes, with no extra infrastructure. Reproducing that on this stack means adding a pub/sub layer or polling — Postgres has no client-push primitive of its own. If this app needed a live order-status ticker that updates without a refresh, Firestore's model would cost nothing extra and this one would cost a WebSocket layer. It doesn't need that today; every page here is either prerendered or re-fetched on navigation, and the checkout flow resolves synchronously through a webhook rather than a live subscription. That's a decision this app made, not a limitation of Postgres — Supabase ships its own Realtime feature on top of Postgres's logical replication, at the cost of running something closer to Firestore's model again.
Mistakes and troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| A Firestore Security Rule silently denies a read that "should" work | Rules evaluate against the request's auth context and the document's own fields — a missing request.auth.uid check or a stale field reference fails closed | Test rules with the Firestore emulator's rule-unit-testing library, not by re-reading the app code |
| RLS policy written, data still exposed | A server client using the service-role key bypasses RLS entirely, by design | Keep service-role usage in one server-only-guarded module and audit every call site |
auth.uid() returns null in a Postgres policy | No verified JWT reached the request — usually a session-refresh step was skipped | Confirm the auth proxy or session-refresh middleware ran before the query |
| Denormalizing a Firestore collection to avoid a join, then the two copies drift | Firestore has no cross-document transaction that spans collections by default | Use a Cloud Function trigger to keep denormalized copies in sync, or reach for Postgres if joins are frequent |
| Assuming RLS alone proves a table is safe | A policy that is never queried against isn't exercised, and a policy with a subtly wrong using clause can pass review unnoticed | Grep for every .from("<table>") call site and confirm which ones are client vs. server before trusting the policy list as documentation |
Frequently asked questions
Is Firestore or Postgres faster? Neither wins outright — they're optimized for different shapes. Firestore is fast at single-document reads and real-time fan-out; Postgres is fast at relational queries (joins, aggregates, filters across tables) that Firestore would need denormalization or multiple round-trips to answer.
Can Postgres do real-time updates like Firestore?
Yes, through an add-on rather than natively — Supabase Realtime listens to Postgres's logical replication stream and pushes changes to subscribed clients, which is the closest equivalent to onSnapshot(). Plain Postgres has no built-in push mechanism.
Does row-level security replace application-level authorization? Only for reads that actually go through it. As this schema shows, a correctly written RLS policy provides no protection if nothing ever queries that table with a client-scoped connection — the security boundary is wherever the query actually originates, not wherever the policy is defined.
If I'm already using Postgres, is there any reason to add Firestore too? The main reason is real-time client subscriptions without building your own push layer — chat, live cursors, presence. For a storefront-shaped app with server-rendered reads and no live-collaboration surface, adding Firestore would mean maintaining two databases for a feature this app doesn't have.
Does moving from Firestore to Postgres mean giving up the client SDK entirely?
It means changing what the client SDK is for. This app's browser-side Supabase client (src/lib/supabase/client.ts, loaded lazily through lazyClient.ts) still exists — it just never calls .from(). Its only job is identity: getUser() and onAuthStateChange() for the header's signed-in state and the buy button's ownership check. A Firestore migration in the other direction would need to replace every onSnapshot() call with either a server fetch on navigation or a bespoke subscription layer, since Postgres gives you neither for free.
Templates in this post
ASoc Reach markets an AI-driven marketing agency, leading with a performance-analytics hero, an achievements band, and an eight-item AI services grid. ASoc Realm is a property-management platform site built around an occupancy-metrics hero, with per-sector use cases and a 50+ integrations grid. ASoc Relay markets a team-messaging platform with a live chat-widget hero, outcome stats, and five messaging solution blocks.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
