Supabase vs Vercel: Both, and 42 Lines Where They Meet
Not alternatives. Vercel builds and serves 584 pages, Supabase holds the schema and the policies, and one 42-line proxy can take the whole site down.
This storefront runs on both. Of thirteen runtime dependencies in package.json, two are Supabase (@supabase/ssr, @supabase/supabase-js) and one is Vercel (@vercel/analytics); Vercel builds and serves 584 prerendered pages, Supabase holds eight SQL migrations' worth of schema, row-level security and a private storage bucket. They are not two answers to one question. The only place they meet is a 42-line file, and that file is also the one way either of them can take the whole site down.
The short answer
Vercel is a deployment platform: it builds your app from a Git push, serves it from a CDN, and runs your server functions. Supabase is a backend-as-a-service: hosted Postgres with row-level security, auth, storage and server-side functions. They overlap at the edges — both sell a Postgres, both run functions — but a typical project uses one of each rather than choosing between them.
The division of labour, as this repo assigns it
| Job | Handled by | Where it lives |
|---|---|---|
| Build + prerender 584 pages | Vercel | next build, no vercel.json needed |
| Serving static HTML, images, WebP variants | Vercel CDN | public/, build-time -card.webp / -view.webp derivatives |
| Security response headers + CSP | Vercel (via the framework) | next.config.ts → async headers() |
| Running Server Actions + 2 Route Handlers | Vercel functions | src/lib/actions/* (8 modules), src/app/api/*/route.ts |
| Identity, sessions, OAuth | Supabase Auth | src/lib/supabase/{client,server,admin}.ts |
| Orders, entitlement slots, download audit | Supabase Postgres | supabase/migrations/0001…0008 |
| Authorisation on that data | Supabase RLS | read-own-only policies, enforced in the database |
| Release zips behind a paywall | Supabase Storage | private releases bucket, signed URLs |
| Conversion analytics | Vercel Web Analytics | src/lib/analytics.ts, five named events, cookieless |
Nothing in the left column is contested. Vercel never sees the database; Supabase never serves a page. The reason the comparison gets asked at all is that both vendors' marketing pages have grown into each other's territory — Vercel sells a Postgres, Supabase runs Edge Functions — and from the outside the stacks look like they should collide.
Where they actually touch: 42 lines
There is exactly one file where "the host" and "the backend" are the same request:
// src/proxy.ts — Next 16 proxy (formerly middleware)
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: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
/* rotate auth cookies onto the response */
},
},
},
);
await supabase.auth.getClaims();
return response;
}
It refreshes the Supabase session on each request so Server Components see a valid one, and writes the rotated cookies onto the response. It calls getClaims(), which validates the JWT, and never getSession(), which returns whatever the cookie says without verifying it.
Its config.matcher excludes static assets, image files, sitemap.xml, robots.txt and the Search Console verification file — nothing that needs a session. Everything else pays for it.
The failure mode that makes them one system
Because the proxy runs on effectively every request and dereferences two environment variables with !, a deploy to Vercel that is missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY does not degrade the account pages. It errors site-wide — on the marketing home page, on a blog post, on /pricing, none of which have anything to do with Supabase.
That is a real constraint in this repository, written into its CLAUDE.md as a do-not-deploy note: the commerce-enabled main must not ship to production until those variables exist in the Vercel project, because the Phase A build currently in production has no proxy and does not care.
The general lesson survives the specifics. A backend dependency in your middleware is a dependency of every route, including the ones that never call the backend. If that is not what you want, gate the proxy's matcher to the authenticated surface area rather than to everything-but-assets — and know that you are trading a site-wide blast radius for the risk of a stale session on a page you forgot to include.
The coupling runs one more level down, into the build. The CSP in next.config.ts derives its connect-src from the Supabase URL at build time:
const supabaseOrigin = (() => {
try {
return new URL(process.env.NEXT_PUBLIC_SUPABASE_URL!).origin;
} catch {
return "https:";
}
})();
Get that env var wrong in the build environment and the policy is wrong in a way no local test reproduces: the pages render, and the browser silently blocks the auth calls.
The 68 KiB tax, and taking it off the critical path
Adding a backend to a mostly-static site has a cost the architecture diagram does not show. @supabase/ssr plus auth-js is roughly 68 KiB over the wire, 255 KiB parsed, and it was reaching every page: Header renders site-wide, useOwnedProducts renders behind every templates grid, and both imported the browser client at module scope. Pages with no account UI whatsoever — /, /blog, /docs, /pricing — were downloading and parsing the entire auth stack before they could settle.
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 is the second half: @supabase/ssr stores its session in a non-HttpOnly cookie precisely so the browser client can read it, so a signed-out visitor can be detected without loading the library at all. It is a rendering shortcut and nothing more — it can never grant anything, because every real gate stays server-side.
Which one enforces what
The split that matters most is not hosting versus database. It is where an authorisation decision is made, and the answer is never Vercel:
-- supabase/migrations/0001_commerce_init.sql
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);
They are the whole of this site's user-data model: profiles, orders, entitlement slots and a download audit trail, each readable only by the account that owns the row. Those policies hold whether the query arrives from a Server Component on Vercel, a Server Action, a Route Handler, or curl with a stolen anon key. A platform can route a request to the wrong function; it cannot route a row past a policy. The download route re-runs its own authorizeDownload per request on top of that — the ownership lookup that decorates the UI is a convenience, not a gate.
Where they genuinely compete
| Capability | Vercel | Supabase | How this repo picks |
|---|---|---|---|
| Postgres | Vercel Postgres (marketplace-provisioned) | Core product, with RLS, RPCs, migrations | Supabase — the auth and the data are the same system, so auth.uid() works inside a policy |
| Server-side functions | Route Handlers + Server Actions, deployed with the app | Edge Functions, deployed to the database side | Vercel — the two handlers need the framework's request lifecycle and raw body |
| File storage | Blob storage | Storage buckets with policy-controlled access | Supabase — release zips must be private and signed per entitlement |
| Auth | Partner integrations | Built in, tied to RLS | Supabase |
| Analytics | Web Analytics, cookieless | — | Vercel — five named conversion events, no PII in props |
| Hosting a Next.js app | The first-party path | Not a hosting product | Vercel |
The one row worth arguing over is Postgres, and the tiebreaker is not performance. It is that auth.uid() inside a row-level security policy only works when the auth system and the database are the same system. Split them and every policy becomes application code you have to get right on every path. Vercel Postgres vs Supabase takes that argument in full; Supabase vs Firebase covers the other direction, where the backend candidate is a different BaaS entirely.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Every route 500s after a deploy, including static pages | Middleware/proxy dereferences a missing Supabase env var on every request | Set the vars in the deployment environment before shipping; consider narrowing config.matcher to authenticated routes |
| Auth works locally, silently fails in production | CSP connect-src was built from a missing or wrong NEXT_PUBLIC_SUPABASE_URL | Check the deployed response headers, not the local ones; the origin is baked in at build time |
| Session is valid in the browser but absent in a Server Component | No session refresh on the request path, or getSession() used where the JWT was never verified | Refresh in the proxy; use getClaims()/getUser() for any decision |
| A page with no account UI ships the whole auth library | The browser client is imported at module scope by a site-wide component | Import it dynamically inside the effect that uses it, and probe for the session cookie first |
| RLS returns zero rows for a user who should see them | The query ran with the anon key and no session, or the policy compares the wrong column | Check auth.uid() in the policy against the table's owner column; verify the request actually carries the session cookie |
| Service-role key needed in a client component | An operation was placed on the wrong side of the boundary | Move it into a Server Action or Route Handler; the service-role key bypasses RLS and must never reach a browser |
Frequently asked questions
Do I have to choose between Supabase and Vercel? Usually not. They solve different problems, and the common setup is a Next.js app deployed on Vercel talking to a Supabase project. The question is worth asking only for the two capabilities that genuinely overlap — Postgres and server-side functions.
Can I host a Next.js app on Supabase? No. Supabase is not a hosting platform for a frontend framework; it provides the database, auth, storage and Edge Functions your app calls. Your app still needs somewhere to build and run.
Can I skip Supabase and use Vercel's own Postgres and blob storage? You can, and for a project with no user accounts it removes a vendor. The moment you add accounts, you are choosing between an auth system wired into your database's policy engine and one you integrate yourself. This codebase gates downloads on rows a policy protects, which made that an easy call.
What breaks if I move one of them later?
Moving hosts is mostly a build-and-DNS exercise. Moving the backend is not: row-level security policies, SECURITY DEFINER functions and storage rules are Postgres and Supabase features, not portable application code. Weigh the two migrations differently when you pick.
Does adding Supabase slow down a static site? It can, and that is a bundle question rather than a database one — see the 68 KiB above. Prerendered pages stay fast as long as the auth client is loaded on demand rather than imported by a component that renders on every page.
Templates in this post
ASoc Quill, ASoc Rally and ASoc Rank are Next.js + Tailwind landing page templates that deploy to Vercel as-is and stay static until you add a backend — no proxy on every request until you need one.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
