Supabase

Supabase Auth Callback Redirect Not Working? Next.js Fix

Auth redirect not working after Supabase sign-in? Here are the three root causes and the exact fixes — callback route, redirect URL allowlist, and router.refresh().

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
11 min read

Tested environment#

FieldValue
Next.js14 + 15 (App Router)
@supabase/ssr0.4+
@supabase/supabase-js2.x
RuntimeNode.js (Vercel serverless)
Last verified2026-06-19
Verification leveldocs-grounded · code-reviewed — verified against Supabase Auth docs and Next.js App Router docs; not claimed as production-tested

Failure signature#

You're hitting this issue if one or more of these is true after sign-in:

  • Redirect works locally but fails on Vercel — user stays on login page or gets a Supabase error page
  • OAuth callback URL not allowed — Supabase returns redirect_uri_mismatch or a generic auth error
  • User confirms email but session is missing — redirected somewhere but getUser() returns null
  • OAuth flow completes but app stays logged out — session cookie not written
  • Middleware redirects in a loop — user gets bounced between /login and a protected route
  • Wrong redirectTo URL — user lands on a 404 or the wrong page after OAuth
  • router.push() navigates but Server Components still show logged-out state — missing router.refresh()

Fast diagnosis#

Open the Network tab and sign in. Then match your symptom:

What you see in DevToolsLikely causeJump to
/auth/callback returns 404Missing callback routeFix 1
/auth/callback returns 302 to Supabase error pageRedirect URL not allowlistedFix 2
/auth/callback returns 200 but page shows logged-outMissing router.refresh()Fix 3
OAuth sign-in never redirects back to your appWrong redirectTo in signInWithOAuth()Fix 2
Middleware redirect loopAuth route not excluded from protected matcherFix 4

Why this happens#

Supabase uses a PKCE flow for OAuth and magic links in Next.js App Router:

  1. User clicks "Sign in with Google".
  2. Supabase redirects to Google with a code challenge.
  3. Google redirects back to your callback URL with a code parameter.
  4. Your callback route calls supabase.auth.exchangeCodeForSession(code).
  5. Supabase sets a session cookie.
  6. Your callback route redirects to the app.

If step 3 uses a URL not in your Supabase allowlist, Supabase rejects it before step 4 ever runs. If step 4 is missing (no callback route), the code expires and the session is never created.

For email/password: the session is set immediately in the browser, but Next.js Router Cache keeps serving the old server-rendered HTML — the page looks logged out even though supabase.auth.getUser() would return a user. router.refresh() forces Next.js to re-fetch the Server Components with the new session.


The fixes#

Fix 1: Create the /auth/callback route#

This route is required for OAuth and magic links. Without it, the PKCE code exchange never runs.

typescript
// app/auth/callback/route.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { NextResponse, type NextRequest } from 'next/server'
 
export async function GET(request: NextRequest) {
  const requestUrl = new URL(request.url)
  const code = requestUrl.searchParams.get('code')
  const token_hash = requestUrl.searchParams.get('token_hash')
  const type = requestUrl.searchParams.get('type') as 'email' | 'recovery' | null
  const next = requestUrl.searchParams.get('next') ?? '/dashboard'
 
  const cookieStore = await cookies()
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          )
        },
      },
    }
  )
 
  if (code) {
    const { error } = await supabase.auth.exchangeCodeForSession(code)
    if (!error) {
      return NextResponse.redirect(new URL(next, request.url))
    }
  }
 
  // Handle email confirmation token_hash
  if (token_hash && type) {
    const { error } = await supabase.auth.verifyOtp({ token_hash, type })
    if (!error) {
      return NextResponse.redirect(new URL(next, request.url))
    }
  }
 
  return NextResponse.redirect(
    new URL('/login?error=Could not authenticate user', request.url)
  )
}

Fix 2: Allowlist your redirect URL#

Go to Supabase Dashboard → Authentication → URL Configuration → Redirect URLs and add:

plaintext
# Local development
http://localhost:3000/auth/callback
 
# Production
https://your-domain.com/auth/callback
 
# Vercel preview deployments (wildcard)
https://*-yourproject.vercel.app/auth/callback

Then in your sign-in code, point redirectTo to the callback route:

typescript
// OAuth
await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
  },
})
 
// Magic link
await supabase.auth.signInWithOtp({
  email,
  options: {
    emailRedirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
  },
})

Fix 3: Call router.refresh() after router.push()#

For email/password auth, the session is set client-side but Next.js does not automatically re-fetch Server Components. You must call both:

typescript
'use client'
import { createBrowserClient } from '@supabase/ssr'
import { useRouter } from 'next/navigation'
 
export function LoginForm() {
  const router = useRouter()
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
 
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
 
    const { error } = await supabase.auth.signInWithPassword({
      email: formData.get('email') as string,
      password: formData.get('password') as string,
    })
 
    if (error) {
      // handle error
      return
    }
 
    // ✅ Both are required: push navigates, refresh re-fetches Server Components
    router.push('/dashboard')
    router.refresh()
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">Sign in</button>
    </form>
  )
}

Fix 4: Stop the middleware redirect loop#

If middleware protects /dashboard but also intercepts /login or /auth/callback, you get a loop. Exclude auth routes from the session check:

Next.js 16: the middleware file convention is deprecated and renamed proxy.ts (export a function named proxy). middleware.ts still runs, and it stays the option if you need the Edge runtime, which proxy does not support (Next.js 16 upgrade guide). The code below is the same under either name.

typescript
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
 
export async function middleware(request: NextRequest) {
  // Skip session check for auth routes — they handle their own redirects
  const { pathname } = request.nextUrl
  if (pathname.startsWith('/auth/') || pathname === '/login' || pathname === '/signup') {
    return NextResponse.next()
  }
 
  let response = NextResponse.next({ request })
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
        },
      },
    }
  )
 
  const { data: { user } } = await supabase.auth.getUser()
 
  if (!user && pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
 
  return response
}
 
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}

Verify the fix#

  1. Check Supabase redirect URL list — Dashboard → Auth → URL Configuration → Redirect URLs. Confirm your production URL and any Vercel preview pattern are listed.
  2. Check .env / Vercel env vars — NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY must be set for the right environment (Preview vs Production in Vercel settings).
  3. Test the OAuth flow — click "Sign in with Google", confirm you land on /auth/callback, then on your next target.
  4. Check cookies after callback — DevTools → Application → Cookies → your domain. You should see sb-*-auth-token cookies after a successful callback.
  5. Test email/password — sign in and watch the Network tab. Confirm /dashboard is loaded fresh (not from cache) and Server Components show the logged-in state.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

Production notes#

  • Trailing slashes — if your Vercel project is configured with trailingSlash: true, add the slash in the Supabase redirect URL: /auth/callback/.
  • Preview deployments — each Vercel preview has a unique URL. Use the wildcard pattern https://*-yourproject.vercel.app/** in Supabase to cover all previews at once.
  • Wildcard risk — a wildcard https://* would allow any domain. Always scope to your own project subdomain.
  • NEXT_PUBLIC_SUPABASE_URL — this is the URL of your Supabase project API, not your app URL. It should end in .supabase.co.
  • NEXT_PUBLIC_SUPABASE_ANON_KEY — the anon key is safe to expose client-side. The service role key is not.
  • Custom SMTP for email confirmation — if you use custom SMTP, the confirmation email uses the redirect URL from your Supabase project settings, not from code. Update it there.

Further reading: Fix: Multiple GoTrueClient instances detected (Supabase).

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote