Skip to content
Homeβ€Ί JavaScriptβ€Ί Supabase Auth with Next.js 16 – The Complete 2026 Guide

Supabase Auth with Next.js 16 – The Complete 2026 Guide

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 26 of 32
Learn how to implement secure authentication with Supabase and Next.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
Learn how to implement secure authentication with Supabase and Next.
  • @supabase/ssr is mandatory for App Router β€” provides cookie-based sessions across client and server
  • Middleware is non-negotiable β€” it refreshes access tokens on every request
  • Always use getUser() on the server β€” getSession() reads unvalidated cookies
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • @supabase/ssr is the only correct package for Next.js App Router β€” supabase-js alone does not handle cookies
  • Middleware refreshes the access token on every request β€” without it, sessions expire silently
  • Server Components, Server Actions, and Route Handlers each need a differently configured Supabase client
  • PKCE flow is the default and automatic in @supabase/ssr β€” it prevents auth code interception attacks
  • Never store tokens in localStorage β€” cookie-based sessions are the only secure pattern in App Router
  • Biggest mistake: using the browser client on the server β€” it leaks credentials and breaks SSR
🚨 START HERE
Supabase Auth Quick Debug Reference
Fast commands for diagnosing auth issues in Next.js + Supabase
🟑Session not persisting across requests
Immediate ActionCheck middleware.ts exists and exports correct config
Commands
ls -la middleware.ts
cat middleware.ts | head -50
Fix NowCreate middleware.ts at project root with createServerClient and updateSession function
🟑Cookies not being set after login
Immediate ActionCheck browser devtools > Application > Cookies for sb-* cookies
Commands
curl -s -I http://localhost:3000 | grep -i set-cookie
cat .env.local | grep SUPABASE
Fix NowEnsure NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set
🟑getUser() returns null in Server Component
Immediate ActionVerify the Server Component creates the client with cookies() from next/headers
Commands
grep -rn 'createServerClient' app/ --include='*.tsx' --include='*.ts' | head -10
grep -rn 'cookies()' app/ --include='*.tsx' --include='*.ts' | head -10
Fix NowImport createServerClient from @supabase/ssr and pass cookies() to the cookie handler
🟑OAuth provider returns 403 or access_denied
Immediate ActionCheck Supabase Dashboard > Auth > Providers > OAuth config
Commands
cat .env.local | grep -i 'SUPABASE_URL\|SUPABASE_KEY\|SITE_URL'
curl -s ${NEXT_PUBLIC_SUPABASE_URL}/auth/v1/health
Fix NowVerify the OAuth provider's redirect URI matches the Supabase callback URL exactly
Production IncidentMissing Middleware Caused 40% Session Drop-Off Within 24 HoursA SaaS application deployed with Supabase Auth but without middleware. Users signed in successfully but were logged out within 1 hour when the access token expired. 40% of active sessions were lost in the first 24 hours.
SymptomUsers reported being randomly logged out while using the application. The login page showed a spike in traffic β€” users were re-authenticating multiple times per day. Supabase dashboard showed 3x the normal number of token grant events. Average session duration dropped from 8 hours to 52 minutes.
AssumptionThe Supabase client automatically refreshed tokens β€” no middleware was needed. The team assumed the browser client handled token refresh internally.
Root causeThe application used @supabase/supabase-js without @supabase/ssr and without middleware. The browser client stored the session in memory (not cookies). When the access token expired after 1 hour, there was no mechanism to refresh it β€” the client did not persist the refresh token across page navigations, and Server Components had no access to the session at all. Every server-side render returned an unauthenticated state, forcing the client to re-check and eventually requiring re-login.
FixInstalled @supabase/ssr and created three client factories: createBrowserClient for Client Components, createServerClient for Server Components and Route Handlers, and createServerClient in middleware for token refresh. Added middleware.ts at the project root that calls supabase.auth.getUser() on every request β€” this triggers automatic token refresh when the access token is near expiry. Left cookie defaults (httpOnly: true, secure: true, sameSite: 'lax'). Session duration increased from 52 minutes to the expected 7+ days.
Key Lesson
Supabase Auth in Next.js App Router REQUIRES middleware β€” there is no automatic token refresh without it.@supabase/ssr is the correct package β€” @supabase/supabase-js alone does not handle cookie-based sessions.The middleware must call getUser() or getSession() on every request β€” this is what triggers the refresh.Without middleware, Server Components always see the user as unauthenticated β€” the session only exists in the browser memory.
Production Debug GuideDiagnose session, authentication, and token issues
User is logged out on every page navigation→Check that middleware.ts exists at the project root and calls supabase.auth.getUser() — without middleware, tokens are not refreshed
Server Components show user as unauthenticated→Verify you are using createServerClient from @supabase/ssr in Server Components — not createBrowserClient
OAuth callback redirects to login page→Check that the redirect URL in Supabase Dashboard matches the callback route exactly — including protocol, domain, and path
Session exists in browser but not in Server Actions→Ensure the Server Action reads cookies via next/headers cookies() — the Supabase client must be created with the cookie store
CORS errors on auth requests→Verify the site URL and additional redirect URLs in Supabase Dashboard > Authentication > URL Configuration match your deployment domain
Token refresh loops causing high Supabase API usage→Check middleware matcher — it should exclude static assets (_next/static, favicon.ico) to avoid unnecessary auth calls

