日本語パスとISRの500

更新: 2026-10-21
Next.jsISRgenerateStaticParamsVercel日本語URL

generateStaticParams を export した動的ルートで、パスに日本語(U+0100以上の文字)が入ると、本番だけHTTP 500になる。 通常の next start、next build、単体テストでは出ない(next dev は試していない)。Valscopeの業種別一覧68URLで起き、公開後から修正した10/20まで、全件500のままだった。経緯は開発日記の日本語URLだけ全件500にある。ここでは、再現の手順、見分け方、直し方だけを書く。

何が起きるか

  • 英数字のパス(/isr/abc)は200で、日本語のパス(/isr/情報・通信業)は500
  • 本番のランタイムログにも、再現したサーバーのログにも、同じメッセージが出る
TypeError: Invalid character in header content ["x-next-cache-tags"]

generateStaticParams を書いたルートはISRになり、応答に x-next-cache-tags ヘッダーが付く。英数字のパスで取得すると、値はこうなっていた。

_N_T_/layout,_N_T_/isr/layout,_N_T_/isr/[id]/layout,_N_T_/isr/[id]/page,_N_T_/isr/abc

末尾の _N_T_/isr/abc が、パスから作られた暗黙のタグだ。café を渡すと _N_T_/isr/café のように、パーセントエンコードされず、そのままの文字が入る。 日本語のパスなら、日本語がヘッダーの値に入る。Node.jsは、ヘッダーの値にU+00FFを超える文字を許さない。

最小のアプリで再現する

本番と同じ動作モードを、環境変数 NEXT_PRIVATE_MINIMAL_MODE=1 で再現する。Next.js 16.2.1の最小アプリを作り、書き方の違う4つのルートを置く。

{
  "dependencies": {
    "next": "16.2.1",
    "react": "19.2.0",
    "react-dom": "19.2.0",
    "typescript": "5.9.3",
    "@types/react": "19.2.2",
    "@types/node": "24.9.1"
  }
}

app/isr/[id]/page.tsx は次のとおり。

type Props = { params: Promise<{ id: string }> }

export const revalidate = 86400

export async function generateStaticParams() {
  return []
}

export default async function Page({ params }: Props) {
  const { id } = await params
  return <h1>isr: {id}</h1>
}

ほかの3つは、この上の書き方だけを変える。

  • app/fd/[id]/page.tsx: revalidate を export const dynamic = "force-dynamic" に置き換える(generateStaticParams は残す)
  • app/rev/[id]/page.tsx: generateStaticParams を消す(revalidate は残す)
  • app/dyn/[id]/page.tsx: どちらも書かない

app/layout.tsx に <html><body>{children}</body></html> を置いて、次を実行する。

npm install
npx next build
NEXT_PRIVATE_MINIMAL_MODE=1 npx next start -p 3917

next build の出力では、isr と fd が ●(SSG、generateStaticParams を使う)、rev と dyn が ƒ(Dynamic)になる。ビルドは4つとも成功する。 別の端末から、4つのルートを、英数字(abc)と日本語(情報・通信業)で取得する。

const base = "http://127.0.0.1:3917"
for (const route of ["isr", "fd", "rev", "dyn"]) {
  for (const id of ["abc", "情報・通信業"]) {
    const res = await fetch(`${base}/${route}/${encodeURIComponent(id)}`)
    await res.text()
    console.log(route, id, res.status)
  }
}

止めるときは、pkill -f では止まらなかったので、lsof -ti:3917 | xargs kill を使った。

結果

ルートの書き方build通常の next startminimal mode英数字日本語英数字日本語generateStaticParams + revalidategenerateStaticParams + force-dynamicrevalidate だけ何も書かない●●ƒƒ200200200200200200200200200200200200200200200500● = SSG(generateStaticParamsを使う)、ƒ = Dynamic。Next.js 16.2.1の最小アプリで測定した。
500になるのは、ISRの書き方(generateStaticParamsとrevalidate)で、minimal modeに日本語のパスを渡した1通りだけだった。
  • 通常の next start では、4ルート×2種類のパスの8通りすべてが200。x-next-cache-tags ヘッダーも出ない。ローカルでは再現しない
  • force-dynamic を足した fd は、next build の出力が ● のままでも、日本語のパスで200が返り、ヘッダーも出なかった
  • revalidate だけを書いた rev は、動的描画(ƒ)のままで、日本語のパスでも200

落ちる文字の境界

「日本語だけが落ちる」のではない。isr に次のパスを渡した結果は、こうなった。

  • café(é = U+00E9): 200
  • ÿ(U+00FF): 200
  • Ā(U+0100): 500
  • 情報・通信業(最初の文字は U+60C5): 500
U+0000 〜 U+007FU+0080 〜 U+00FFU+0100 〜200200500ASCII(abc など)é ÿ などĀ、日本語、そのほかここから落ちる
境界は U+00FF と U+0100 の間。Node.jsのヘッダー検証が、1バイトで表せる範囲(U+00FFまで)だけを通す。

