back to thoughts
    6 min read

    Server-Side Auth in Next.js with Supabase: My Setup

    This is the approach I use to get secure, SSR-friendly authentication working in the Next.js App Router with Supabase. It uses HTTP-only cookies, a small serve

    #supabase#react#app router#ssr#next.js#authentication
    share:

    This is the approach I use to get secure, SSR-friendly authentication working in the Next.js App Router with Supabase. It uses HTTP-only cookies, a small server client helper, a browser client for client components, and middleware to keep sessions fresh.


    What we are building

    • A server client you can call from Server Components, Server Actions, and Route Handlers
    • A browser client for Client Components (for example, realtime)
    • Middleware that refreshes sessions and writes cookies correctly
    • A simple login and signup flow with an email confirmation route
    • A protected page that redirects unauthenticated users

    Security note: in server code, check auth with supabase.auth.getUser() so the token is verified with the auth server. Do not rely on getSession() for protection logic.


    0) Prerequisites

    • Next.js 14 or newer (App Router)
    • A Supabase project with Project URL and Anon Key

    1) Install packages

    npm install @supabase/supabase-js @supabase/ssr

    2) Environment variables

    Create a .env.local file:

    NEXT_PUBLIC_SUPABASE_URL=<your_supabase_project_url>
    NEXT_PUBLIC_SUPABASE_ANON_KEY=<your_supabase_anon_key>

    These values are safe to expose in the browser because they are anon keys, not service role keys.


    3) Supabase clients (server and browser)

    Create a utils/supabase/ folder and add two files.

    utils/supabase/client.ts (for Client Components)

    // utils/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 this only in the browser, for example when subscribing to realtime.

    utils/supabase/server.ts (for Server Components, Server Actions, Route Handlers)

    // utils/supabase/server.ts
    import { createServerClient, type CookieOptions } 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: { name: string; value: string; options: CookieOptions }[]) {
              try {
                cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
              } catch {
                // Called from a Server Component: ignore writes, middleware will persist them
              }
            },
          },
        }
      )
    }

    This wiring makes Supabase use HTTP-only cookies for SSR and token refresh.


    4) Session middleware

    Create utils/supabase/middleware.ts and a project root middleware.ts. The middleware revalidates the session and ensures cookies are up to date on every request that needs it.

    utils/supabase/middleware.ts

    // utils/supabase/middleware.ts
    import { createServerClient, type CookieOptions } 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().map(({ name, value }) => ({ name, value }))
            },
            setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
              cookiesToSet.forEach(({ name, value, options }) => {
                response.cookies.set(name, value, options)
              })
            },
          },
        }
      )
    
      // Refresh the session if needed and sync cookies
      await supabase.auth.getUser()
    
      return response
    }

    middleware.ts

    // middleware.ts
    import { type NextRequest } from 'next/server'
    import { updateSession } from '@/utils/supabase/middleware'
    
    export async function middleware(request: NextRequest) {
      return updateSession(request)
    }
    
    export const config = {
      matcher: [
        // Run on all paths except static and image assets and favicon
        '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
      ],
    }

    Why middleware: Server Components can read cookies but cannot write them. The middleware runs first and persists refreshed tokens for both server and browser before your pages render.


    5) Login UI and Server Actions

    A simple email and password login that keeps credentials off the client.

    app/login/page.tsx

    // app/login/page.tsx
    import { login, signup } from './actions'
    
    export default function LoginPage() {
      return (
        <form>
          <label htmlFor="email">Email:</label>
          <input id="email" name="email" type="email" required />
          <label htmlFor="password">Password:</label>
          <input id="password" name="password" type="password" required />
          <button formAction={login}>Log in</button>
          <button formAction={signup}>Sign up</button>
        </form>
      )
    }

    app/login/actions.ts

    // app/login/actions.ts
    'use server'
    
    import { redirect } from 'next/navigation'
    import { createClient } from '@/utils/supabase/server'
    
    export async function login(formData: FormData) {
      const email = String(formData.get('email') ?? '')
      const password = String(formData.get('password') ?? '')
    
      const supabase = await createClient()
      const { error } = await supabase.auth.signInWithPassword({ email, password })
      if (error) {
        throw new Error(error.message)
      }
      redirect('/private')
    }
    
    export async function signup(formData: FormData) {
      const email = String(formData.get('email') ?? '')
      const password = String(formData.get('password') ?? '')
    
      const supabase = await createClient()
      const { error } = await supabase.auth.signUp({ email, password })
      if (error) throw new Error(error.message)
      // If confirmations are enabled, the user must confirm via email before login
      redirect('/check-email')
    }

    Tip: call cookies() before any Supabase fetches inside Server Actions if you need to avoid caching for authenticated data.


    6) Email confirmation route

    If email confirmations are on, update the Confirm signup template in the Supabase dashboard to use this URL pattern:

    {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email

    Then add a confirmation route:

    app/auth/confirm/route.ts

    // app/auth/confirm/route.ts
    import { type EmailOtpType } from '@supabase/supabase-js'
    import { type NextRequest } from 'next/server'
    import { createClient } from '@/utils/supabase/server'
    import { redirect } from 'next/navigation'
    
    export async function GET(request: NextRequest) {
      const { searchParams } = new URL(request.url)
      const token_hash = searchParams.get('token_hash')
      const type = searchParams.get('type') as EmailOtpType | null
      const next = searchParams.get('next') ?? '/'
    
      if (token_hash && type) {
        const supabase = await createClient()
        const { error } = await supabase.auth.verifyOtp({ type, token_hash })
        if (!error) redirect(next)
      }
      redirect('/error')
    }

    7) Protect a server page

    app/private/page.tsx

    // app/private/page.tsx
    import { redirect } from 'next/navigation'
    import { createClient } from '@/utils/supabase/server'
    
    export default async function PrivatePage() {
      const supabase = await createClient()
      const { data, error } = await supabase.auth.getUser()
    
      if (error || !data?.user) {
        redirect('/login')
      }
    
      return <p>Hello {data.user.email}</p>
    }

    Use getUser() in server code so the token is verified against the auth server.


    8) Sign out with a Server Action

    // app/logout/actions.ts
    'use server'
    
    import { redirect } from 'next/navigation'
    import { createClient } from '@/utils/supabase/server'
    
    export async function signOut() {
      const supabase = await createClient()
      await supabase.auth.signOut()
      redirect('/login')
    }

    Common pitfalls and notes

    • This writeup targets the App Router. Do not mix this cookie-based server approach with older client-only patterns.
    • Prefer getUser() over getSession() for server-side protection checks.
    • Update the middleware matcher to exclude static assets to avoid unnecessary work.
    • If your project warns that cookies() should be awaited, keep const cookieStore = await cookies() in the server client helper. If not, you can remove the await.

    Further reading

    👨‍💻

    Ryan Katayi

    Full-stack developer who turns coffee into code. Building things that make the web a better place, one commit at a time.

    more about me