紡Book の勉強ノートに戻る
開発記録2026.06.21

テストは全部緑、でも dev サーバは全リクエスト落ちていた — Next.js 16 の middleware→proxy

Next.jsテスト紡Book

綴Photo の web(/lab)にオーナー専用の認証ゲートを足す作業で踏んだ、Next.js 16 の規約変更とテストの盲点の記録です。

緑のテストと、落ちている dev サーバ

/lab は自分専用の作業場で、URL を知られても中を見られないようにサーバ側のゲートで塞ぎたかった。passcode を入れた人だけ通す、という軽いゲート。

判定ロジックは純関数に切り出して、AI にテストごと書いてもらった。

「ゲートが OFF なら通す」「/lab/share/ だけは受け手用なので素通し」「cookie が一致したら通す」「それ以外はリダイレクト」——この4つを純粋な関数にして、22 本のユニットテストが全部緑になった。(テスト結果は全部「緑」——テスト用語で、緑は成功・赤は失敗の意味。信号と同じで、緑=進んでいい・赤=止まれのイメージ。)

ts
// src/lib/labGate.ts — 判定だけを閉じ込めた純関数。
// Next のことも、自分がどのファイル名で置かれるかも知らない。
export function gateDecision(args: {
  pathname: string;
  cookieValue: string | undefined;
  gateToken: string | undefined;
}): GateDecision {
  if (!isLabGateActive(args.gateToken)) return { type: "allow" };          // ゲートOFFなら通す
  if (args.pathname.startsWith("/lab/share/")) return { type: "allow" };    // 受け手用パスは通す
  if (args.cookieValue && args.cookieValue === args.gateToken) return { type: "allow" };  // cookie 一致で通す
  return { type: "redirect", to: "/lab-gate?next=" + encodeURIComponent(args.pathname) };  // それ以外は弾いてリダイレクト
}

緑だったので動くつもりでブラウザから叩いたら、/lab どころか全部のリクエストが返ってこない。dev サーバのログには同じエラーが延々と吐かれていた。

Error: Both middleware file "./src/middleware.ts" and proxy file "./src/proxy.ts"
are detected. Please use "./src/proxy.ts" only.

原因は middleware が proxy にリネームされていたこと

エラーがそのまま答えだった。middleware.ts と proxy.ts の両方がある、と。でも自分が新しく作ったのは middleware.ts だけ。proxy.ts は前から置いてあった。

知らなかったのは、Next.js 16 でこの関所のファイル名が middleware から proxy にリネームされていたこと。前に置いてあった proxy.ts(中身は空の素通し)が Next 16 の正本で、それに気づかず古い名前で middleware.ts を新規に作っていた。両方あると Next は「どちらか1つにしろ」と起動を止める——これが全リクエスト落ちの正体だった。

リネームと言っても、書き方が変わったわけではありません。変わったのはファイル名と、export する関数名の2箇所だけで、引数も戻り値も matcher の書き方も同じです。

ts
// Next 15 まで: src/middleware.ts
export function middleware(request: NextRequest) { /* 中身は同じ */ }
export const config = { matcher: ["/lab", "/lab/:path*"] };
 
// Next 16 から: src/proxy.ts
export function proxy(request: NextRequest) { /* 中身は同じ */ }
export const config = { matcher: ["/lab", "/lab/:path*"] };

だから直し方も単純で、ゲートの配線を既存の proxy.ts に移して middleware.ts を消すだけ。

ts
// src/proxy.ts — 判定はせず、labGate に渡して答えを Next の言葉に翻訳するだけ
export function proxy(request: NextRequest) {
  const decision = gateDecision({
    pathname: request.nextUrl.pathname,
    cookieValue: request.cookies.get(LAB_GATE_COOKIE)?.value,
    gateToken: process.env.LAB_GATE_TOKEN,
  });
  if (decision.type === "redirect") return NextResponse.redirect(new URL(decision.to, request.url));  // ゲート未通過ならログイン画面へ送る
  return NextResponse.next();  // 何もせず素通し(次の処理へ渡す)
}
 
