ゲスト
📢 PR Claude Opus 4.7 公開! 新機能・破壊的変更・移行手順を 5 分で確認 →

Next.js Static Export で trailing slash の URL が 403 になる罠

2026-04-26

#Next.js #Static Export #デバッグ

Next.js を output: "export" + trailingSlash: false で運用していて、直アクセスのみ 403 になる事案があった。

症状の 整理:

真因は Next.js Link のクライアント側ルーティング静的ホスティングの URL 解釈 の挙動差にあった。

trailingSlash: false で書き出すと、出力は out/articles/foo.html(ファイル)のみ。out/articles/foo/index.html は作られない。

ユーザーが /articles/foo/ を直接踏むと:

  1. nginx は articles/foo/ をディレクトリ扱いし index.html を探す
  2. 見つからないのでディレクトリインデックスを表示しようとする
  3. 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 を ビルドパイプラインの必須工程 として組み込むことを推奨する。