Next.js Static Export で trailing slash の URL が 403 になる罠
2026-04-26
#Next.js
#Static Export
#デバッグ
Next.js を output: "export" + trailingSlash: false で運用していて、直アクセスのみ 403 になる事案があった。
症状の 整理:
- トップページからリンクで
/articles/fooを開く → 200 で表示される - ブラウザでリロード or URL を直入力で
/articles/foo/を開く → 403 Forbidden - 表示されている URL を見ても末尾スラッシュは付いていない or 付いている
真因は Next.js Link のクライアント側ルーティング と 静的ホスティングの URL 解釈 の挙動差にあった。
trailingSlash: false で書き出すと、出力は out/articles/foo.html(ファイル)のみ。out/articles/foo/index.html は作られない。
ユーザーが /articles/foo/ を直接踏むと:
- nginx は
articles/foo/をディレクトリ扱いしindex.htmlを探す - 見つからないのでディレクトリインデックスを表示しようとする
Options -Indexesで禁止 → 403 Forbidden
一方 Next.js Link でクリックした場合は、SPA ルーティング(History API + fetch)で .html を読みにいくため、サーバ側で URL の解釈すら走らない。だから直アクセスとリロードでだけ 403 が露出する。
「気づきにくい」 不具合の典型。
解決:
ビルド後の post-process スクリプトで articles/foo/index.html を併存生成する。articles/foo.html をコピーして配置するだけで、両形式 200 を返せるようになる。
// scripts/trailing-slash-shim.mjs
const SUBDIRS = ["articles", "lessons", "categories", "musk/posts"];
for (const dir of SUBDIRS) {
const entries = await fs.readdir(path.join(OUT_DIR, dir));
for (const entry of entries) {
if (!entry.endsWith(".html")) continue;
const baseName = entry.replace(/\.html$/, "");
const src = path.join(OUT_DIR, dir, entry);
const dstDir = path.join(OUT_DIR, dir, baseName);
const dst = path.join(dstDir, "index.html");
await fs.mkdir(dstDir, { recursive: true });
await fs.copyFile(src, dst);
}
}
検証 は curl で 3 形式を必ず確認:
# 拡張子付き / 末尾スラッシュ / 拡張子なし の 3 形式
curl -o /dev/null -w "%{http_code}\n" https://example.com/articles/foo.html
curl -o /dev/null -w "%{http_code}\n" https://example.com/articles/foo/
curl -o /dev/null -w "%{http_code}\n" https://example.com/articles/foo
すべて 200 が返れば 完了。
Static Export を採用するチームには、この shim を ビルドパイプラインの必須工程 として組み込むことを推奨する。