結論:NextResponse.redirect() は Cookie を引き継がない
Next.js App Router の Route Handler で、await cookies() でセッション Cookie を書いた後に NextResponse.redirect() を返すと、その Cookie が redirect レスポンスに乗りません。
// ❌ 壊れている実装(Cookie が redirect に届かない)
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
await cookies().set('session', ...)
return NextResponse.redirect(next)
// ✅ 正しい実装(redirect レスポンスに直接 Cookie をセット)
const response = NextResponse.redirect(next)
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookies) =>
cookies.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
),
},
}
)
await supabase.auth.exchangeCodeForSession(code)
return response
この仕様に気づくまで、筆者の masatoman.net では launch 以来約 1 ヶ月、誰もログインできない状態が続いていました。
なぜ気づけなかったのか
302 で「成功に見える失敗」
auth callback は HTTP 302 を返していたので、アクセスログには何も出ません。
- Vercel デプロイ → success
- magic link メール → 届く
- callback URL → HTTP 302(成功に見える)
- セッション → 未確立(ここが壊れていた)
型チェックも linter も ESLint も検知できない種類のバグです。
smoke test がなかった
デプロイが成功した ≠ 機能している。この前提がなく、実際にブラウザでログインを試みなければ気づけませんでした。
auth callback の正しい実装
// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createServerClient } from '@supabase/ssr'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/'
if (!code) {
return NextResponse.redirect(`${origin}/auth/error`)
}
const response = NextResponse.redirect(`${origin}${next}`)
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 }) => {
request.cookies.set(name, value)
response.cookies.set(name, value, options)
})
},
},
}
)
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
return NextResponse.redirect(`${origin}/auth/error`)
}
return response
}
response.cookies.set() が正解。cookies() からセットするのではなく、redirect レスポンスオブジェクトに直接書き込みます。
合わせて踏んだ Stripe Webhook の 301 問題
同時期に Stripe Webhook も 1 週間全失敗していました。
原因は Stripe に登録した URL が naked ドメイン(masatoman.net)で、Vercel が www.masatoman.net に 301 リダイレクト。Stripe は POST → 301 を追わない(body 喪失のセキュリティ仕様)ため全件失敗。
# Stripe ダッシュボードのエラーレスポンス
{
"redirect": "https://www.masatoman.net/api/stripe/webhook",
"status": 301
}
修正:Stripe の Webhook URL を www. 付きに変更するだけ。
smoke test の実装
// app/api/health/auth-callback/route.ts
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { origin } = new URL(request.url)
const res = await fetch(
`${origin}/auth/callback?code=smoke_test_invalid`,
{ redirect: 'manual' }
)
const location = res.headers.get('location') ?? ''
const ok =
res.status === 302 &&
(location.includes('/auth/error') || location.includes('/'))
return NextResponse.json(
{ ok, status: res.status, location },
{ status: ok ? 200 : 500 }
)
}
# .github/workflows/smoke-test.yml
name: Smoke Test
on:
push:
branches: [main]
schedule:
- cron: '0 */6 * * *'
jobs:
auth-callback:
runs-on: ubuntu-latest
steps:
- name: Auth Callback Health Check
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
https://your-site.com/api/health/auth-callback)
if [ "$STATUS" != "200" ]; then exit 1; fi
main push のたびに自動実行。収益化インフラの常時監視として機能します。
まとめ:やること 3 つ
-
auth callback の実装を確認 —
cookies().set()→NextResponse.redirect()の順なら今すぐ修正 - Stripe Webhook URL を確認 — Vercel のリダイレクト先(www/naked)と一致させる
- smoke test を main push に組み込む — デプロイ成功 ≠ 機能している
収益化インフラの品質設計については masatoman.net の全文記事で詳しく解説しています。