BandBridge の開発中、ローカルでは動くのに Vercel Preview で認証が切れる問題に遭遇しました。原因特定に2時間かかりましたが、Supabase Auth と Next.js App Router の組み合わせで起こりやすい落とし穴でした。
この記事では、問題の原因と解決策、そして同じ問題を繰り返さないための仕組みを紹介します。
問題:なぜ認証が切れるのか
2026年1月15日の開発日記から:
認証問題のデバッグ: ローカル環境では動くのにVercel Previewで認証が切れる問題に2時間ハマった。原因はSupabaseの
getUser()がServer Componentで呼ばれたとき、トークンリフレッシュをトリガーするがCookieを書き込めないこと
getUser() は内部でトークンの有効期限をチェックし、必要に応じてリフレッシュします。しかし、Server Component は HTTP レスポンスを返す前に実行されるため、新しいトークンを Cookie に書き込めません。
結果として:
- ユーザーがページにアクセス
- Server Component で
getUser()が呼ばれる - トークンが期限切れなのでリフレッシュ
- 新しいトークンが生成されるが、Cookie に保存されない
- 次のリクエストで古いトークンが使われ、認証エラー
ローカル環境ではトークンの有効期限が十分に長いため問題が起きにくく、発見が遅れました。
解決策:getSession() を使う
Supabase Auth + Server Componentsの落とし穴:
getUser()はServer Componentで使うべきではない。middlewareでのみ使用し、Server ComponentではgetSession()を使う
getSession() はトークンのリフレッシュをトリガーしません。セッションの検証は middleware で行い、Server Component では getSession() でセッション情報を取得するだけにします。
実装パターン
Claude Code でヘルパー関数を作成して、全 Server Component で統一します:
// lib/auth.ts
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
export async function getAuthUser() {
const supabase = createServerComponentClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
return session?.user ?? null;
}
export async function requireAuth() {
const user = await getAuthUser();
if (!user) {
redirect('/login');
}
return user;
}
Server Component では getUser() を直接呼ばず、必ずこのヘルパーを使います:
// app/dashboard/page.tsx
import { requireAuth } from '@/lib/auth';
export default async function DashboardPage() {
const user = await requireAuth();
// user は必ず存在する
return <div>Welcome, {user.email}</div>;
}
middleware でのトークンリフレッシュ
トークンのリフレッシュは middleware で行います。middleware はレスポンスヘッダーを制御できるため、Cookie の書き込みが可能です。
ただし、リダイレクト時に Cookie が消えてしまう問題がありました:
middleware修正: リダイレクト時にSet-Cookieヘッダーが消えてしまう問題。Cookieを明示的にコピーして引き継ぐようにした
// middleware.ts
export async function middleware(request: NextRequest) {
const response = NextResponse.next({
request: { headers: request.headers },
});
const supabase = createMiddlewareClient({ req: request, res: response });
const { data: { session } } = await supabase.auth.getSession();
// 認証が必要なページでセッションがない場合
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
const redirectUrl = new URL('/login', request.url);
const redirectResponse = NextResponse.redirect(redirectUrl);
// Cookie を明示的にコピー
response.cookies.getAll().forEach(cookie => {
redirectResponse.cookies.set(cookie.name, cookie.value);
});
return redirectResponse;
}
return response;
}
ESLint ルールで強制する
同じ問題を繰り返さないために、Claude Code に依頼して ESLint ルールを追加しました:
規約ドキュメントの重要性: 同じ問題を繰り返さないために、認証パターンをドキュメント化した
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-syntax': [
'warn',
{
selector: "CallExpression[callee.property.name='getUser']",
message: 'getUser() は Server Component で使わないでください。getAuthUser() を使用してください。'
}
]
}
};
これにより、getUser() を直接使おうとすると警告が表示されます。
もう一つの落とし穴:Portal と Link の競合
同じ日に別の認証問題にも遭遇しました:
ポータルとLinkの競合: 最初はRadix UIのバグかと思ったが、実際はNext.jsのLinkがポータル内でprefetchを試みて認証コンテキストがリセットされていた。
useRouterへの変更で解決したが、原因特定に時間がかかった
Radix UI の Sheet(モーダル)内で Next.js の Link を使うと、prefetch のタイミングで認証状態がリセットされることがありました。
解決策は useRouter でプログラマティックにナビゲーションすることです:
// NG: Portal 内での Link
<Sheet>
<Link href="/profile">プロフィール</Link>
</Sheet>
// OK: useRouter を使う
<Sheet>
<button onClick={() => router.push('/profile')}>
プロフィール
</button>
</Sheet>
まとめ
Supabase Auth + Next.js App Router で認証が切れる問題の原因と解決策:
- 問題:
getUser()は Server Component でトークンをリフレッシュするが、Cookie に書き込めない - 解決: Server Component では
getSession()を使い、リフレッシュは middleware に任せる - 統一: ヘルパー関数
getAuthUser()/requireAuth()で全体を統一 - 強制: ESLint ルールで
getUser()の直接使用を検出 - ドキュメント化: 認証パターン規約を作成して、チーム全体で共有
ローカルで動くからといって油断せず、Preview 環境でも十分にテストすることが大切です。
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。