1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js + Supabase Auth の middleware でリダイレクトループにハマった3パターンと完全解決法

1
Last updated at Posted at 2026-06-23

結論

Next.js App Router + Supabase Auth で「ログインしたはずなのにリダイレクトされ続ける」問題の原因は 3パターン に絞られます。

パターン 原因 症状
1 getSessiongetUser 移行時に 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 をクリックすると:

  1. /auth/callback?code=xxxxx にリダイレクト
  2. middleware が走る → この時点でセッション未確立
  3. middleware が「未認証」と判断して /login にリダイレクト
  4. 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 なしだと cookieStorePromise オブジェクトになり、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パターン(詳細版)

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?