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 or 15 with App Router
@supabase/ssrv0.3+ (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 — only middleware and Route Handlers can. The catch prevents an error from crashing your component while still allowing the middleware to handle the actual refresh.
The Middleware Client#
// 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'
return NextResponse.redirect(url)
}
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 can read cookies, but they cannot set cookies directly. The session refresh must have already happened in middleware 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. Always base redirects on the supabaseResponse object or clone its cookies.
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.
Multiple Supabase client instances in Client Components. Creating a new createBrowserClient() on every render is wasteful. Either memoize it with useMemo or move it to a module-level singleton.
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.
- Is the cookie
HttpOnlyandSecure? Supabase SSR sets these by default — don't override them.
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 90% of issues: always use getUser() (not getSession()) for server-side auth checks, and always return the supabaseResponse object from middleware.
Related reading:
- Advanced Authentication Patterns with Next.js and Supabase
- Supabase Authentication & Authorization Patterns
- Security Best Practices for Next.js and Supabase Applications
See Also#
- Supabase debugging and troubleshooting hub
- Supabase Auth + Middleware: The Complete Session Management Guide for Next.js 15
- Supabase Authentication with Next.js 15 Complete Production Guide 2026
- Supabase Auth Error Codes Explained: same_password, weak_password, invalid_credentials (Fix Guide + TypeScript Cheat Sheet 2026)
- Handle Supabase Auth Errors in Next.js Middleware
Related#
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Supabase Authentication with Next.js 15 Complete Guide
Supabase Auth in Next.js 15: email/password, OAuth, magic links, middleware protection, RLS integration, and multi-tenant SaaS patterns.
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.