Supabase Auth + Next.js App Router で認証が切れる問題と解決策

2026.02.01
Share:

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 に書き込めません。

結果として:

  1. ユーザーがページにアクセス
  2. Server Component で getUser() が呼ばれる
  3. トークンが期限切れなのでリフレッシュ
  4. 新しいトークンが生成されるが、Cookie に保存されない
  5. 次のリクエストで古いトークンが使われ、認証エラー

ローカル環境ではトークンの有効期限が十分に長いため問題が起きにくく、発見が遅れました。

解決策: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() を直接使おうとすると警告が表示されます。

同じ日に別の認証問題にも遭遇しました:

ポータルと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 で認証が切れる問題の原因と解決策:

  1. 問題: getUser() は Server Component でトークンをリフレッシュするが、Cookie に書き込めない
  2. 解決: Server Component では getSession() を使い、リフレッシュは middleware に任せる
  3. 統一: ヘルパー関数 getAuthUser() / requireAuth() で全体を統一
  4. 強制: ESLint ルールで getUser() の直接使用を検出
  5. ドキュメント化: 認証パターン規約を作成して、チーム全体で共有

ローカルで動くからといって油断せず、Preview 環境でも十分にテストすることが大切です。

Zeronova avatar

Zeronovaゼロノバ

Product Manager / AI-Native Builder

Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。

関連プロダクト

BandBridge(バンドブリッジ)

ミュージシャンとバンドをつなぐ