Skip to main content
ASoc
Comparison

Supabase vs Neon: Both Are Postgres, So the Database Isn't the Call

Six RLS policies, all built on auth.uid() — a function Postgres does not have. An audit of exactly which of our own code depends on the platform rather than the database.

The ASoc Team10 min read

Both are Postgres, so the database is not the decision. Supabase wires an auth service, object storage and a REST gateway into that Postgres and makes row-level security the enforcement point; Neon sells the Postgres itself, with storage and compute separated and copy-on-write branching on top — and if you are meeting Neon as the database Vercel provisions for you, Vercel Postgres vs. Supabase is the same question asked from the hosting side. The real question is how much of your authorization currently lives in the database — because that is the part that does not port.

This storefront runs on Supabase. What follows is an audit of exactly which of our own code depends on the platform rather than on Postgres, measured from this repository. We have not rebuilt the same stack on Neon, so nothing here is a benchmark of Neon — it is an inventory of what a move would have to replace.

Where the two actually diverge

SupabaseNeon
DatabasePostgresPostgres
Row-Level SecurityPostgres feature, and the intended enforcement layerPostgres feature, available, but not wired to anything by default
IdentityFirst-party auth service issuing JWTs the database readsNot part of the Postgres instance
Object storageFirst-party, with RLS-aware bucketsNot part of the product
Client accessPostgREST gateway + typed JS SDKYour own API layer, or a driver
BranchingNot the headline featureCopy-on-write branches, the headline feature
Scaling modelProvisioned resourcesCompute time, scale-to-zero

Read that table and the split is clearer than "BaaS vs database": Supabase's proposition is that the security boundary can live in the database, and Neon's is that the database should be cheap, forkable and disposable. Those are compatible right up until you have written the security boundary into SQL.

What "authorization lives in the database" looks like in practice

This site's commerce schema is eight migrations and six tables — profiles, orders, entitlement_slots, download_events, download_deliveries, refund_requests. Every read policy on it is one line, and they all have the same shape:

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);

Six policies in total, all of them for select. There is no write policy for any signed-in role anywhere in the schema — every mutation goes through a security definer function with a pinned search_path, six of those, so a client holding the anon key can read its own rows and change nothing at all.

That is a design we would keep on any Postgres. What is not portable is the three characters doing the work: auth.uid().

auth.uid() is not a Postgres builtin. It is a function Supabase's platform defines, which reads a claim out of the JWT that Supabase's auth service issued and its gateway forwarded into the session. Take the platform away and the SQL above is syntactically fine and semantically empty — there is no signed-in identity for the database to compare against, because nothing put one there.

So the honest cost of moving this schema to a bare Postgres is not "rewrite the policies." It is:

  1. Issue and verify JWTs yourself (or adopt a separate identity product).
  2. Get the verified subject into every database session, as a claim or a set_config.
  3. Define your own equivalent of auth.uid() that reads it.
  4. Own the failure mode where step 2 is skipped — because a policy comparing against NULL denies everything, which is the safe direction, right up until a service-role connection quietly bypasses it.

None of that is exotic. All of it is code you did not have before, sitting on the path where a mistake means one customer reads another customer's orders.

The half of our footprint that is not the database at all

Two more dependencies show up in the audit, and neither is Postgres:

Storage. Release zips live in a private Supabase bucket at {slug}/{framework}/{slug}-{framework}-v{version}.zip, and /api/download signs a URL with a 60-second TTL only after the request has passed authentication, entitlement verification and an atomic rate limit — the full ordering is its own post. Neon does not sell object storage; that bucket becomes S3, R2 or equivalent, plus the signing code, plus a second set of access rules that no longer share an identity model with the database ones. That is the largest single line item in a migration, and it is invisible if you compare the two products on the word "Postgres."

Session refresh. src/proxy.ts runs on every matched request and does exactly one thing:

await supabase.auth.getClaims();

getClaims() validates the JWT signature; getSession() would return whatever is in the cookie without verifying it. That distinction is a Supabase SDK concern, and on a different stack it becomes yours to get right.

The number: what the SDK costs the browser

Measured from a production build of this site on 2026-08-26, the Supabase client chunk is 245,083 bytes raw / 63,579 bytes gzipped — the single largest client chunk in the build.

It is also on the first-load path of zero of the site's 28 routes. It used to be on all of them, because the site-wide Header imported createClient at module scope. The fix is a 47-line module whose whole purpose is deferral:

// src/lib/supabase/lazyClient.ts
export function hasAuthCookie(): boolean {
  if (typeof document === "undefined") return false;
  return /(?:^|;\s*)sb-.+?-auth-token/.test(document.cookie);
}

export async function loadSupabaseClient(): Promise<BrowserClient> {
  const { createClient } = await import("@/lib/supabase/client");
  return createClient();
}

Both call sites only touched the client inside an effect, so the import waits for the effect. Visitors with no session cookie never fetch it at all. How that was verified from the build artifacts is a separate post; the point for this comparison is that a batteries-included SDK has a weight, it lands on the client, and it is manageable rather than fatal.

