DEV Community

Cover image for Supabase Auth Redirect Not Working Next.js App Router
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on • Originally published at iloveblogs.blog

Supabase Auth Redirect Not Working Next.js App Router

Supabase Auth Redirect Not Working Next.js App Router

Supabase Auth Redirect Not Working Next.js App Router Solution

Authentication redirects failing in Next.js App Router is a common issue that leaves users stuck on the login page even after successful authentication. You implement Supabase auth, users sign in successfully, but the redirect to the dashboard never happens—or worse, they get redirected to the wrong page. As covered in our Supabase Authentication & Authorization Patterns, proper redirect handling is essential for smooth user experience.

In this guide, I'll show you exactly why redirects fail in Next.js 14 App Router and how to fix them for email/password, OAuth, and magic link authentication.

Why Auth Redirects Fail in Next.js App Router

The Next.js App Router introduced significant changes to how routing works, and Supabase auth redirects require special handling. Here's what's happening:

The Core Problem

  1. Server vs Client Rendering - App Router uses Server Components by default, but auth state changes happen on the client
  2. Router Cache - Next.js aggressively caches routes, so even after authentication, cached pages may not reflect the new auth state
  3. Callback URL Mismatch - OAuth and magic link callbacks need explicit handling in App Router

Why Traditional Solutions Don't Work

// ❌ This doesn't work in App Router
const { data, error } = await supabase.auth.signInWithPassword({
  email,
  password,
})

if (!error) {
  window.location.href = '/dashboard' // Causes full page reload
}
Enter fullscreen mode Exit fullscreen mode

The issue: Using window.location.href causes a full page reload and loses the React state. Using router.push() alone doesn't refresh Server Components.

Step-by-Step Solution

Step 1: Create Auth Callback Route

First, create a callback route handler for OAuth and magic link redirects:

// app/auth/callback/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'

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

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

    if (!error) {
      // Redirect to the intended destination
      return NextResponse.redirect(new URL(next, request.url))
    }
  }

  // If there's an error, redirect to login with error message
  return NextResponse.redirect(
    new URL('/login?error=Could not authenticate user', request.url)
  )
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Fix Email/Password Redirects

For email/password authentication, use both router.push() and router.refresh():

'use client'

