日本語パスとISRの500
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 を使った。
結果
- 通常の
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
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の結果と整合している
関連
- 開発日記: 日本語URLだけ全件500 — 何を作って、どう気づいて、なぜ2週間見つからなかったか
- 公開されたか確かめる — 「マージ済み」と「公開済み」を分けて確かめる
- デザインを実装に落とす — Next.jsの実装まわりの手順
- Valscope — 業種別の一覧があるプロダクト