Setting up Supabase with Vercel
A no-nonsense walkthrough for getting Supabase running with a Next.js App Router project on Vercel. Auth that survives reloads, RLS that does the right thing, and queries that are typed end-to-end.
Why this pairing works
Supabase plus Next.js on Vercel is the closest thing the modern web has to a default. You get a Postgres database, auth, file storage and edge functions on one side; serverless rendering and a real React framework on the other. The integration is officially blessed and the docs are mostly fine. The trouble is the bits the docs do not mention — the silent middleware misconfigurations, the cookie-domain quirks, the server-component cache gotchas.
This is the setup I use on every greenfield Next.js project. It is opinionated. It will save you a week.
Project setup
Spin up a fresh Next.js project and add the Supabase SSR helpers. Do not use the deprecated auth-helpers package — it is on its way out. The current correct package is @supabase/ssr.
bashpnpm create next-app@latest my-app --ts --app --tailwind --eslint cd my-app pnpm add @supabase/supabase-js @supabase/ssr pnpm add -D supabase
Initialise a local Supabase project so you can develop offline and produce migrations.
bashpnpm supabase init pnpm supabase start # spins up a full local Postgres + Studio pnpm supabase link --project-ref <your-project-ref>
Environment variables, properly
Three variables matter. Two of them are public, one is decidedly not.
NEXT_PUBLIC_SUPABASE_URL— the project URL, fine to expose.NEXT_PUBLIC_SUPABASE_ANON_KEY— the anon JWT, also fine to expose because RLS is doing the work.SUPABASE_SERVICE_ROLE_KEY— the master key. Never imported into client code. Never logged. Never sent to the browser. Server-only.
On Vercel, set all three under Project Settings → Environment Variables. Mark the first two for "Production, Preview, Development" and the service role key only for the environments where you actually need it. If you can avoid using the service role key entirely from your app code, you should — it sidesteps RLS, which means a single bug becomes a data leak.
The two Supabase clients
App Router has two execution contexts that matter: server components / route handlers / server actions, and client components. Supabase needs a different cookie strategy in each. Set both up once and never think about it again.
typescript// lib/supabase/server.ts import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' export async function createClient() { const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll() }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ) } catch { // setAll called from a Server Component — fine to ignore, // middleware refreshes the session } }, }, } ) }
typescript// lib/supabase/client.ts import { createBrowserClient } from '@supabase/ssr' export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ) }
Auth that actually works in App Router
The single most important file is the middleware. Without it, sessions silently expire and users find themselves logged out at the worst possible moments. The job of the middleware is to refresh the JWT on every request and rewrite the cookies.
typescript// middleware.ts import { NextResponse, type NextRequest } from 'next/server' import { createServerClient } from '@supabase/ssr' export async function middleware(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() { return request.cookies.getAll() }, setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value) ) response = NextResponse.next({ request }) cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options) ) }, }, } ) // IMPORTANT: do not put logic between createServerClient and getUser await supabase.auth.getUser() return response } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg)$).*)'], }
If your users keep getting logged out at random, your middleware matcher is wrong.
For login flows, prefer server actions. They keep secrets on the server, integrate cleanly with form submissions, and revalidate the path so server components pick up the new auth state immediately.
typescript// app/login/actions.ts 'use server' import { createClient } from '@/lib/supabase/server' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' export async function signIn(formData: FormData) { const supabase = await createClient() const { error } = await supabase.auth.signInWithPassword({ email: formData.get('email') as string, password: formData.get('password') as string, }) if (error) return { error: error.message } revalidatePath('/', 'layout') redirect('/dashboard') }
Row-level security from day one
Turn RLS on for every table. No exceptions. Forgetting to enable RLS on a single table is the most common Supabase data leak there is. Add a default-deny stance, then write per-table policies that grant exactly what is needed.
sql-- supabase/migrations/0001_init.sql create table public.posts ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users on delete cascade, title text not null, body text not null, created_at timestamptz not null default now() ); alter table public.posts enable row level security; create policy "users read own posts" on public.posts for select using (auth.uid() = user_id); create policy "users insert own posts" on public.posts for insert with check (auth.uid() = user_id); create policy "users update own posts" on public.posts for update using (auth.uid() = user_id);
Test the policies with a real session, not the SQL editor (which runs as service role). Use supabase test db or write a quick Vitest spec that signs in two users and confirms one cannot read the other's rows.
Type-safe queries
Generate types from your live schema and commit them. The dev loop is: change schema, regenerate types, get a compile error wherever you broke something. It is one of the better developer experiences in modern web work.
bashpnpm supabase gen types typescript --linked > types/database.ts
typescript// lib/supabase/server.ts (extended) import type { Database } from '@/types/database' import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' export async function createClient() { const cookieStore = await cookies() return createServerClient<Database>(/* ... */) }
Edge cases and gotchas
The Vercel preview cookie domain. By default Supabase auth cookies are bound to the request domain. Preview deployments have a different domain on every PR. That is fine for browsing, but if you embed iframes or call your preview API from a fixed host, you may need to set cookieOptions.domain explicitly.
Server component caching. Next.js will happily cache a Supabase query made in a server component. If your data is user-specific, make sure the page is dynamic (e.g. it reads cookies via createClient) — that opts it out of caching automatically. If it is not, mark the segment export const dynamic = 'force-dynamic'.
Realtime over Vercel. Vercel's serverless functions cannot hold WebSockets open. Run realtime subscriptions from the client only, or push events through a separate worker.
Pitfalls
Even one accidental import of the service role key into a client component bundles it into JavaScript shipped to every browser. Add a lint rule that bans the env var name from anything under app/ that is not marked server-only.
You enable RLS on every table you create directly, then forget it on a join table generated by a foreign key migration. The join table reveals exactly what the parents tried to hide. Audit with a query against pg_class.
After a sign-in server action, you must call revalidatePath or revalidateTag, otherwise the layout still thinks the user is anonymous.
Importing the browser client into a route handler works in dev and silently breaks in production. Keep the two factories in clearly-named files and resist the temptation to "consolidate".
Wrap-up
Once this is set up — middleware in place, RLS on every table, types generated, two clients — Supabase becomes the most boring part of your stack. You write SQL migrations, you write server actions, the database does the access control. Most of my projects deploy to production with a single environment variable change and almost no auth code in the app itself.
That is the goal. Auth is not where you want to be clever.
Want this done for you?
If you would rather skip the YAK shave and have someone who has done this fifty times set it up properly, that is what I do for a living.
Start a project