Node.jsだけでも確かめられる。

node -e 'const { validateHeaderValue: v } = require("node:http"); v("x", "ÿ"); console.log("U+00FF ok"); v("x", "Ā"); console.log("U+0100 ok")'

1つ目は通って U+00FF ok が出て、2つ目で ERR_INVALID_CHAR が投げられた。

見分ける

  • 本番のログ: vercel logs --environment production --no-branch --no-follow --status-code 500 --since 30m --limit 3 --expand で、Invalid character in header content ["x-next-cache-tags"] が出る(10/20に使ったコマンド)
  • 外から取得する: 同じルートを、英数字のパスと日本語のパスの2種類で取得して比べる。片方だけ500なら、これを疑う
  • 書き方を洗い出す: grep -rn "generateStaticParams" app/ でISR化しているルートを探し、パスに入る値(DBの名前、日本語のslug)にU+0100以上の文字が入りうるかを見る

直す

直し方確かめた場所結果
generateStaticParams を外して動的描画にする本番(Valscopeの68URL)、最小アプリ200
generateStaticParams を残し、dynamic = "force-dynamic" を足す最小アプリだけ200
パスを英数字のslugにする最小アプリで、英数字のパスが200になることだけ200

Valscopeでは、1つ目を採った。両ページから generateStaticParams と revalidate を外した。 外すとページの結果をキャッシュしなくなるので、データ取得を守る別のキャッシュが要る。Valscopeは、取得関数の unstable_cache(24時間)が残っているので、取得関数もキャッシュキーも変えなかった。読み取りの増加は想定していないが、実測はしていない。

再発を防ぐ

書き方を固定するテスト

単体テストでは、描画方式の結果は再現できない。固定できるのは、原因になる書き方がないことまでだ。

it("generateStaticParams を export しない", async () => {
  const pageModule = await import("@/app/companies/industry/[id]/page")
  expect("generateStaticParams" in pageModule).toBe(false)
})

修正前の書き方に戻すと、落ちる。

デプロイ直後に、sitemapの全件を取得する

新しい種類のURLを足したら、デプロイ直後に、sitemapに載っているURLを全件取得して、200以外を数える。次のスクリプトは、sitemapの索引(sitemapindex)にも対応し、第2引数のパスの接頭辞で対象を絞れる。

// 使い方: node check-sitemap.mjs <sitemapのURL> [パスの接頭辞]
const [sitemapUrl, prefix = ""] = process.argv.slice(2)
const headers = { "user-agent": "Mozilla/5.0 (compatible; sitemap-check)" }

async function locs(url) {
  const xml = await (await fetch(url, { headers })).text()
  const found = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1].trim())
  if (!xml.includes("<sitemapindex")) return found
  return (await Promise.all(found.map(locs))).flat()
}

const urls = (await locs(sitemapUrl)).filter((u) => new URL(u).pathname.startsWith(prefix))
let bad = 0
for (const url of urls) {
  const res = await fetch(url, { headers, redirect: "manual" })
  await res.arrayBuffer()
  if (res.status !== 200) {
    bad++
    console.log(res.status, decodeURI(url))
  }
}
console.log(`${urls.length}件中、200以外は${bad}件`)
process.exit(bad === 0 ? 0 : 1)

最小アプリの public/sitemap.xml に、5つのURL(isr の英数字・日本語・エンコード済みの日本語、dyn と fd の日本語)を載せて、minimal modeで動かした結果は、次のとおり。

500 http://127.0.0.1:3917/isr/情報・通信業
500 http://127.0.0.1:3917/isr/銀行業
5件中、200以外は2件

終了コードは1で、/dyn で絞ると「1件中、200以外は0件」で終了コード0になった。UAを付けているのは、Pythonの既定のUAだと、CloudflareがすべてのURLに403を返したため(本番の確認で踏んだ)。

確かめていないこと

  • Next.jsの他のバージョン。 試したのは16.2.1だけで、新しい版で直っているかは分からない
  • Next.js本体とVercel側の修正状況。 同じ種類の問題(非ASCIIのパスがキャッシュタグのヘッダーに入る)は、Netlifyのランタイムが直している。コミットの原文では、Pages Routerのタグを作る箇所でキーに encodeURI をかけ、ヘッダーの分割を %2c にも対応させ、日本語のパスのテストを足している。Next.js本体のissueやVercel側の修正は、検索では見つけられなかった
  • 本番が、minimal modeで動いていること自体。 同じ環境変数で、本番と同じエラーが再現した、というところまでしか確かめていない。タグの組み立てのコードは読んでいない
  • force-dynamic の併用と、英数字のslugへの変更。 最小アプリでしか確かめていない。本番では、generateStaticParams を外す形だけを採った
  • U+0100以上のすべての文字。 試したのは Ā と日本語だけ。Node.jsの検証が文字種ではなく範囲で見ていることは、validateHeaderValue の結果と整合している

関連

WIKI一覧へ