Supabase Auth Callback Redirect Not Working? Next.js Fix
technology

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().

2026-02-16
11 min read
Supabase Auth Callback Redirect Not Working? Next.js Fix

Tested environment#

| Field | Value | |---|---| | Next.js | 14 + 15 (App Router) | | @supabase/ssr | 0.4+ | | @supabase/supabase-js | 2.x | | Runtime | Node.js (Vercel serverless) | | Last verified | 2026-06-19 | | Verification level | docs-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 DevTools | Likely cause | Jump to | |---|---|---| | /auth/callback returns 404 | Missing callback route | Fix 1 | | /auth/callback returns 302 to Supabase error page | Redirect URL not allowlisted | Fix 2 | | /auth/callback returns 200 but page shows logged-out | Missing router.refresh() | Fix 3 | | OAuth sign-in never redirects back to your app | Wrong redirectTo in signInWithOAuth() | Fix 2 | | Middleware redirect loop | Auth route not excluded from protected matcher | Fix 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:

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 varsNEXT_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.

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.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

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

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.