Supabase SSR Sessions in Next.js App Router (2026 Guide)
How createServerClient, createBrowserClient and middleware cooperate to refresh the Supabase auth cookie — and why getUser() belongs on the server.
The short answer: Supabase SSR session management in the App Router is three clients and one refresh point. createBrowserClient in Client Components, createServerClient (with the getAll/setAll cookie adapter — the older get/set/remove methods are deprecated) in Server Components and Server Actions, and a middleware that calls supabase.auth.getUser() on every request to validate and refresh the token, writing the refreshed cookies back to the response. Break any of those three and you get the classic symptom: the client shows a logged-in user while the server sees an anonymous request.
This guide explains exactly what happens to a Supabase session as it moves through the Next.js App Router request lifecycle — from the browser, through middleware, into Server Components, and back to Client Components — and why getUser() (or the newer getClaims()) is the only server-side check you should trust. Once you understand the flow, the bugs become obvious.
Estimated read time: 16 minutes
Prerequisites#
- Next.js 14, 15 or 16 with App Router
@supabase/ssrv0.4+, the release that introduced thegetAll/setAllcookie methods (not the deprecatedauth-helpers-nextjs)- Basic understanding of Next.js middleware and Server Components
- A Supabase project with Auth configured
The Core Problem: Two Different Storage Mechanisms#
The browser Supabase client stores sessions in localStorage. Server Components, middleware, and API routes cannot access localStorage — it doesn't exist on the server.
Supabase SSR solves this by storing sessions in cookies instead. Cookies are sent with every HTTP request, so the server can read them. But this introduces a new problem: cookies have to be actively managed. When the access token expires (default: 1 hour), the refresh token must be used to get a new one, and the new session must be written back to the cookie.
If nothing refreshes the session, the server sees an expired token and treats the user as unauthenticated — even though the client-side session is still valid.
This is why the middleware exists.
The Request Lifecycle#
Here's what happens on every request to a protected page:
Browser Request
↓
Next.js Middleware (middleware.ts)
→ createServerClient with request cookies
→ supabase.auth.getUser() ← validates + refreshes token if needed
→ writes updated session back to response cookies
↓
Server Component (page.tsx / layout.tsx)
→ createServerClient with cookies() helper
→ supabase.auth.getUser() ← reads the refreshed session
↓
HTML Response to Browser
→ Set-Cookie headers update browser cookies
↓
Client Component hydration
→ createBrowserClient reads from cookies (synced)Every step in this chain must use the correct client. Using the wrong one at any step breaks the chain.
Check Your Cookie Adapter: getAll/setAll, Not get/set/remove#
Before copying any middleware snippet from an old blog post, check which cookie API it implements. @supabase/ssr has had two cookie contracts:
- Current (use this): a
cookiesobject withgetAll()andsetAll(cookiesToSet). This is the only pattern shown in the official Next.js server-side auth guide, and it exists because Supabase splits large sessions across multiple chunked cookies — the library needs to read and write them as a set. - Deprecated: individual
get(name)/set(name, value, options)/remove(name)methods, carried over from the retired@supabase/auth-helpers-nextjspackage. Theauth-helperspackages themselves are deprecated in favour of@supabase/ssr.
Mixing the two is a real failure mode: a tutorial's get/set/remove middleware paired with a getAll/setAll server client means one side silently fails to write the refreshed chunks, and users get logged out at the exact moment their access token first expires — which is why the bug typically shows up "about an hour after login" (the default access-token lifetime) and never in a quick local test. If your createServerClient call mentions get: or remove:, migrate it before debugging anything else.
Next.js 14 vs 15/16: the cookies() Contract#
The other version split to check is Next.js itself. In Next.js 15, cookies(), headers(), params and searchParams became async — the Next.js 15 release notes list this as a breaking change, with a temporary sync escape hatch that logs warnings. Practically:
- Next.js 14:
const cookieStore = cookies()— synchronous, and yourcreateClient()utility can be sync too. - Next.js 15/16:
const cookieStore = await cookies()— the utility must beasync, and every call site needsawait createClient().
The snippets in this guide use the async form. If you're still on Next.js 14, remove the two awaits; everything else is identical.
Setting Up the Supabase Clients#
The Server Client Utility#
Create a single utility that both Server Components and Server Actions use:
// src/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 — cookies are read-only here.
// The middleware handles the actual cookie refresh.
}
},
},
}
)
}The try/catch in setAll is intentional. Server Components cannot set cookies; per the Next.js cookies() reference, .set only works in a Server Function (Server Action) or Route Handler, and middleware writes cookies on its own NextResponse. The catch prevents an error from crashing your component while still allowing the middleware to handle the actual refresh.
The Middleware Client#
On Next.js 14 and 15 this file is middleware.ts exporting middleware. Next.js 16 renamed the convention to proxy.ts exporting proxy; the Supabase Next.js guide warns that a proxy.ts is never called on Next.js 15 and earlier, so sessions never refresh. The Supabase code inside is identical either way.
// src/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = 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) {
// Write to both the request (for downstream middleware) and response (for browser)
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// IMPORTANT: Always use getUser(), never getSession()
const { data: { user } } = await supabase.auth.getUser()
// Redirect unauthenticated users away from protected routes
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
const redirectResponse = NextResponse.redirect(url)
// Carry the refreshed session cookies onto the redirect
supabaseResponse.cookies.getAll().forEach((cookie) =>
redirectResponse.cookies.set(cookie)
)
return redirectResponse
}
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}The matcher pattern is important. Running middleware on static assets wastes compute and can cause issues with image optimization.
The Browser Client#
// src/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!
)
}This is only used in Client Components ('use client'). It reads from cookies automatically and stays in sync with the server session.
Using the Session in Server Components#
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
const { data: projects } = await supabase
.from('projects')
.select('*')
.order('created_at', { ascending: false })
return (
<div>
<h1>Welcome, {user.email}</h1>
{/* render projects */}
</div>
)
}The redirect() call here is a safety net. The middleware should have already redirected unauthenticated users, but defense in depth is good practice.
Using the Session in Server Actions#
Server Actions run on the server and, unlike Server Components, can both read and set cookies, so the setAll call in the shared utility succeeds here (for example after signInWithPassword or signOut). Middleware still refreshes the token before the action runs.
// app/dashboard/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function createProject(formData: FormData) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
throw new Error('Unauthorized')
}
const { error } = await supabase
.from('projects')
.insert({
name: formData.get('name') as string,
owner_id: user.id,
})
if (error) throw error
revalidatePath('/dashboard')
}Next.js Server Actions with Supabase: Complete Production Guide
Handling Auth State in Client Components#
Client Components need to react to auth state changes (login, logout, token refresh). Use the onAuthStateChange listener:
// src/components/AuthProvider.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
export function AuthProvider({ children }: { children: React.ReactNode }) {
const supabase = createClient()
const router = useRouter()
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event) => {
if (event === 'SIGNED_OUT') {
router.push('/login')
}
if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
router.refresh() // re-fetch Server Component data with new session
}
}
)
return () => subscription.unsubscribe()
}, [supabase, router])
return <>{children}</>
}The router.refresh() call on TOKEN_REFRESHED is critical. It tells Next.js to re-render Server Components with the updated session, keeping client and server state in sync.
getUser() vs getSession() vs getClaims()#
Three methods look interchangeable and are not:
getSession()reads the session straight out of the cookie without contacting Supabase Auth. The official docs are blunt about it: "Never trustsupabase.auth.getSession()inside server code… It isn't guaranteed to revalidate the Auth token." Fine for optimistic UI on the client; never sufficient as a server-side gate.getUser()sends the access token to the Supabase Auth server and returns the user only if the token is valid — one network round-trip per call. This is the pattern used throughout this guide and the one most existing codebases rely on.getClaims()is the newer option the Supabase server-side auth docs now recommend for server-side protection: it verifies the JWT's signature against your project's public signing keys. With asymmetric JWT signing keys enabled, that verification happens locally — no Auth-server round-trip on every request — which matters in middleware that runs on literally every navigation.
If your project still uses the legacy symmetric JWT secret, getUser() remains the safe default. The rule that survives all three: the contents of a cookie are attacker-controlled input until something cryptographically validates them.
Common Pitfalls#
Using getSession() instead of getUser() for auth checks. getSession() reads the session from the cookie without validating it against the Supabase Auth server. A tampered or expired token will still return a session object. getUser() makes a network call to validate — use it for any security-sensitive check.
Not returning supabaseResponse from middleware. If you return a different NextResponse object (like a redirect), the updated session cookies won't be included. The Supabase guide says to return the supabaseResponse that setAll last built, or copy its cookies (and cache headers) onto the new response first, as the redirect in the middleware above does.
Creating the server client outside of an async context. The cookies() helper from next/headers must be called inside an async Server Component or Server Action. Calling it at module level will throw.
In Next.js 15 and 16 the contract is stricter:
cookies()returns aPromise, so a call that isn't awaited hands you a pending Promise instead of a cookie store, and your session lookup returns null — the symptom looks like an auth bug, not a syntax error. The exact migration and a grep-based verification pass are documented in thecookies() should be awaitedfix walkthrough.
Forgetting the middleware matcher. Without a matcher, middleware runs on every request including _next/static files. This adds latency and can interfere with static asset serving.
Assuming createBrowserClient() creates a new instance per render. In the browser it does not: the @supabase/ssr source caches one client and returns it on every later call unless you pass isSingleton: false, so calling the createClient() helper inside a component is safe without useMemo.
Debugging Session Issues#
When something breaks, check in this order:
- Is the middleware running? Add a
console.logand check your server logs. - Is
getUser()returning a user in middleware? Log the result. - Are the Set-Cookie headers present in the response? Check Network tab in DevTools.
- Is the cookie being sent on subsequent requests? Check Application > Cookies in DevTools.
- Do the cookie attributes match the environment?
@supabase/ssrdefaults topath: "/",sameSite: "lax"andhttpOnly: false(source): the cookie is deliberately readable bycreateBrowserClient, so forcinghttpOnly: truebreaks the browser client.
Summary and Next Steps#
The Supabase SSR session flow is: middleware refreshes the token → Server Components read the refreshed session → Client Components stay in sync via onAuthStateChange. Break any link in that chain and you get silent auth failures.
The two rules that prevent most of these failures: always use getUser() (not getSession()) for server-side auth checks, and always return the supabaseResponse object from middleware.
Related reading:
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Related Guides
Supabase Auth + Middleware: Session Guide for Next.js 15
Supabase auth and session management in Next.js 15: middleware patterns, cookie handling, refresh tokens, MFA, and the silent failures that ruin auth.
Supabase + Google OAuth on Next.js 15: Working Guide (2026)
Complete Google OAuth setup for Supabase + Next.js 15 (App Router, @supabase/ssr): Cloud Console config, redirect allowlists, refresh tokens, scopes.
Advanced Authentication Patterns with Next.js and Supabase
Advanced Next.js + Supabase auth patterns: OAuth, magic links, passwordless, custom JWT, multi-tenant auth, and enterprise SSO integration.

