結論
Next.js App Router + Supabase Auth で「ログインしたはずなのにリダイレクトされ続ける」問題の原因は 3パターン に絞られます。
| パターン | 原因 | 症状 |
|---|---|---|
| 1 |
getSession → getUser 移行時に setAll を空実装 |
ログイン直後でも常に未認証 |
| 2 | matcher に /auth/callback が含まれる |
magic link 後にログインページに戻される |
| 3 | Next.js 15 で cookies() に await 漏れ |
セッションが常に null(エラーなし) |
今すぐ確認するチェックリスト:
-
middleware 内で
supabase.auth.getUser()を使っているか -
matcherから/auth/callbackを除外しているか -
cookies()にawaitがあるか(Next.js 15 以降) -
setAllで Cookie を書き込む実装になっているか
パターン1:getSession() → getUser() 移行でループ
Supabase は 2024 年末から getSession() の代わりに getUser() を推奨していますが、middleware での使い方が変わります。
壊れている実装
// ❌ setAll が空実装 → Cookie refresh できない
const supabase = createServerClient(url, key, {
cookies: {
getAll: () => request.cookies.getAll(),
setAll: () => {}, // ← これが原因
},
})
const { data: { user } } = await supabase.auth.getUser() // 常に null
setAll を空にすると Supabase が JWT リフレッシュのために書き換えようとする Cookie がレスポンスに乗りません。結果として getUser() は常に null を返します。
正しい実装
// ✅ setAll で supabaseResponse を都度再生成する
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 })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// ⚠️ getUser の前に途中 return しないこと
const { data: { user } } = await supabase.auth.getUser()
if (!user && !request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}
パターン2:matcher が /auth/callback を遮断
次の matcher は一見よさそうですが、/auth/callback をプロテクト対象に含めてしまいます。
// ❌ /auth/callback が保護対象に入る
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
magic link をクリックすると:
-
/auth/callback?code=xxxxxにリダイレクト - middleware が走る → この時点でセッション未確立
- middleware が「未認証」と判断して
/loginにリダイレクト - Route Handler が1度も実行されない
認証コードを処理する前に middleware が遮断していました。
// ✅ auth/ を明示的に除外
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|api/webhooks/|auth/|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
パターン3:Next.js 15 の cookies() に await 漏れ
Next.js 15 から cookies() が非同期になりました。
// ❌ Next.js 15 以降で await が必要
const cookieStore = cookies() // Promise が返る
// ✅
const cookieStore = await cookies() // ReadonlyRequestCookies が返る
await なしだと cookieStore が Promise オブジェクトになり、getAll() が TypeError または空配列を返します。エラーが出ないまま常に未認証扱いになります。
古いサンプルコードをそのままコピーした場合に踏みやすいです。
デバッグ手順
3パターンの切り分けには console.log が最速です。
export async function middleware(request: NextRequest) {
console.log('[mw] path:', request.nextUrl.pathname)
console.log('[mw] cookies:', request.cookies.getAll().map(c => c.name))
const response = await updateSession(request)
console.log('[mw] status:', response.status)
console.log('[mw] location:', response.headers.get('location'))
return response
}
加えて getUser() の直後:
const { data: { user }, error } = await supabase.auth.getUser()
console.log('[mw] user:', user?.id, 'error:', error?.message)
-
locationが/loginを指し続ける → パターン1 or 3 -
/auth/callbackへのリクエストが location/loginを返す → パターン2 -
userが常に null でerrorなし →setAll空実装(パターン1)
完全版 middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
const PROTECTED_PATHS = ['/dashboard', '/articles', '/settings']
async function updateSession(request: NextRequest): Promise<NextResponse> {
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 })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
const pathname = request.nextUrl.pathname
const isProtected = PROTECTED_PATHS.some(p => pathname.startsWith(p))
if (!user && isProtected) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
if (user && pathname === '/login') {
const url = request.nextUrl.clone()
url.pathname = '/dashboard'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|api/webhooks/|auth/|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
まとめ
-
パターン1:
setAllが空実装 → Cookie refresh 不可 →getUser()が常に null -
パターン2: matcher が
/auth/callbackを遮断 → Route Handler が実行されない -
パターン3: Next.js 15 の
cookies()にawait漏れ → セッション null(エラーなし)
3つが同時に起きると原因特定が困難になります。console.log で各パターンを1つずつ潰してください。
認証が通ったあとの収益動線(有料コンテンツ保護・Stripe 連携)については masatoman.net で実体験をもとに書いています。
→ Next.js + Supabase Auth の middleware でリダイレクトループにハマった3パターン(詳細版)