A Neon-based stack does not automatically avoid this. Whatever you use for identity ships a client too. The difference is that you get to choose it separately from the database, which is genuinely worth something if the bundled one is a bad fit.

Where Neon's branching is the better answer

Being fair to the other side: copy-on-write branching is the feature we would most like to have and the one this comparison cannot dismiss. A branch that forks the whole dataset in seconds, per pull request, is a materially different development workflow from applying migrations against a shared database and hoping. If your product's risk lives in schema change — frequent migrations against large, valuable data — that is a strong argument, and the pricing model that scales compute to zero between branches is what makes it affordable.

It happens not to be our risk. This storefront's largest dataset is not in a database at all: the 111-product catalog is a typed TypeScript module, versioned in git, validated by the test suite, and deployed with the site. Only the commerce tables are in Postgres, they are small, and their migrations are numbered files reviewed like any other code. We already get most of what branching offers, from git, for the data that changes most.

That is the same reasoning as our Supabase vs MongoDB comparison: the dataset decides, not the app.

How to make the call

  • Your authorization is already SQL. Policies, auth.uid(), RLS-aware storage — Supabase is not a vendor choice at that point, it is the runtime your security model assumes. Moving means rebuilding the identity plumbing, not editing config.
  • Your authorization is in your API layer. The database is doing storage and nothing else. Neon's branching and compute pricing are real advantages and the switching cost is low, because there is nothing platform-specific in the schema.
  • You are starting now, on a template. Take the batteries-included option, because a working auth flow on day one is worth more than an architecture you have not needed yet — and keep the escape hatch open by not scattering auth.uid() through business logic that could live in one place.
  • You need object storage or realtime. That is a product-boundary question rather than a database one. Adding a second vendor is fine; not noticing you have to is what makes migrations overrun.

The one thing that is not a good reason either way is benchmark numbers. Both run the same Postgres; the difference in a normal web workload is your query plans, not the logo.

Mistakes and how they show up

MistakeHow it shows upFix
Assuming RLS ports because both are PostgresPolicies apply cleanly and authorize nobodyauth.uid() is platform-provided; supply the claim yourself before the policies mean anything
Comparing on "both are Postgres"The migration overruns on storage and identity, not the schemaInventory what is around the database first — buckets, JWTs, the gateway
Using getSession() in server codeTrusts an unverified cookiegetClaims() validates the signature; that is the whole difference
Writing RLS and also connecting with a service-role keyThe key bypasses every policy silentlyKeep service-role usage to a named server-only module and audit its call sites
Choosing on branching when your data is in gitYou pay for a workflow advantage you already hadAsk which dataset actually changes, and where it currently lives
Treating the client SDK's size as unavoidableAuth code on the critical path of pages with no account UIDefer the import to the effect that uses it, then verify against the build

Frequently asked questions

Did you benchmark Neon against Supabase? No. We run Supabase and have not rebuilt this stack on Neon, so this post makes no performance claim about either. What it measures is our own dependency surface — six policies, six security definer functions, one private bucket, one SDK chunk — which is the thing a migration would actually have to move.

Can I use Neon with row-level security? Yes — RLS is a Postgres feature and works on any Postgres. What you have to supply is the verified identity the policies compare against, which on Supabase arrives from its auth service and gateway for free.

Is Supabase just Postgres with extras? Practically, the extras are the product. The database is standard Postgres, and that is deliberate — it is why an exit is possible at all. But if you have written policies against auth.uid() and signed URLs against its storage, "just Postgres" understates what you would carry.

What about Firebase in this comparison? Different axis entirely — that one is relational vs document and Security Rules vs RLS, and it has its own post. Supabase against Neon is a comparison between two Postgres products, which is why it comes down to the platform rather than the data model.

Templates in this post

ASoc Frame markets a text-to-image generator, with a prompt hero, a six-tile capabilities grid and a four-step how-it-works flow. ASoc Hearth is an AI smart-home site — ambient automation, voice control, a setup walkthrough and three pricing tiers. ASoc Ignite is a marketing site for an AI applications studio, with a voice workflow, an image-model capability grid, a mobile-app download block and a projects portfolio.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Comparison9 min read

Supabase vs PlanetScale: 7 Foreign Keys Into a Table MySQL Doesn't Have

The dialect differences port. The 7 foreign keys into auth.users and 6 policies built on auth.uid() don't — inventoried line by line from this storefront's 8 migrations.

Read more
Comparison9 min read

Supabase vs Prisma: An ORM Cannot See Your Row-Level Security

Point Prisma at a Supabase database and your policies stop working — in one of two directions, neither the one you wrote. Our own six policies, audited against that.

Read more
Comparison9 min read

SvelteKit vs. Remix: Same Primitives, Different Component Bill

Both answer routing, data loading and form posts the same way. The divergence is what reaches the browser — and how much of a page you can avoid shipping at all.

Read more