Next.js App Router + Supabase SSR Session Management Deep Dive
Developer Guide

Next.js App Router + Supabase SSR Session Management Deep Dive

Deep dive into Supabase SSR session management in Next.js App Router. Learn how cookies, middleware, and Server Components interact to keep users authenticated.

2026-03-17
14 min read
Next.js App Router + Supabase SSR Session Management Deep Dive

Next.js App Router + Supabase SSR Session Management Deep Dive#

Session management is where most Next.js + Supabase integrations break. Not loudly — silently. The client shows a logged-in user, the server sees an anonymous request, and you spend hours wondering why your Server Components are returning empty data.

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. Once you understand the flow, the bugs become obvious.

Estimated read time: 14 minutes

Prerequisites#

  • Next.js 14 or 15 with App Router
  • @supabase/ssr v0.3+ (not the deprecated auth-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.


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')
}

[INTERNAL LINK: nextjs-server-actions-supabase-complete-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.


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.

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:

  1. Is the middleware running? Add a console.log and check your server logs.
  2. Is getUser() returning a user in middleware? Log the result.
  3. Are the Set-Cookie headers present in the response? Check Network tab in DevTools.
  4. Is the cookie being sent on subsequent requests? Check Application > Cookies in DevTools.
  5. Is the cookie HttpOnly and Secure? 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:

  • [INTERNAL LINK: nextjs-supabase-advanced-authentication-patterns]
  • [INTERNAL LINK: supabase-authentication-authorization]
  • [INTERNAL LINK: nextjs-supabase-security-best-practices]

Frequently Asked Questions

|

Have more questions? Contact us