綴Photo の web(/lab)にオーナー専用の認証ゲートを足す作業で踏んだ、Next.js 16 の規約変更とテストの盲点の記録です。
緑のテストと、落ちている dev サーバ
/lab は自分専用の作業場で、URL を知られても中を見られないようにサーバ側のゲートで塞ぎたかった。passcode を入れた人だけ通す、という軽いゲート。
判定ロジックは純関数に切り出して、AI にテストごと書いてもらった。
「ゲートが OFF なら通す」「/lab/share/ だけは受け手用なので素通し」「cookie が一致したら通す」「それ以外はリダイレクト」——この4つを純粋な関数にして、22 本のユニットテストが全部緑になった。(テスト結果は全部「緑」——テスト用語で、緑は成功・赤は失敗の意味。信号と同じで、緑=進んでいい・赤=止まれのイメージ。)
// 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 の書き方も同じです。
// 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 を消すだけ。
// 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 の外に居るのがポイントで、①の経路は通るのに④の経路は通りません。
backend:8000 という名前は Docker の中でだけ通じます。①はポートが外に出ているので通りますが、④は外に出ていない名前を引こうとするので、そもそも届く先がありません。
これを切り替えるスイッチが USE_X_FORWARDED_HOST で、「ブラウザから見たホスト名」を backend に伝えます。有効にすると backend は公開用のホスト名でURLを組みます。
直すには、経由役が backend に「ブラウザから見えるホスト名」を伝える設定(USE_X_FORWARDED_HOST まわり)が要ります。これは本番のメディア配信の設計とひとつながりで、ローカルで動かしている今の段階に片手間で入れる話ではなかった。トークン隠しが効くのも今のところローカル限定の話なので、プロキシ化はここで一度 revert して、本物のログインを入れる段階にまとめてやると決めた。passcode で塞ぐゲートのほうは残した。
引っかかったのは2つとも、「コード単体は正しいのに、置かれる環境・経由する経路でだけ壊れる」型だった。gateDecision も画像URLの組み立ても、関数として見れば何も間違っていません。
この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。