セキュリティ

Next.js 16 の proxy.ts で Vercel 環境の CORS を正しく設定する方法

Next.js 16 で導入された proxy.ts ファイル規約を使用して、Vercel の Production・Preview・ローカル環境すべてで CORS を正しく設定する方法を解説します。400 Bad Request エラーの原因と解決策も詳しく説明します。

本記事では、Next.js 16 で新たに導入された proxy.ts ファイル規約を使用して、Vercel の各環境(Production・Preview・ローカル)で CORS を正しく設定する方法を解説します。実際に発生した 400 Bad Request エラーの原因特定から解決までの過程も含めて詳しく説明します。

proxy.ts とは

Next.js 16 の新しいファイル規約

Next.js 16 では、従来の middleware.ts に加えて、新しいファイル規約として proxy.ts が導入されました。

proxy.ts は、リクエストの前処理や CORS 設定、リダイレクト、リライトなどを一元的に管理するためのファイルです。

ファイルの配置場所

公式ドキュメントによると、proxy.ts は以下のいずれかに配置します:

プロジェクトルート/proxy.ts
または
src/proxy.ts(src ディレクトリを使用している場合)

重要なのは、pages または app ディレクトリと同じレベルに配置することです。

src/
├── app/          ← App Router
├── components/
├── lib/
└── proxy.ts      ← app と同じレベル

middleware.ts との違い

項目proxy.tsmiddleware.ts
導入バージョンNext.js 16Next.js 12
主な用途CORS、プロキシ、リクエスト前処理認証、リダイレクト、パス書き換え
関数名proxymiddleware
併用可能(役割を分離して使用)可能

発生した問題

症状

Vercel の Preview 環境(linto-dev-git-issue-33-naokiyazawas-projects.vercel.app)でルートページにアクセスした際、以下のエラーが発生しました:

Request URL: https://linto-dev-git-issue-33-naokiyazawas-projects.vercel.app/
Request Method: OPTIONS
Status Code: 400 Bad Request
x-matched-path: /

最初の proxy.ts の設定

export const config = {
  matcher: "/api/:path*",
};