Supabase Auth provides email/password, OAuth, magic link, and phone authentication backed by GoTrue. Integrating it with Next.js App Router requires a specific client configuration that differs from the Pages Router approach. The core dependency is @supabase/ssr β€” it manages cookie-based sessions and provides factory functions for browser, server, and middleware contexts.

The three most common integration failures are: using supabase-js directly without @supabase/ssr (sessions do not persist across requests), skipping middleware (access tokens expire and users are silently logged out), and using the same client configuration in Server Components and Client Components (cookie handling breaks).

This guide covers the complete integration: client setup, middleware configuration, Server Actions for auth flows, route protection, OAuth providers, and the production failure patterns that cause outages.

Package Setup: @supabase/ssr is Mandatory

The correct package for Next.js App Router is @supabase/ssr β€” not @supabase/supabase-js used alone. The ssr package provides three factory functions: createBrowserClient for Client Components, createServerClient for Server Components and Route Handlers, and the same createServerClient used in middleware. It manages cookie-based sessions automatically.

The supabase-js package alone stores sessions in localStorage by default. This breaks SSR because Server Components cannot access localStorage β€” they have no browser context. The ssr package solves this by using cookies, which are available on both client and server.

Install both packages: @supabase/ssr and @supabase/supabase-js (ssr depends on it). The supabase-js package is a peer dependency β€” you do not import from it directly in App Router code.

io.thecodeforge.auth.package-setup.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
// ============================================
// Package Installation and Environment Setup
// ============================================
// npm install @supabase/ssr @supabase/supabase-js

// ---- .env.local ----
// NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
// NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

// ---- Supabase URL Configuration (Dashboard) ----
// Site URL: https://your-domain.com
// Redirect URLs:
//   https://your-domain.com/auth/callback
//   https://your-domain.com/auth/callback?next=/dashboard
//   http://localhost:3000/auth/callback (for local dev)

// ============================================
// Three Client Factories β€” One Per Context
// ============================================
// Each context needs a differently configured Supabase client.
// Using the wrong client in the wrong context causes:
//   - Session loss
//   - Cookie handling failures
//   - SSR hydration mismatches

// ---- Factory 1: Browser Client (Client Components) ----
// File: lib/supabase/client.ts
// Used in: Client Components with 'use client'
// Handles: Cookie read/write on the browser side

import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

// ---- Factory 2: Server Client (Server Components, Route Handlers) ----
// File: lib/supabase/server.ts
// Used in: Server Components, Route Handlers, Server Actions
// Handles: Cookie read/write via next/headers cookies()

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 is called from Server Components where
            // cookies cannot be modified. This is expected β€”
            // middleware handles cookie updates.
          }
        },
      },
    }
  )
}

// ---- Factory 3: Middleware Client ----
// File: middleware.ts (project root)
// Used in: Next.js middleware
// Handles: Token refresh on every request

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(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) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  // This is the critical call β€” it refreshes the token if expired
  // Do NOT remove this line
  const { data: { user } } = await supabase.auth.getUser()

  // Redirect unauthenticated users away from protected routes
  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login') &&
    !request.nextUrl.pathname.startsWith('/auth')
  ) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return supabaseResponse
}

