Next.js + Supabase Auth の middleware でリダイレクトループにハマった3パターン
結論:middleware ループの原因は3パターンに絞られる
Next.js App Router + Supabase Auth で実装したプロテクトルートが、ログイン後も延々とリダイレクトを繰り返す。この現象の原因は、筆者の経験上 3 パターン に分類されます。
1. getSession() を getUser() に切り替えたことで認証状態が常に null になる
2. matcher に /auth/callback を含めて callback 処理中に middleware が再実行される
3. Next.js 15 で cookies() が非同期になり、await 漏れでセッションが取れない
これら3つが混在すると「ログインできてるはずなのにリダイレクトされ続ける」状態になります。3日かけて1つずつ潰しました。
3行サマリー:
- Supabase の公式ドキュメントが
getSession→getUser移行を推奨しているが、middleware 内での使い方が変わる /auth/callbackを matcher の保護対象に入れるとコールバック処理中に再認証チェックが走りループする- Next.js 15 の
cookies()は非同期になった。同期で呼んでいる箇所があると常に null が返る
今すぐ確認するチェックリスト:
- middleware 内で
supabase.auth.getUser()を使っているか(getSession()は deprecated) -
middleware.tsのmatcherから/auth/callbackを除外しているか -
cookies()の呼び出しにawaitがあるか(Next.js 15 以降) -
updateSession()を middleware の最初に実行しているか
masatoman.net(Next.js + Supabase Auth + Stripe)を個人で構築・運用中。2026年5月時点で Phase 0(初期読者獲得フェーズ)。有料記事2本(¥1,000 each)公開済み、月額サブスク(¥1,000/月)運用中。本記事の middleware ループ問題は masatoman.net の認証実装時に実際に遭遇し、3日間かけて解決した体験をもとに執筆しています。
何が起きたか
masatoman.net に Supabase Auth を組み込んで有料コンテンツの保護機能を実装したときのことです。
ログインページで magic link を送信し、メールのリンクをクリックする。リダイレクトされる。また、ログインページに戻る。 また magic link を送る。また戻る。
ログイン成功のはずなのに、ダッシュボードに到達できません。
当初は「magic link の URL が壊れているのかも」と疑いました。しかしメールのリンクを直接ブラウザに貼り付けても同じ結果でした。ターミナルのログには特にエラーなし。Supabase のダッシュボードでは user は作成されていました。
つまり「認証自体は成功しているが、middleware が認証済みと判断しない」という状態でした。
パターン1:getSession() を getUser() に変えたらループした
原因
Supabase の公式ドキュメントが 2024 年末から getSession() の代わりに getUser() を使うよう推奨を変えています。セキュリティ上の理由で、getSession() はクライアントのローカルストレージにキャッシュされたセッションを返すため、サーバー側での検証が不十分とされたためです。
推奨に従って getUser() に切り替えたところ、middleware でセッション取得が常に失敗するようになりました。
// ❌ これがループの原因になった実装
export async function middleware(request: NextRequest) {
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: () => {}, // ← 何もしていない
},
}
)
const { data: { user } } = await supabase.auth.getUser() // 常に null
if (!user && isProtectedRoute(request)) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
何が起きていたか
setAll を空実装にしていたため、middleware でセッション Cookie の refresh が一切できていませんでした。
Supabase Auth は JWT トークンのリフレッシュを Cookie の書き換えで行います。middleware で Cookie を書けないと、有効期限の切れたトークンを持つユーザーは getUser() が null を返し続けます。ログイン直後でも、/auth/callback でセッションが確立された Cookie が middleware で更新されないと次のリクエストで認証が通りません。
解決策
Supabase が提供している @supabase/ssr の updateSession() パターンを使うのが正解です。
// ✅ 正しい実装
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
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
}
setAll で supabaseResponse を都度生成し直すことで、Cookie の書き換えがレスポンスに確実に乗るようになります。getUser() の前に途中 return してしまうと Cookie の refresh が止まるため、この順序は守ってください。
パターン2:matcher に /auth/callback を入れてループした
原因
プロテクトルートの設定として、次のような matcher を書いていました。
// ❌ /auth/callback がプロテクト対象に入ってしまう
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
このパターンは _next と静的ファイルを除外しますが、/auth/callback は除外されません。
magic link をクリックすると次の流れになります。
/auth/callback?code=xxxxxにリダイレクト- middleware が走る
- この時点でまだセッションは確立されていない(code の交換処理が動く前)
- middleware が「未認証」と判断して
/loginにリダイレクト /auth/callbackの Route Handler が実行されないまま終了
つまり、認証コードを処理するページ自体を middleware が遮断していました。 Route Handler は1度も呼ばれていませんでした。
解決策
/auth パス全体を matcher から除外します。
// ✅ /auth を明示的に除外する matcher
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|api/webhooks/|auth/|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
あるいは matcher で広く取って、middleware 内でパスを判定する方法もあります。
// ✅ middleware 内でパスチェック(matcher は広め)
export async function middleware(request: NextRequest) {
// /auth/* は middleware をスキップ
if (request.nextUrl.pathname.startsWith('/auth')) {
return NextResponse.next()
}
return await updateSession(request)
}
どちらの方法でも構いません。/auth/callback に middleware が干渉しないことを最優先に考えてください。
パターン3:Next.js 15 で cookies() に await を忘れてループした
原因
Next.js 15 から、cookies()、headers() などの Dynamic APIs が非同期になりました。
// Next.js 14 まで(同期)
const cookieStore = cookies()
// Next.js 15 以降(非同期)
const cookieStore = await cookies()
@supabase/ssr のサンプルコードも同期前提のものが Web 上にたくさん残っているため、コピーして使うと await が抜けたままになります。
症状は次のとおりです。
// ❌ await 漏れ。cookieStore が Promise オブジェクトになる
const cookieStore = cookies() // Promise<ReadonlyRequestCookies> が返る
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll() // TypeError: cookieStore.getAll is not a function
},
},
}
)
TypeScript の型チェックが効いていない場合や、エラーが握りつぶされているケースでは「getAll() が空配列を返し続ける」という症状になります。この場合はランタイムエラーが出ず、静かにセッションが null になります。
解決策
// ✅ await を追加
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component からの呼び出しでは set が使えない(無視で OK)
}
},
},
}
)
await cookies() を忘れると エラーが出ないまま常に未認証扱い になるため、症状だけ見ても原因が分かりにくいです。Next.js 15 以降を使っている場合は、cookies() の呼び出し箇所を全てチェックしてください。
なぜ3日かかったか
3つのパターンが同時に刺さっていたことが最大の理由です。
最初は「magic link が機能していない」と思い込んで Supabase 側のデバッグから始めました。Supabase のダッシュボードでは user が作成されているため、「認証は通っている」と判断。次に「Next.js の Route Handler 自体の問題」と疑い、/auth/callback/route.ts をひたすら読みました。
実は Route Handler は正常に動いていました。問題は middleware でした。しかし当時の自分は「middleware のデバッグ方法」を体系的に持っておらず、ログの追い方に無駄な時間がかかりました。
デバッグで効いた2つの手順
1. middleware.ts に console.log を大量に入れる
export async function middleware(request: NextRequest) {
console.log('[middleware] path:', request.nextUrl.pathname)
console.log('[middleware] cookies:', request.cookies.getAll().map(c => c.name))
const response = await updateSession(request)
console.log('[middleware] response status:', response.status)
console.log('[middleware] location:', response.headers.get('location'))
return response
}
ターミナルで npm run dev を走らせながら、全リクエストのパスとリダイレクト先をログで追いました。これで「/auth/callback が middleware に遮断されている」が即座に分かりました。
2. supabase.auth.getUser() のレスポンスを直接 console.log
const { data: { user }, error } = await supabase.auth.getUser()
console.log('[middleware] user:', user?.id, 'error:', error?.message)
user が null で error もなければ「Cookie の書き込みに問題がある(setAll が空実装)」を疑います。error があれば JWT の期限切れや不正なキーなど、別の原因を追います。
この2つのログを入れれば、3つのパターンのどれに該当するかは1時間以内に特定できるはずです。
完全版 middleware コード
3つのパターン対策を組み込んだ middleware.ts の全文です。
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
const PROTECTED_PATHS = ['/dashboard', '/articles', '/settings']
const AUTH_PATHS = ['/auth', '/login']
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)
)
},
},
}
)
// getUser の前に途中 return しないこと(Cookie refresh が止まる)
const {
data: { user },
} = await supabase.auth.getUser()
const pathname = request.nextUrl.pathname
const isProtected = PROTECTED_PATHS.some(p => pathname.startsWith(p))
const isAuthPath = AUTH_PATHS.some(p => pathname.startsWith(p))
if (!user && isProtected) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
if (user && isAuthPath && !pathname.startsWith('/auth')) {
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)$).*)',
],
}
設計のポイント:
setAllでsupabaseResponseを都度再生成(Cookie がレスポンスに確実に乗る)getUser()の前に途中 return しない(セッションの refresh が完走する)- matcher で
auth/を除外(/auth/callbackへの干渉を防ぐ) api/webhooks/を除外(Stripe webhook など認証不要なエンドポイント)
で、どう稼ぐ?
この middleware ループ問題を3日かけて解決した後、気づいたことがあります。
認証が壊れている間は、有料コンテンツへの到達が物理的に不可能です。
記事を書いても、SEO 流入があっても、Qiita から読者が来ても、ログインができなければ有料コンテンツを購入できません。どれだけコンテンツの質を上げても、技術的な詰まりが収益のボトルネックになります。
個人開発の収益化を考えるとき、「コンテンツ量」よりも先に「技術的動線が通っているか」を確認することが重要です。確認すべきポイント:
- ログインできるか(magic link、OAuth 両方で別ブラウザ・シークレットモードから試す)
- プロテクトルートに到達できるか(ログイン後に期待のページが開くか)
- 決済フローが完走するか(Stripe のテストカードで実際に課金する)
これを「認証 smoke test」として main ブランチへの push 時に自動実行する仕組みを作りました。middleware のバグが再発しても早期に気づけます。
Claude Crew Lab(Free)では、このような「技術と収益を繋ぐ実装の落とし穴」を継続的に共有しています。技術は書けるが稼ぐ動線で詰まっている、という方はぜひ登録してください。
masatoman のメルマガ — 毎週月曜の朝に手紙を 1 通
masatoman.net の今週の記事 1 本を、読者目線で深掘りした手紙が毎週月曜 9:00 に届きます。「これ自分のことだ」が見つかる予告編。登録特典に「個人開発の収益化チェックリスト 15 項目」。
masatoman のメルマガ — 毎週月曜の朝に 1 通
masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。
まとめ
Next.js + Supabase Auth の middleware でリダイレクトループが起きる原因は3パターンです。
| パターン | 原因 | 症状 |
|---|---|---|
| 1 | getSession → getUser 移行時に setAll を空実装 | ログイン直後でも常に未認証 |
| 2 | matcher に /auth/callback が含まれる | magic link 後にログインページに戻される |
| 3 | Next.js 15 で cookies() に await 漏れ | セッションが常に null(エラーなし) |
3つが同時に起きると「何が原因か分からない」状態になります。middleware.ts に console.log を入れてパスとレスポンスを追いながら、1つずつ潰すのが最短ルートです。
認証が通っていないと有料コンテンツへの到達もできません。実装後は別ブラウザでのログインテストを必ず行ってください。
関連記事:
Next Step
次に読むならこの導線です
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
【第10回】Claude Code × Supabase で管理画面を30分で生成する Skill 実装ガイド
Supabase のテーブルを参照して管理画面を自動生成する Skill を、実コード付きで解説。Claude Code が DB スキーマから Next.js 管理画面を30分で生成する仕組みを公開します。
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
Claude Code で 0→MVP を1日で作る全記録 — recipe-ai Build in Public
Claude Codeを使い、YouTube料理動画からレシピを自動抽出するAIアプリ「recipe-ai」を0からMVPまで1日で構築した全記録。CLAUDE.md設計、API実装、Supabase連携、Vercelデプロイ、Stripe課金導入までの工程を時系列で公開。
次の実験記録も追う
Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。
masatoman のメルマガ — 毎週月曜の朝に 1 通
masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。
この記事が役に立ったらシェア