Vercel Postgres vs. Supabase: We Deploy on Vercel and Still Run Supabase
8 migrations, 6 RLS policies and one site-wide outage risk — why a Vercel-hosted storefront still runs Supabase for its database and identity.
Vercel Postgres and Supabase are not the same category of thing, which is why the comparison keeps going in circles. Vercel Postgres is a database attached to your hosting account. Supabase is a Postgres database plus the auth service, storage bucket and row-level-security model built around it. This storefront deploys on Vercel and still runs Supabase, and the reason is in the "plus".
The short answer
Pick Vercel Postgres when you need a SQL database and already own identity and file storage somewhere else. Pick Supabase when the database also has to answer who is asking — because its auth service issues the JWT that row-level security compares against, and that pairing is the part you cannot rebuild in an afternoon.
What this repo actually deploys
This is not a benchmark. We have never run this stack on Vercel Postgres, so there is no latency number here and no cost table pretending to be one. What we can show is the dependency surface a migration would have to move, counted in this checkout:
| Vercel Postgres | Supabase (what this storefront runs) | |
|---|---|---|
| Database | Postgres, provisioned as a Vercel Marketplace integration (vercel install neon) | Postgres, 8 migrations in supabase/migrations/ |
| Identity | Not included — bring your own | Supabase Auth (GoTrue), same project as the database |
| Authorization | Application code, or RLS with an identity you supply | 6 RLS policies comparing auth.uid(), plus 3 migrations of security definer RPCs |
| File storage | Not included — bring your own | Private releases bucket, signed URLs |
| Billing | Unified on the Vercel invoice | Separate, or unified through Vercel's Supabase integration |
The row that decides it is Identity. Row-Level Security is a Postgres feature, so it works on any Postgres — Vercel's included. What RLS needs is a verified user ID to compare against, and that is what a bare database does not ship.
The line that makes RLS work, and where it comes from
Here is an actual policy from this repo's first migration:
-- supabase/migrations/0001_commerce_init.sql
alter table public.orders enable row level security;
create policy "own orders" on public.orders for select
using ((select auth.uid()) = user_id);
auth.uid() is not a Postgres builtin. It reads the authenticated user's ID out of the JWT that Supabase's gateway attached to the request. On a database with no auth service in front of it, that function does not exist and the policy has nothing to compare — you would have to mint the claims yourself, verify them yourself, and set them on the connection yourself, on every request, before any query runs.
That is the work Supabase is absorbing. It is also why "just use Postgres, RLS is a Postgres feature" is technically true and practically incomplete.
The write side is deliberately not symmetric
A detail worth stealing regardless of which side you pick: this schema has zero write policies for the anonymous or authenticated roles. Reads are declarative and enforced by the database. Writes go through server code holding the service-role key, which bypasses RLS entirely:
// src/lib/supabase/admin.ts
import "server-only";
import { createClient as createSbClient } from "@supabase/supabase-js";
export function createAdminClient() {
return createSbClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
}
The import "server-only" on the first line is the guard that matters: importing this module from a Client Component fails the build rather than shipping a service-role key to a browser. Three of the eight migrations add security definer functions for the operations that need atomicity a plain insert cannot give — an entitlement redemption, a download rate-limit check-and-increment.
This split is portable. Whether the database is Vercel's or Supabase's, "reads declarative, writes through audited server code" is the shape that survives review.
The Vercel-specific trap nobody warns you about
Running Supabase on Vercel introduces one failure mode that belongs to the seam rather than to either product. This repo's session refresh lives in a Next.js proxy:
// src/proxy.ts — 42 lines, runs on every matched request
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { /* getAll / setAll onto the response */ } },
);
await supabase.auth.getClaims();
return response;
}
Note the ! on both env vars, and note the matcher: it runs on everything except static assets and the crawler routes. Deploy this build to a Vercel project whose Supabase environment variables are not set, and the proxy throws on every request — not on the dashboard, not on checkout, on the marketing home page too. A missing env var stops being a broken feature and becomes a site-wide outage, because the auth code sits in front of pages that have no account UI at all.
Our own CLAUDE.md carries that warning in bold for exactly this reason. If you wire Supabase into a Vercel project, set the env vars before the first production deploy, not after the first 500.
getClaims() in that snippet is also doing load-bearing work: it validates the JWT's signature. getSession() reads the cookie and believes it. On a system where policies trust auth.uid(), the difference between those two calls is the difference between authorization and decoration.
The bundle cost we measured, and removed
The one number we did measure here is what the client SDK costs. @supabase/ssr plus auth-js is about 68 KiB over the wire and 255 KiB parsed, and it was reaching every page on the site — Header renders site-wide, useOwnedProducts renders behind every templates grid, and both imported createClient at module scope. The auth stack was in the initial bundle of /, /blog, /docs and /pricing.
Both call sites only touch the client inside an effect, so the import can wait for the effect too:
// 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();
}
The cookie probe then skips the fetch entirely for signed-out visitors, who have no session to read. It is a rendering shortcut and can never grant anything — every real gate stays on the server. This is a Supabase-shaped cost, and a bare database would not have imposed it; it is a fair point on the Vercel Postgres side of the ledger, and it is fixable in about forty lines.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Every route 500s after deploy, including static marketing pages | Proxy runs site-wide and its env vars are unset | Set NEXT_PUBLIC_SUPABASE_URL / _ANON_KEY in the Vercel project before deploying |
| RLS policies written, rows still leak | Queries run with the service-role key, which bypasses RLS | Keep service-role usage in one server-only module and audit its call sites |
auth.uid() returns null in a policy | No verified JWT on the request | Confirm the session refresh ran; on a bare Postgres you must supply the claim yourself |
| Auth SDK in the bundle of pages with no login UI | createClient imported at module scope | Defer to a dynamic import inside the effect that uses it |
| Session looks valid but authorization is wrong | getSession() trusts an unverified cookie | Use getClaims() or getUser() for any decision |
Frequently asked questions
Can I use both? Yes, and most teams do — they are not competitors. Vercel hosts the app; Supabase holds the data and the identity. Vercel's marketplace even provisions Supabase and puts it on the same invoice.
Is "Vercel Postgres" still its own product?
Postgres on Vercel is provisioned through the Vercel Marketplace rather than as a separate first-party database: Vercel's CLI documentation lists vercel install neon under provisioning storage, alongside vercel install upstash and vercel install supabase. Which is the quietly decisive fact here — on Vercel, both sides of this comparison are marketplace integrations, installed the same way and billed through the same invoice. If you are weighing the Neon side specifically, the comparison that matters is Supabase against Neon, which is a comparison between two Postgres products rather than between a database and a backend.
Does RLS work on Vercel Postgres? Yes — it is a Postgres feature and it is there. What you supply yourself is the verified identity the policies compare against, which is the part Supabase's auth service hands you for free.
What would migrating off Supabase actually cost?
For this repo: 6 policies rewritten against an identity you now mint and verify, 3 migrations of security definer RPCs moved, one private storage bucket and its signed-URL issuance rebuilt, and the whole session-refresh path replaced. The schema itself is standard Postgres and would move unchanged — which is exactly why an exit is possible at all, and exactly why "just Postgres" understates what you would carry.
Templates in this post
ASoc Mind is the home variant of an AI marketing-solutions site — a marketing-intelligence hero, an achievements band, and a services section detailing eight image-AI capabilities. ASoc Momentum markets an AI consulting agency, leading with a services stack, a testimonial slider, case studies and a two-tier pricing table. ASoc Neuron markets a neural-network platform, with a product-preview hero, a six-capability grid, a three-tier pricing table and an integrations grid.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
