本記事では、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.ts | middleware.ts |
|---|---|---|
| 導入バージョン | Next.js 16 | Next.js 12 |
| 主な用途 | CORS、プロキシ、リクエスト前処理 | 認証、リダイレクト、パス書き換え |
| 関数名 | proxy | middleware |
| 併用 | 可能(役割を分離して使用) | 可能 |
発生した問題
症状
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 関数の対象でした。
根本原因
- OPTIONS リクエストがルートパス
/に送信された - matcher が
/api/:path*のみを対象としていた - ルートパス
/は matcher にマッチしないため、proxy関数が実行されなかった - 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=...以下の要因が考えられます:
- Vercel の SSO 保護:
_vercel_jwtCookie による Preview 環境の認証 - Next.js RSC: React Server Components のナビゲーション
- 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 が発生する場合
- matcher の範囲を確認: リクエストパスが matcher にマッチしているか確認
- OPTIONS メソッドの処理を確認: preflight リクエストが正しく処理されているか確認
- Vercel のログを確認: デプロイ後に Vercel ダッシュボードで Function ログを確認
CORS エラーが発生する場合
- オリジンの確認: リクエスト元のオリジンが
allowedOriginsまたは正規表現パターンにマッチしているか確認 - Credentials の確認: Cookie を使用する場合、
Access-Control-Allow-Credentials: trueが設定されているか確認 - ブラウザの開発者ツールで確認: レスポンスヘッダーに CORS 関連のヘッダーが含まれているか確認
proxy 関数が実行されない場合
- ファイルの配置を確認:
src/proxy.tsがsrc/appと同じレベルにあるか確認 - 関数名を確認:
export function proxyと正しく命名されているか確認 - ビルドエラーを確認:
npm run buildでエラーが出ていないか確認
まとめ
Next.js 16 の proxy.ts で Vercel 環境の CORS を正しく設定するためのポイント:
| ポイント | 説明 |
|---|---|
| ファイル配置 | src/proxy.ts を src/app と同じレベルに配置 |
| 関数名 | export function proxy で定義 |
| matcher 設定 | 静的ファイル以外のすべてのパスをカバー |
| 動的オリジン | 正規表現で Vercel Preview URL に対応 |
| Credentials | 認証を使用する場合は必須 |
| OPTIONS 処理 | preflight リクエストに適切なヘッダーで応答 |
この設定により、Production・Preview・ローカル開発環境のすべてで CORS が正しく動作します。