export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - public folder
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}
Mental Model
Three Clients, Three Contexts
Each Next.js rendering context needs a differently configured Supabase client β€” one factory per context.
  • Browser Client (createBrowserClient) β€” Client Components, reads/writes cookies via document.cookie
  • Server Client (createServerClient + cookies()) β€” Server Components, Route Handlers, reads cookies via next/headers
  • Middleware Client (createServerClient) β€” middleware.ts, refreshes tokens on every request
  • Using the browser client on the server breaks SSR β€” it cannot access document.cookie
  • Using the server client in Client Components causes hydration mismatches β€” it accesses server-only APIs
πŸ“Š Production Insight
@supabase/ssr is the only correct package for App Router β€” supabase-js alone uses localStorage.
Three factory functions: browser client, server client, middleware client β€” each handles cookies differently.
Rule: one factory per context β€” never use the browser client on the server.
🎯 Key Takeaway
@supabase/ssr is mandatory for App Router β€” provides cookie-based session management.
Three client factories: browser, server, middleware β€” each configured for its rendering context.
Never use supabase-js directly without @supabase/ssr β€” sessions will not persist.

Middleware: The Non-Negotiable Token Refresh Layer

Middleware runs on every request before the page renders. Its primary job in Supabase Auth is to refresh the access token when it is near expiry. Without middleware, the access token expires after 1 hour (default) and the user is silently logged out.

The middleware calls supabase.auth.getUser() β€” this is the trigger for token refresh. If the access token is expired, getUser() uses the refresh token (stored in cookies) to obtain a new access token. The new tokens are written back to cookies via the setAll callback.

The matcher configuration is critical. Without it, middleware runs on static assets (_next/static, images, favicon) β€” this adds unnecessary Supabase API calls and increases latency. The matcher should exclude static files and public assets.

io.thecodeforge.auth.middleware.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
// ============================================
// middleware.ts β€” Token Refresh and Route Protection
// ============================================
// File: middleware.ts (MUST be at project root)
// This file runs on every matched request β€” before page render

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) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  // ---- Critical: getUser() triggers token refresh ----
  // This is NOT just a user check β€” it refreshes expired tokens
  // Removing this line causes sessions to expire after 1 hour
  const {
    data: { user },
  } = await supabase.auth.getUser()

  // ---- Route Protection Logic ----
  // Define which routes require authentication
  const isAuthRoute = request.nextUrl.pathname.startsWith('/auth')
  const isLoginRoute = request.nextUrl.pathname === '/login'
  const isPublicRoute = request.nextUrl.pathname === '/' ||
    request.nextUrl.pathname.startsWith('/pricing') ||
    request.nextUrl.pathname.startsWith('/about')

  // Redirect unauthenticated users to login
  if (!user && !isAuthRoute && !isLoginRoute && !isPublicRoute) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    url.searchParams.set('redirectedFrom', request.nextUrl.pathname)
    return NextResponse.redirect(url)
  }

  // Redirect authenticated users away from login page
  if (user && isLoginRoute) {
    const url = request.nextUrl.clone()
    url.pathname = '/dashboard'
    return NextResponse.redirect(url)
  }

  return supabaseResponse
}

// ---- Matcher: Exclude static assets ----
// Without this, middleware runs on every static file request
// This adds latency and unnecessary Supabase API calls
export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - api routes that handle their own auth
     * - _next/static (static files)
     * - _next/image (image optimization)
     * - favicon.ico
     * - Static file extensions
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

// ============================================
// Alternative: Role-Based Route Protection
// ============================================
// If you need role-based access control in middleware,
// fetch the user profile after getUser()

export async function middlewareWithRoles(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) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const { data: { user } } = await supabase.auth.getUser()

  // Admin-only routes
  if (request.nextUrl.pathname.startsWith('/admin')) {
    if (!user) {
      return NextResponse.redirect(new URL('/login', request.url))
    }

    // Fetch user role from your users table
    const { data: profile } = await supabase
      .from('profiles')
      .select('role')
      .eq('id', user.id)
      .single()

    if (profile?.role !== 'admin') {
      return NextResponse.redirect(new URL('/dashboard', request.url))
    }
  }

  return supabaseResponse
}
⚠ Middleware is NOT Optional
πŸ“Š Production Insight
Without middleware, access tokens expire after 1 hour β€” users are silently logged out.
getUser() triggers token refresh β€” it is not just a user check, it refreshes expired tokens.
Rule: middleware.ts at project root, matcher excludes static assets, getUser() always called.
🎯 Key Takeaway
Middleware is non-negotiable β€” it refreshes access tokens on every request.
getUser() in middleware is the token refresh trigger β€” removing it causes silent logouts.
Matcher must exclude static assets β€” every unmatched request triggers a Supabase API call.