import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)
  const router = useRouter()
  const supabase = createClient()

  async function handleLogin(e: React.FormEvent) {
    e.preventDefault()
    setLoading(true)

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

    if (error) {
      alert(error.message)
      setLoading(false)
      return
    }

    // ✅ CORRECT: Use both push and refresh
    router.push('/dashboard')
    router.refresh() // This updates Server Components
  }

  return (
    <form onSubmit={handleLogin}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Signing in...' : 'Sign In'}
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Fix OAuth Redirects

For OAuth providers (Google, GitHub, etc.), configure the redirect URL properly:

'use client'

import { createClient } from '@/lib/supabase/client'

export function GoogleSignIn() {
  const supabase = createClient()

  async function handleGoogleSignIn() {
    const { data, error } = await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        // ✅ CORRECT: Point to callback route
        redirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
        queryParams: {
          access_type: 'offline',
          prompt: 'consent',
        },
      },
    })

    if (error) {
      alert(error.message)
    }
  }

  return (
    <button onClick={handleGoogleSignIn}>
      Sign in with Google
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Fix Magic Link Redirects

For magic link authentication, configure the email redirect:

'use client'

import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'

export function MagicLinkForm() {
  const [email, setEmail] = useState('')
  const [loading, setLoading] = useState(false)
  const [sent, setSent] = useState(false)
  const supabase = createClient()

  async function handleMagicLink(e: React.FormEvent) {
    e.preventDefault()
    setLoading(true)

    const { error } = await supabase.auth.signInWithOtp({
      email,
      options: {
        // ✅ CORRECT: Point to callback route with next parameter
        emailRedirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
      },
    })

    if (error) {
      alert(error.message)
    } else {
      setSent(true)
    }

    setLoading(false)
  }

  if (sent) {
    return <p>Check your email for the magic link!</p>
  }

  return (
    <form onSubmit={handleMagicLink}>
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Sending...' : 'Send Magic Link'}
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Add Middleware Protection

Ensure your middleware properly handles redirects:

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

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({
    request: {
      headers: request.headers,
    },
  })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return request.cookies.get(name)?.value
        },
        set(name: string, value: string, options: CookieOptions) {
          request.cookies.set({ name, value, ...options })
          response = NextResponse.next({ request: { headers: request.headers } })
          response.cookies.set({ name, value, ...options })
        },
        remove(name: string, options: CookieOptions) {
          request.cookies.set({ name, value: '', ...options })
          response = NextResponse.next({ request: { headers: request.headers } })
          response.cookies.set({ name, value: '', ...options })
        },
      },
    }
  )

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

  // Redirect to login if not authenticated
  if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // Redirect to dashboard if already authenticated
  if (user && (request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup')) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  return response
}

export const config = {
  matcher: ['/dashboard/:path*', '/login', '/signup'],
}
Enter fullscreen mode Exit fullscreen mode

Working Code Examples

Complete Login Flow with Redirect

// app/login/page.tsx
'use client'

import { createClient } from '@/lib/supabase/client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState, useEffect } from 'react'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)
  const router = useRouter()
  const searchParams = useSearchParams()
  const supabase = createClient()

  // Get redirect destination from URL or default to dashboard
  const next = searchParams.get('next') ?? '/dashboard'

  // Show error if present in URL
  useEffect(() => {
    const error = searchParams.get('error')
    if (error) {
      alert(error)
    }
  }, [searchParams])

  async function handleLogin(e: React.FormEvent) {
    e.preventDefault()
    setLoading(true)

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

    if (error) {
      alert(error.message)
      setLoading(false)
      return
    }

    // Redirect to intended destination
    router.push(next)
    router.refresh()
  }

  return (
    <div>
      <h1>Sign In</h1>
      <form onSubmit={handleLogin}>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="Email"
          required
        />
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          placeholder="Password"
          required
        />
        <button type="submit" disabled={loading}>
          {loading ? 'Signing in...' : 'Sign In'}
        </button>
      </form>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

OAuth with Multiple Providers

// components/OAuthButtons.tsx
'use client'

import { createClient } from '@/lib/supabase/client'

export function OAuthButtons() {
  const supabase = createClient()

  async function signInWithProvider(provider: 'google' | 'github') {
    const { data, error } = await supabase.auth.signInWithOAuth({
      provider,
      options: {
        redirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
      },
    })

    if (error) {
      alert(error.message)
    }
  }

  return (
    <div>
      <button onClick={() => signInWithProvider('google')}>
        Sign in with Google
      </button>
      <button onClick={() => signInWithProvider('github')}>
        Sign in with GitHub
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

  • Mistake #1: Not using router.refresh() - After authentication, you must call router.refresh() to update Server Components. Without it, protected pages won't recognize the new auth state.

  • Mistake #2: Wrong callback URL - OAuth and magic links must redirect to /auth/callback, not directly to /dashboard. The callback route exchanges the code for a session.

  • Mistake #3: Missing next parameter - Always include a next parameter in your callback URL to specify where users should go after authentication.

  • Mistake #4: Using window.location.href - This causes a full page reload and loses React state. Use Next.js's router.push() and router.refresh() instead.

  • Mistake #5: Not handling errors in callback - Always check for errors in the callback route and redirect to login with an error message if authentication fails.

FAQ

Why does my OAuth redirect fail?

OAuth redirects fail when the callback URL is incorrect or not configured in Supabase. Ensure your callback URL is https://iloveblog.blog/auth/callback and add it to the "Redirect URLs" list in your Supabase project settings.

How do I redirect to a specific page after login?

Use the next query parameter in your redirect URL. For example: /auth/callback?next=/profile. The callback route will read this parameter and redirect accordingly.

Why do I need both router.push() and router.refresh()?

router.push() navigates to the new route, but router.refresh() is needed to update Server Components with the new auth state. Without refresh, Server Components will still show the old (unauthenticated) state.

Can I use redirectTo with email/password auth?

No, redirectTo only works with OAuth and magic link authentication. For email/password, use router.push() and router.refresh() after successful sign-in.

How do I test redirects locally?

Use http://localhost:3000/auth/callback as your redirect URL during development. Make sure to add this to your Supabase project's allowed redirect URLs.

Related Articles

Conclusion

Fixing auth redirects in Next.js App Router requires understanding the difference between Server and Client Components and properly handling the authentication callback. The key steps are:

  1. Create a callback route handler for OAuth and magic links
  2. Use router.push() + router.refresh() for email/password auth
  3. Always include the next parameter to specify redirect destination
  4. Configure middleware to handle protected routes

Test your implementation by signing in with each auth method and verifying users are redirected to the correct page. If redirects still fail, check your Supabase project settings to ensure callback URLs are properly configured.

With these fixes in place, your authentication flow will work smoothly across all auth methods in Next.js 14 App Router.


Originally published at https://www.iloveblogs.blog

Top comments (0)