この設定では、/api/* パスのみが proxy 関数の対象でした。

根本原因

  1. OPTIONS リクエストがルートパス / に送信された
  2. matcher が /api/:path* のみを対象としていた
  3. ルートパス / は matcher にマッチしないため、proxy 関数が実行されなかった
  4. Next.js のページコンポーネントは OPTIONS メソッドを処理しないため、400 Bad Request が返された

なぜ OPTIONS リクエストが発生したのか

リクエストヘッダーを分析すると:

vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
cookie: _vercel_jwt=...; __Secure-better-auth.session_token=...

以下の要因が考えられます:

  1. Vercel の SSO 保護: _vercel_jwt Cookie による Preview 環境の認証
  2. Next.js RSC: React Server Components のナビゲーション
  3. Better Auth のセッション検証: 認証状態の確認リクエスト

解決策

正しい matcher 設定

/api/* だけでなく、静的ファイル以外のすべてのパスを対象にする必要があります:

export const config = {
  matcher: [
    // API routes
    "/api/:path*",
    // All other paths (excluding static files)
    "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
  ],
};

matcher の正規表現の解説

/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)

/                    → ルートから始まる
(                    → グループ開始
  (?!...)            → 否定先読み(以下にマッチしない場合のみ続行)
    _next/static     → 静的ファイル
    |_next/image     → 画像最適化
    |favicon.ico     → ファビコン
    |sitemap.xml     → サイトマップ
    |robots.txt      → robots.txt
  .*                 → 任意の文字列
)                    → グループ終了

この正規表現により:

  • / (ルート) → マッチ
  • /about → マッチ
  • /api/auth → マッチ
  • /_next/static/chunk.js → マッチしない(除外)
  • /favicon.ico → マッチしない(除外)

最終的な proxy.ts

以下が、Vercel 環境で正しく動作する完全な proxy.ts の実装です:

import { type NextRequest, NextResponse } from "next/server";

// 固定の許可オリジン
const allowedOrigins = [
  // Production
  "https://linto-dev.vercel.app",
  // Develop branch
  "https://linto-dev-git-develop-naokiyazawas-projects.vercel.app",
  // Local development
  "http://localhost:3000",
];

// 動的な Vercel プレビュー URL パターン(PR ブランチ用)
// linto-dev-git-issue-30-naokiyazawas-projects.vercel.app などにマッチ
const vercelPreviewPattern =
  /^https:\/\/linto-dev-git-issue-\d+-naokiyazawas-projects\.vercel\.app$/;

const corsOptions = {
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
};

function isAllowedOrigin(origin: string): boolean {
  // 固定リストをチェック
  if (allowedOrigins.includes(origin)) {
    return true;
  }
  // 動的パターンをチェック(PR ブランチ)
  if (vercelPreviewPattern.test(origin)) {
    return true;
  }
  return false;
}

export function proxy(request: NextRequest) {
  const origin = request.headers.get("origin") ?? "";
  const allowed = isAllowedOrigin(origin);

  // Handle CORS preflight requests
  if (request.method === "OPTIONS") {
    const preflightHeaders = {
      ...(allowed && { "Access-Control-Allow-Origin": origin }),
      "Access-Control-Allow-Credentials": "true",
      "Access-Control-Max-Age": "86400",
      ...corsOptions,
    };
    return NextResponse.json({}, { headers: preflightHeaders });
  }

  // Handle simple requests
  const response = NextResponse.next();

  if (allowed) {
    response.headers.set("Access-Control-Allow-Origin", origin);
  }

  response.headers.set("Access-Control-Allow-Credentials", "true");

  Object.entries(corsOptions).forEach(([key, value]) => {
    response.headers.set(key, value);
  });

  return response;
}

export const config = {
  matcher: [
    // API routes
    "/api/:path*",
    // All other paths (excluding static files)
    "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
  ],
};

実装のポイント解説

1. 動的な Vercel Preview URL への対応

Vercel の Preview URL は PR ごとに動的に生成されます:

https://linto-dev-git-issue-30-naokiyazawas-projects.vercel.app
https://linto-dev-git-issue-31-naokiyazawas-projects.vercel.app
...

すべての URL を allowedOrigins に列挙することは現実的ではないため、正規表現でパターンマッチングします:

const vercelPreviewPattern =
  /^https:\/\/linto-dev-git-issue-\d+-naokiyazawas-projects\.vercel\.app$/;

2. Access-Control-Allow-Credentials

response.headers.set("Access-Control-Allow-Credentials", "true");

この設定は、Cookie を含むリクエスト(認証情報付きリクエスト)を許可するために必要です。Better Auth などの認証ライブラリを使用している場合は必須です。

注意: Access-Control-Allow-Credentials: true を設定する場合、Access-Control-Allow-Origin には * を使用できません。具体的なオリジンを指定する必要があります。

3. Access-Control-Max-Age

"Access-Control-Max-Age": "86400",

preflight リクエスト(OPTIONS)の結果をブラウザがキャッシュする時間(秒)です。86400 秒 = 24 時間に設定することで、preflight リクエストの回数を減らし、パフォーマンスを向上させます。

4. 許可されていないオリジンの処理

公式ドキュメントのパターンに従い、許可されていないオリジンには CORS ヘッダーを設定しません:

if (allowed) {
  response.headers.set("Access-Control-Allow-Origin", origin);
}

これにより、許可されていないオリジンからのリクエストは、ブラウザの CORS ポリシーによってブロックされます。

高度な matcher 設定

prefetch リクエストを除外する

Next.js の next/link による prefetch リクエストを proxy の対象から除外することで、不要な処理を減らせます:

export const config = {
  matcher: [
    {
      source: "/((?!_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};

条件付きマッチング

特定のヘッダーやクエリパラメータが存在する場合のみマッチさせることもできます:

export const config = {
  matcher: [
    {
      source: "/api/:path*",
      has: [
        { type: "header", key: "Authorization" },
      ],
    },
  ],
};

トラブルシューティング

400 Bad Request が発生する場合

  1. matcher の範囲を確認: リクエストパスが matcher にマッチしているか確認
  2. OPTIONS メソッドの処理を確認: preflight リクエストが正しく処理されているか確認
  3. Vercel のログを確認: デプロイ後に Vercel ダッシュボードで Function ログを確認

CORS エラーが発生する場合

  1. オリジンの確認: リクエスト元のオリジンが allowedOrigins または正規表現パターンにマッチしているか確認
  2. Credentials の確認: Cookie を使用する場合、Access-Control-Allow-Credentials: true が設定されているか確認
  3. ブラウザの開発者ツールで確認: レスポンスヘッダーに CORS 関連のヘッダーが含まれているか確認

proxy 関数が実行されない場合

  1. ファイルの配置を確認: src/proxy.tssrc/app と同じレベルにあるか確認
  2. 関数名を確認: export function proxy と正しく命名されているか確認
  3. ビルドエラーを確認: npm run build でエラーが出ていないか確認

まとめ

Next.js 16 の proxy.ts で Vercel 環境の CORS を正しく設定するためのポイント:

ポイント説明
ファイル配置src/proxy.tssrc/app と同じレベルに配置
関数名export function proxy で定義
matcher 設定静的ファイル以外のすべてのパスをカバー
動的オリジン正規表現で Vercel Preview URL に対応
Credentials認証を使用する場合は必須
OPTIONS 処理preflight リクエストに適切なヘッダーで応答

この設定により、Production・Preview・ローカル開発環境のすべてで CORS が正しく動作します。