Server Actions: Sign Up, Sign In, Sign Out, OAuth

Server Actions handle authentication flows on the server. They receive form data, call Supabase Auth methods, and redirect the user. The key pattern: Server Actions use the server client (createServerClient with cookies()), not the browser client.

Each auth flow has specific requirements: sign up requires email confirmation (unless disabled), sign in sets cookies via the response, sign out clears cookies, and OAuth requires a callback route that exchanges the auth code for a session.

The redirect URL for OAuth must be registered in the Supabase Dashboard. Mismatches cause 403 errors that are difficult to debug because the error occurs at the OAuth provider level, not in your code.

io.thecodeforge.auth.server-actions.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
// ============================================
// Server Actions for Authentication
// ============================================
// File: app/auth/actions.ts
// All auth flows run on the server β€” never expose keys to the client

'use server'

import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import { createClient } from '@/lib/supabase/server'

// ---- Sign Up ----
// Creates a new user with email and password
// Supabase sends a confirmation email by default
// User must click the link to verify their email

export async function signUp(formData: FormData) {
  const supabase = await createClient()

  const email = formData.get('email') as string
  const password = formData.get('password') as string
  const name = formData.get('name') as string

  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      data: {
        // Custom user metadata stored in auth.users.raw_user_meta_data
        full_name: name,
      },
      // URL the user is redirected to after clicking the confirmation email
      emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
    },
  })

  if (error) {
    return { error: error.message }
  }

  // Show confirmation message β€” user must verify email
  redirect('/auth/check-email')
}

// ---- Sign In ----
// Authenticates with email and password
// Sets session cookies automatically via @supabase/ssr

export async function signIn(formData: FormData) {
  const supabase = await createClient()

  const email = formData.get('email') as string
  const password = formData.get('password') as string

  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  })

  if (error) {
    return { error: error.message }
  }

  redirect('/dashboard')
}

// ---- Sign In with OAuth ----
// Initiates the OAuth flow (Google, GitHub, etc.)
// User is redirected to the provider, then back to the callback route

export async function signInWithOAuth(provider: 'google' | 'github' | 'azure') {
  const supabase = await createClient()
  const headersList = await headers()
  const origin = headersList.get('origin')

  const { data, error } = await supabase.auth.signInWithOAuth({
    provider,
    options: {
      // This URL must be registered in Supabase Dashboard
      // Auth > URL Configuration > Redirect URLs
      redirectTo: `${origin}/auth/callback`,
    },
  })

  if (error) {
    redirect('/login?error=oauth_failed')
  }

  // Supabase returns the OAuth provider URL β€” redirect the user
  redirect(data.url)
}

// ---- Sign Out ----
// Clears session cookies and redirects to login

export async function signOut() {
  const supabase = await createClient()
  await supabase.auth.signOut()
  redirect('/login')
}

// ---- Forgot Password ----
// Sends a password reset email

export async function forgotPassword(formData: FormData) {
  const supabase = await createClient()
  const email = formData.get('email') as string

  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`,
  })

  if (error) {
    return { error: error.message }
  }

  return { success: 'Check your email for the reset link' }
}

// ---- Update Password ----
// Called from the reset password page after user clicks the email link

export async function updatePassword(formData: FormData) {
  const supabase = await createClient()
  const password = formData.get('password') as string

  const { error } = await supabase.auth.updateUser({
    password,
  })

  if (error) {
    return { error: error.message }
  }

  redirect('/dashboard')
}

// ---- OAuth Callback Route ----
// File: app/auth/callback/route.ts
// Exchanges the auth code for a session

import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const code = searchParams.get('code')
  const next = searchParams.get('next') ?? '/dashboard'

  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)

    if (!error) {
      const forwardedHost = request.headers.get('x-forwarded-host')
      const isLocalEnv = process.env.NODE_ENV === 'development'

      if (isLocalEnv) {
        return NextResponse.redirect(`http://localhost:3000${next}`)
      } else if (forwardedHost) {
        return NextResponse.redirect(`https://${forwardedHost}${next}`)
      } else {
        return NextResponse.redirect(`${process.env.NEXT_PUBLIC_SITE_URL}${next}`)
      }
    }
  }

  // Auth code exchange failed
  return NextResponse.redirect(`${new URL(request.url).origin}/login?error=auth_callback_failed`)
}

