Auth in Next.js (App Router)
Complete Supabase Auth setup for a Next.js App Router app on SupaHost: browser and server clients, the proxy that refreshes sessions, a callback route, sign-in actions and protected pages.
This page sets up cookie-based Supabase Auth in a Next.js App Router app, connected to a SupaHost project. By the end you'll have email and OAuth sign-in, sessions that refresh on their own, and pages that only signed-in users can see. The example app is a recipe box with a private /kitchen area.
For the general concepts (why cookies, what PKCE is), see Server-side auth and SSR.
1. Install and configure
npm install @supabase/supabase-js @supabase/ssr
Copy the Project URL and the anon key from Dashboard → your project → Connect tab → App:
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://app-one.acme.supahost.space
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Then in Dashboard → your project → Auth tab → Auth card, set Site URL to your production address and add these Redirect URLs:
http://localhost:3000/**
https://recipes.example.com/**
Click Save Auth settings.
2. The browser client
// 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!
)
}
Use it in Client Components, for example for Realtime subscriptions. It returns the same instance however often you call it.
3. The server client
// 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 {
// Called from a Server Component, which can't set cookies.
// The proxy below refreshes the session instead.
}
},
},
}
)
}
Create a new server client in each Server Component, Server Action and Route Handler. It's cheap.
4. The proxy that keeps sessions alive
Server Components can't write cookies, so something that runs before them must refresh expired tokens. In Next.js 16 that's proxy.ts. On Next.js 15 and earlier, name the file middleware.ts and the exported function middleware; the code inside is the same.
// lib/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(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, headers) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
response = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
Object.entries(headers ?? {}).forEach(([key, value]) =>
response.headers.set(key, value)
)
},
},
}
)
// Verifies the token and refreshes it if needed. Don't remove this.
const { data } = await supabase.auth.getClaims()
if (!data?.claims && request.nextUrl.pathname.startsWith('/kitchen')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
const redirect = NextResponse.redirect(url)
response.cookies.getAll().forEach((cookie) => redirect.cookies.set(cookie))
return redirect
}
return response
}
// proxy.ts (project root, or src/ if you use one)
import type { NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'
export async function proxy(request: NextRequest) {
return updateSession(request)
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|webp)$).*)'],
}
Two details matter here. Return the response that setAll last built, or the browser never receives the refreshed cookies. And if you return a different response (like the redirect above), copy the cookies onto it.
5. The callback route
OAuth sign-ins and email links (confirmation, magic link, reset password) come back with a ?code= that must be exchanged for a session:
// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/kitchen'
const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/kitchen'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) return NextResponse.redirect(`${origin}${safeNext}`)
}
return NextResponse.redirect(`${origin}/login?error=callback`)
}
6. Sign-in actions
// app/login/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
const site = process.env.NEXT_PUBLIC_SITE_URL!
export async function signIn(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.signInWithPassword({
email: String(formData.get('email')),
password: String(formData.get('password')),
})
if (error) redirect('/login?error=credentials')
redirect('/kitchen')
}
export async function signUp(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.signUp({
email: String(formData.get('email')),
password: String(formData.get('password')),
options: { emailRedirectTo: `${site}/auth/callback` },
})
if (error) redirect('/login?error=signup')
redirect('/login?check=email')
}
export async function signInWithGitHub() {
const supabase = await createClient()
const { data } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: { redirectTo: `${site}/auth/callback?next=/kitchen` },
})
if (data.url) redirect(data.url)
}
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect('/login')
}
// app/login/page.tsx
import { signIn, signUp, signInWithGitHub } from './actions'
export default function LoginPage() {
return (
<form>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button formAction={signIn}>Sign in</button>
<button formAction={signUp}>Create account</button>
<button formAction={signInWithGitHub} formNoValidate>Continue with GitHub</button>
</form>
)
}
To use GitHub, enable it in the OAuth providers card first. See Sign in with GitHub.
7. A protected page
// app/kitchen/page.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export default async function KitchenPage() {
const supabase = await createClient()
const { data } = await supabase.auth.getClaims()
if (!data?.claims) redirect('/login')
const { data: recipes } = await supabase
.from('recipes')
.select('id, title')
.order('title')
return (
<ul>
{recipes?.map((r) => <li key={r.id}>{r.title}</li>)}
</ul>
)
}
The proxy's redirect is a convenience. The check in the page (and RLS in the database) is what actually protects the data, so keep both.
Deploying
On Vercel, set the same three environment variables, with NEXT_PUBLIC_SITE_URL pointing at your production domain. Add preview URLs to Redirect URLs with a pattern scoped to your team. See Site URL and redirect URLs and Deploying on Vercel.
Never put the service_role key in a NEXT_PUBLIC_ variable. If a Route Handler needs admin access, read it from a server-only variable such as SUPABASE_SERVICE_ROLE_KEY.
If something doesn't work, see Troubleshooting Supabase Auth in Next.js.