export const config = { matcher: ["/lab", "/lab/:path*"] };  // この関所を効かせる URL の範囲(/lab とその配下だけ)

置いたファイルは3つになった。src/lib/labGate.ts(決める)、src/lib/labGate.test.ts(それをテストする)、src/proxy.ts(配線する)。proxy.ts にも if が1つあるが、あれは判断ではなく、labGate が返した答えを Next の言葉(NextResponse)に置き換えているだけです。判断は labGate 側にしか無く、labGate は Next のことを何も知りません。

なぜユニットテストが素通りしたか

今日いちばん引っかかったのはここ。テストは22本全部緑だったのに、サーバは起動すら通っていなかった。

テストが見ていたのは gateDecision という純関数の中身——入力を入れたら正しい判定が出るか、だけだった。一方で落ちていたのは「その関数をどのファイル名で置くか」という Next の規約の層。純関数テストはファイル名を知りませんし、知る必要もありません。だから規約違反はテストの網の外を素通りして、実際にサーバを起動して初めて表に出た。

労力の割に効くのはこの分け方だ、と思って分けたのだが、効く理由と効かない理由が同じところから出ていた。labGate が Next を知らないから、Next を起動せずに22本テストできた。同じ理由で、Next の規約に乗れているかは1本も見ていない。

緑のテストが保証していたのは「ロジックは正しい」までで、「フレームワークの規約に乗れているか」は別の層だった。ここはテストを足して埋めるより、一度ライブで起動して叩いてみるのが早いです。今回もそれで捕まえた。緑を見て動くつもりになりかけたところを、実際に起動して確かめる側に倒したのがよかった。

ついでに踏んだ reverse-proxy のメディアURL

ゲートが直ったあと、もう一歩進めて「API のトークンをブラウザに出さない」ために、web からの API 呼び出しを Next 経由のプロキシにしようとした。これも途中まで動いたが、今度は画像が出なくなった。

返ってきた画像のURLを見たら http://backend:8000/media/... になっていた。これは Docker の内部でだけ届く名前で、ブラウザからは到達できません。

なぜそうなるか。backend (Django) はリクエストの Host ヘッダを見てレスポンス内の絶対URLを組みます。プロキシが backend を backend:8000 という内部名で叩くと Host ヘッダも内部名のままになり、backend は「自分のアドレス」としてその内部名を使って絶対URLを組んでしまいます。だから返ってくる画像URLも内部名になります。

図にするとこう。ブラウザだけが Docker の外に居るのがポイントで、①の経路は通るのに④の経路は通りません。

Docker ネットワーク(この中だけで通じる呼び名がある)ブラウザbackend という名前を知らないNext(経由役)内部名で backend を叩くbackend (Django)宛名を見て画像の絶対URLを組む① localhost:3000② Host: backend:8000③ 返ってきた画像URL http://backend:8000/media/…④ その URL で画像を取りに行く✕

backend:8000 という名前は Docker の中でだけ通じます。①はポートが外に出ているので通りますが、④は外に出ていない名前を引こうとするので、そもそも届く先がありません。

これを切り替えるスイッチが USE_X_FORWARDED_HOST で、「ブラウザから見たホスト名」を backend に伝えます。有効にすると backend は公開用のホスト名でURLを組みます。

直すには、経由役が backend に「ブラウザから見えるホスト名」を伝える設定(USE_X_FORWARDED_HOST まわり)が要ります。これは本番のメディア配信の設計とひとつながりで、ローカルで動かしている今の段階に片手間で入れる話ではなかった。トークン隠しが効くのも今のところローカル限定の話なので、プロキシ化はここで一度 revert して、本物のログインを入れる段階にまとめてやると決めた。passcode で塞ぐゲートのほうは残した。

引っかかったのは2つとも、「コード単体は正しいのに、置かれる環境・経由する経路でだけ壊れる」型だった。gateDecision も画像URLの組み立ても、関数として見れば何も間違っていません。

この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。