// ---- Magic Link ----
// Sends a one-click sign-in link

export async function signInWithMagicLink(formData: FormData) {
  const supabase = await createClient()
  const email = formData.get('email') as string

  const { error } = await supabase.auth.signInWithOtp({
    email,
    options: {
      emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
    },
  })

  if (error) {
    return { error: error.message }
  }

  return { success: 'Check your email for the sign-in link' }
}
Mental Model
Server Actions as the Auth Gateway
Server Actions are the single entry point for all auth flows β€” they run on the server and set cookies via @supabase/ssr.
  • Sign up creates the user β€” email confirmation is required by default (can be disabled in Dashboard)
  • Sign in sets session cookies β€” @supabase/ssr handles cookie write automatically
  • OAuth initiates a redirect β€” the callback route exchanges the code for a session
  • Sign out clears cookies β€” always redirect after signOut() to avoid stale UI
  • All auth errors return a message string β€” handle them in the form component
πŸ“Š Production Insight
Server Actions use the server client β€” never the browser client for auth flows.
OAuth callback must exchange the auth code for a session β€” without it, the user is redirected but not authenticated.
Rule: every OAuth flow needs a callback route that calls exchangeCodeForSession().
🎯 Key Takeaway
Server Actions are the auth gateway β€” sign up, sign in, sign out, OAuth all run on the server.
OAuth requires a callback route that exchanges the auth code for a session β€” register the URL in Dashboard.
Sign out must redirect β€” without redirect, the UI shows stale authenticated state.

Route Protection: Server Components and Layout Guards

Route protection happens at two levels: middleware (runs before render, catches all requests) and Server Components (runs during render, provides user data to the page). Middleware is for enforcement β€” it redirects unauthenticated users. Server Components are for data β€” they fetch the user and pass it to the UI.

The pattern: middleware protects routes at the network level (redirects before any code runs). Server Components protect at the render level (fetch user, show/hide content). Both use getUser() β€” this is the Supabase-recommended method for server-side auth checks because it validates the JWT with the Supabase Auth server.

io.thecodeforge.auth.route-protection.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
// ============================================
// Route Protection Patterns
// ============================================

// ---- Pattern 1: Server Component Auth Check ----
// File: app/dashboard/page.tsx
// Fetches the user in a Server Component β€” passes to children

import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'

export default async function DashboardPage() {
  const supabase = await createClient()

  // getUser() validates the JWT with Supabase Auth server
  // Do NOT use getSession() for server-side auth β€” it reads
  // from cookies without validation
  const { data: { user }, error } = await supabase.auth.getUser()

  if (error || !user) {
    redirect('/login')
  }

  // Fetch user-specific data
  const { data: profile } = await supabase
    .from('profiles')
    .select('*')
    .eq('id', user.id)
    .single()

  return (
    <div>
      <h1>Welcome, {profile?.full_name ?? user.email}</h1>
      <p>Email: {user.email}</p>
      <p>Member since: {new Date(user.created_at).toLocaleDateString()}</p>
    </div>
  )
}

// ---- Pattern 2: Layout Guard ----
// File: app/(protected)/layout.tsx
// Protects all routes under the (protected) route group

import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { Sidebar } from '@/components/sidebar'

export default async function ProtectedLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  return (
    <div className="flex min-h-screen">
      <Sidebar user={user} />
      <main className="flex-1 p-6">
        {children}
      </main>
    </div>
  )
}

// ---- Pattern 3: Client Component with User Context ----
// File: components/user-provider.tsx
// Passes server-fetched user to Client Components

'use client'

import { createContext, useContext } from 'react'
import type { User } from '@supabase/supabase-js'

const UserContext = createContext<User | null>(null)

export function UserProvider({
  user,
  children,
}: {
  user: User
  children: React.ReactNode
}) {
  return (
    <UserContext.Provider value={user}>
      {children}
    </UserContext.Provider>
  )
}

export function useUser() {
  const user = useContext(UserContext)
  if (!user) {
    throw new Error('useUser must be used within UserProvider')
  }
  return user
}

// ---- Usage in Layout ----
// File: app/(protected)/layout.tsx (updated)

import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { UserProvider } from '@/components/user-provider'

export default async function ProtectedLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  return (
    <UserProvider user={user}>
      {children}
    </UserProvider>
  )
}

