Supabase Auth on Vercel Requires Double Login in Production

I’m experiencing a double-login issue on Vercel production that does not occur on localhost.

Localhost Behavior (Expected)

  • User clicks “Sign in with Google”

  • OAuth redirect → /auth/callback?code=... → session established

  • User is immediately logged in and can access protected routes

  • Single login, works every time

Vercel Production Behavior (Broken)

  • User clicks “Sign in with Google”

  • OAuth redirect → /auth/callback → appears to land successfully

  • User is NOT logged in on first attempt

  • Must click “Sign in” again (sometimes 2–3 times) for session to persist

  • After multiple attempts, the session eventually “sticks”

  • Sometimes logs out again after refresh

Additional Symptoms

  • Auth cookies (sb-access-token, sb-refresh-token) appear to be set but not recognised immediately

  • Refreshing right after callback sometimes logs the user out

  • API routes return 401 Unauthorized even after apparent successful login

  • Clearing cookies temporarily resets the issue


What I’ve Tried

  1. Investigated Edge Runtime cookie propagation

  2. Added a temporary delay page after callback (didn’t fix it)

  3. Disabled middleware completely — issue persisted

  4. Added runtime = 'nodejs' to callback — no change

  5. Reviewed cookie handling inside middleware — saw repeated NextResponse.next() creation

  6. Rolled back to an older commit to remove recent auth refactors

None of these resolved the issue on Vercel.

Environment

Framework: Next.js 15.5.3 (App Router)

Deployment: Vercel (production)

Auth: Supabase Auth with `@supabase/ssr` v2

Auth Flow: OAuth (Google) + Magic Link

Runtime: Currently mixed (some routes use `runtime = ‘nodejs’`, others default to Edge)

Current Setup

middleware.ts

import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabaseMiddleware'

export async function middleware(request: NextRequest) {
  return await updateSession(request)
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)|api).*)',
  ],
}

lib/supabaseMiddleware.ts

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 }) // 🔴 Recreates response
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

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

  return supabaseResponse
}

src/app/auth/callback/route.ts

export const dynamic = 'force-dynamic'

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

  const cookieStore = await cookies()
  const supabase = createServerClient(/* ... */)

  await supabase.auth.exchangeCodeForSession(code)
  const { data: { session } } = await supabase.auth.getSession()

  if (!session) {
    return NextResponse.redirect(new URL('/login?error=auth_failed', origin))
  }

  const successRedirect = NextResponse.redirect(new URL(next, origin))
  cookieStore.getAll().forEach(({ name, value, ...options }) => {
    successRedirect.cookies.set({
      name,
      value,
      path: '/',
      sameSite: 'none',
      secure: true,
      ...options,
    })
  })

  return successRedirect
}

Supabase Server Client Structure (lib/supabaseServer.ts)

  • createSupabaseServerClientForComponent() → no-op setAll

  • createSupabaseServerClientForRoute() → writes cookies with sameSite: 'none'

  • createSupabaseServerClientForHandler() → same as above
    What I Need Clarification On

    Since I’m back on my older commit (stable before refactors), I want to understand the correct, production-safe setup for Supabase SSR on Vercel:

    1. Should I use middleware or not?

    Supabase docs show both patterns:

    • With updateSession() middleware (edge-based)

    • Without middleware (server-component only)

    But middleware runs on Edge runtime, and Supabase SSR docs mention limitations on Edge.

    Which approach is correct for Vercel today?

    2. Should I explicitly force runtime = "nodejs" on:

    • /auth/callback

    • Server component pages

    • API routes

    • Globally in next.config.js

    Is mixing edge/node causing the stale session?

    3. Are there required files for a stable setup?

    Should my project include:

    • middleware.ts?

    • Only a callback route?

    • Only server clients?

    • Both middleware + server clients?

    What is the recommended minimal setup?

    4. Is this a known issue with Supabase SSR on Vercel?

    I’ve seen a few posts mentioning:

    • Multi-region cold starts

    • Cookie propagation delay

    • Callback and next SSR request hitting different function instances

    • Edge isolation issues

    Would love to know if this aligns with known behavior.

    If anyone has a working, clean example:

    A working repo link or file structure example (auth flow + SSR sessions) would help massively.

    Thank you

    I’ve been stuck on this for weeks and would love clarity from anyone who has solved this in production or from the Vercel/Supabase team.
    Happy to share logs, repo snippets, or deploy URLs if needed.

    Thanks in advance!