// ---- Client Component consuming the user ----
// File: components/user-avatar.tsx

'use client'

import { useUser } from '@/components/user-provider'

export function UserAvatar() {
  const user = useUser()

  return (
    <div className="flex items-center gap-2">
      <div className="h-8 w-8 rounded-full bg-primary">
        {user.email?.charAt(0).toUpperCase()}
      </div>
      <span className="text-sm">{user.email}</span>
    </div>
  )
}
⚠ getUser() vs getSession() on the Server
πŸ“Š Production Insight
getUser() validates the JWT with Supabase Auth server β€” getSession() reads cookies without validation.
Using getSession() on the server is a security vulnerability β€” cookies can be forged.
Rule: always getUser() on the server, getSession() only in Client Components.
🎯 Key Takeaway
Middleware enforces auth at the network level β€” Server Components provide user data at render time.
Always use getUser() on the server β€” getSession() reads unvalidated cookies and is insecure.
UserProvider pattern passes server-fetched user to Client Components without re-fetching.

Row Level Security: Server-Side Authorization

Supabase uses Row Level Security (RLS) to enforce authorization at the database level. RLS policies control which rows a user can read, insert, update, or delete. The auth.uid() function returns the authenticated user's ID β€” it is available in RLS policies because Supabase sets it from the JWT.

RLS is the authorization layer β€” it works independently of the authentication layer. Even if a user is authenticated, they cannot access rows that RLS policies deny. This is defense in depth: even if your application code has a bug, the database enforces access control.

The critical rule: NEVER disable RLS on tables that store user data. If RLS is disabled, any authenticated user can read or modify any row β€” including other users' data.

io.thecodeforge.auth.rls-policies.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
-- ============================================
-- Row Level Security (RLS) Policies
-- ============================================
-- RLS enforces authorization at the database level
-- auth.uid() returns the authenticated user's ID from the JWT

-- ---- Enable RLS on a table ----
-- Without this, RLS policies are ignored
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;

-- ---- Policy: Users can read their own profile ----
CREATE POLICY "Users can read own profile"
  ON public.profiles
  FOR SELECT
  USING (auth.uid() = id);

-- ---- Policy: Users can update their own profile ----
CREATE POLICY "Users can update own profile"
  ON public.profiles
  FOR UPDATE
  USING (auth.uid() = id)
  WITH CHECK (auth.uid() = id);

-- ---- Policy: Users can insert their own profile ----
-- Typically called via a trigger after user signs up
CREATE POLICY "Users can insert own profile"
  ON public.profiles
  FOR INSERT
  WITH CHECK (auth.uid() = id);

-- ---- Policy: Users cannot delete profiles ----
-- No DELETE policy = deletion is denied by default

-- ---- Auto-create profile on signup ----
-- Trigger that creates a profile row when a user signs up
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
  INSERT INTO public.profiles (id, full_name, avatar_url)
  VALUES (
    NEW.id,
    NEW.raw_user_meta_data ->> 'full_name',
    NEW.raw_user_meta_data ->> 'avatar_url'
  );
  RETURN NEW;
END;
$$;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW
  EXECUTE FUNCTION public.handle_new_user();

-- ---- Multi-tenant: Team-based access ----
-- Users can only access data from their team
CREATE TABLE public.projects (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  team_id UUID NOT NULL REFERENCES public.teams(id),
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;

-- Policy: Users can read projects from their team
CREATE POLICY "Team members can read projects"
  ON public.projects
  FOR SELECT
  USING (
    team_id IN (
      SELECT team_id FROM public.team_members
      WHERE user_id = auth.uid()
    )
  );

-- Policy: Team admins can create projects
CREATE POLICY "Team admins can create projects"
  ON public.projects
  FOR INSERT
  WITH CHECK (
    team_id IN (
      SELECT team_id FROM public.team_members
      WHERE user_id = auth.uid()
      AND role = 'admin'
    )
  );

-- ---- Service Role Key: Bypasses RLS ----
-- Use ONLY in server-side code that needs full access
-- NEVER expose the service role key to the client
-- Example: background jobs, webhooks, admin operations

-- In your server code:
-- import { createClient } from '@supabase/supabase-js'
-- const supabase = createClient(
--   process.env.NEXT_PUBLIC_SUPABASE_URL!,
--   process.env.SUPABASE_SERVICE_ROLE_KEY! -- bypasses RLS
-- )
⚠ RLS is Not Optional for User Data
πŸ“Š Production Insight
RLS enforces authorization at the database level β€” even if app code has bugs, data is protected.
The service role key bypasses RLS β€” use it only for admin operations, never expose it to the client.
Rule: enable RLS on every table that stores user data β€” test policies with different user contexts.
🎯 Key Takeaway
RLS is the authorization layer β€” auth.uid() identifies the user, policies control access.
Service role key bypasses RLS β€” use only for server-side admin operations.
Always enable RLS on user data tables β€” a missing policy denies access by default.
πŸ—‚ Supabase Auth Client Comparison
Which client factory to use in each Next.js context
ContextFactory FunctionCookie HandlingToken RefreshUse For
Client ComponentcreateBrowserClientdocument.cookieVia middlewareUI interactions, real-time subscriptions
Server ComponentcreateServerClient + cookies()next/headers cookies()Via middlewareSSR auth checks, data fetching
Server ActioncreateServerClient + cookies()next/headers cookies()Via middlewareSign up, sign in, sign out flows
Route HandlercreateServerClient + cookies()next/headers cookies()Via middlewareAPI routes, webhooks
MiddlewarecreateServerClientrequest.cookiesTriggers refreshToken refresh, route protection

🎯 Key Takeaways

  • @supabase/ssr is mandatory for App Router β€” provides cookie-based sessions across client and server
  • Middleware is non-negotiable β€” it refreshes access tokens on every request
  • Always use getUser() on the server β€” getSession() reads unvalidated cookies
  • Three client factories: browser, server, middleware β€” each configured for its rendering context
  • RLS enforces authorization at the database level β€” never disable it on user data tables
  • OAuth requires a callback route that exchanges the auth code for a session

⚠ Common Mistakes to Avoid

    βœ•Using supabase-js without @supabase/ssr
    Symptom

    Sessions do not persist across page navigations. Users are logged out on every refresh. Server Components always see the user as unauthenticated because supabase-js stores sessions in localStorage which is not accessible server-side.

    Fix

    Install @supabase/ssr and use createBrowserClient (client), createServerClient (server), and createServerClient (middleware). The ssr package manages cookie-based sessions that work across client and server.

    βœ•Not implementing middleware
    Symptom

    Users are silently logged out after 1 hour when the access token expires. Session duration averages 52 minutes instead of the expected 7 days. Supabase dashboard shows 3x normal token grant events from constant re-authentication.

    Fix

    Create middleware.ts at the project root. Call supabase.auth.getUser() on every matched request β€” this triggers automatic token refresh when the access token is near expiry. Configure the matcher to exclude static assets.

    βœ•Using getSession() instead of getUser() on the server
    Symptom

    The application appears to work but is vulnerable to session forgery. An attacker can craft cookies that pass getSession() checks because it reads from cookies without validating the JWT with the Supabase Auth server.

    Fix

    Always use getUser() in Server Components, Server Actions, and Route Handlers. getUser() validates the JWT with the Supabase Auth server β€” it returns an error if the token is invalid or expired. Use getSession() only in Client Components.

    βœ•Forgetting to register OAuth redirect URLs
    Symptom

    OAuth sign-in redirects to the provider but returns a 403 or access_denied error on callback. The browser shows the Supabase error page instead of the application.

    Fix

    Register the callback URL in Supabase Dashboard > Authentication > URL Configuration > Redirect URLs. The URL must match exactly: protocol (https), domain, and path (/auth/callback). Add localhost URLs for development.

    βœ•Using the browser client in Server Components
    Symptom

    Server Component throws a runtime error about document is not defined, or hydration mismatch errors appear because the server and client render different content based on the session state.

    Fix

    Use createServerClient from @supabase/ssr in Server Components. Pass cookies() from next/headers to the cookie handler. The browser client (createBrowserClient) accesses document.cookie which does not exist in the server environment.

    βœ•Disabling RLS on user data tables
    Symptom

    Any authenticated user can read or modify any row in the table β€” including other users' private data. A single API call with a different user ID returns data that should be restricted.

    Fix

    Enable RLS on every table that stores user data. Create SELECT, INSERT, UPDATE, and DELETE policies that use auth.uid() to restrict access to the current user's rows. Test policies with different user contexts before deploying.

Interview Questions on This Topic

  • QWhy is @supabase/ssr required for Next.js App Router, and what happens if you use supabase-js alone?Mid-levelReveal
    @supabase/ssr provides cookie-based session management that works across client and server contexts. The supabase-js package alone stores sessions in localStorage by default. This breaks App Router because: 1. Server Components cannot access localStorage β€” they have no browser context, so they always see the user as unauthenticated. 2. Sessions do not persist across page navigations β€” each server-side render starts without a session. 3. There is no automatic token refresh β€” the access token expires after 1 hour and users are silently logged out. The ssr package solves this by using cookies (available on both client and server) and providing three factory functions: createBrowserClient for Client Components, createServerClient for Server Components, and createServerClient configured for middleware that refreshes tokens on every request.
  • QWhat is the difference between getUser() and getSession() in Supabase Auth, and when should you use each?Mid-levelReveal
    getUser() validates the JWT with the Supabase Auth server β€” it makes an API call to verify the token is valid, not expired, and not revoked. It is secure for server-side use because it cannot be fooled by forged cookies. getSession() reads the session from cookies or localStorage without validating the JWT. It is fast (no API call) but can return stale or tampered data if used on the server. The rule: always use getUser() in Server Components, Server Actions, and Route Handlers. Use getSession() only in Client Components where the browser manages the session and the user cannot easily forge cookie data. Using getSession() on the server is a security vulnerability.
  • QHow does middleware handle token refresh in a Supabase + Next.js application?SeniorReveal
    The middleware creates a Supabase client with createServerClient and configures the cookie handler to read from request.cookies and write to the response cookies. When the middleware calls supabase.auth.getUser(), this triggers the token refresh mechanism. If the access token is expired or near expiry, the Supabase client automatically uses the refresh token (stored in cookies) to obtain a new access token from the Supabase Auth server. The new tokens are written back to cookies via the setAll callback, which creates a new NextResponse with updated Set-Cookie headers. Without this middleware call, the access token expires after 1 hour (default) and there is no mechanism to refresh it β€” the user is silently logged out.
  • QWhat is Row Level Security in Supabase and why is it important for authentication?Mid-levelReveal
    Row Level Security (RLS) is a PostgreSQL feature that restricts which rows a user can access based on policies. Supabase integrates RLS with its Auth system through the auth.uid() function, which returns the authenticated user's ID from the JWT. RLS is the authorization layer β€” authentication verifies who the user is (via Supabase Auth), while authorization controls what they can access (via RLS policies). Even if a user is authenticated, they cannot access rows that RLS policies deny. RLS provides defense in depth: even if the application code has a bug that exposes a query without a WHERE clause, the database enforces access control. The service role key bypasses RLS β€” it should only be used for server-side admin operations and never exposed to the client.

Frequently Asked Questions

Can I use Supabase Auth with the Pages Router instead of App Router?

Yes, but the client setup differs. The Pages Router uses @supabase/auth-helpers-nextjs (deprecated) or @supabase/ssr with a different cookie configuration. The App Router approach described in this guide uses the latest @supabase/ssr package which is the recommended path for new projects.

How do I customize the email templates for sign-up confirmation and password reset?

Go to Supabase Dashboard > Authentication > Email Templates. You can customize the confirmation, invitation, magic link, and password reset templates. Use {{ .ConfirmationURL }} for the redirect link and {{ .Token }} for the OTP code. Templates support HTML and plain text.

How do I handle session expiry gracefully in the UI?

The middleware handles token refresh automatically β€” users should not experience session expiry during active use. For inactive sessions (user leaves tab open for days), the refresh token eventually expires. Handle this by checking getUser() on the server and redirecting to login if it returns null. In Client Components, listen for the SIGNED_OUT auth event via supabase.auth.onAuthStateChange().

Can I use Supabase Auth with multiple Supabase projects in a single Next.js app?

Yes, but each project needs its own client instance with its own cookie namespace. Use different cookie name prefixes to avoid conflicts. Create separate factory functions for each project (e.g., createClientProjectA, createClientProjectB) with distinct cookie configurations.

How do I test Supabase Auth in my CI/CD pipeline?

Use the Supabase CLI to run a local Supabase instance in CI. The command supabase start spins up all Supabase services locally. Set the environment variables to point to the local instance. For integration tests, create test users via the admin API (service role key) and test auth flows against the local instance.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← Previous10 Common Next.js 16 App Router Mistakes (And How to Fix Them)Next β†’tRPC v11 + Next.js 16: Complete Setup and Best Practices
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged