日本語URLだけ全件500 ビルドもテストも通っていた

10月20日、Valscope(val.codese.net)の業種別の企業一覧を、sitemapに載っている68URL、全件取得しました。68件とも、HTTP 500でした。
/companies/industry/情報・通信業 のように、日本語の業種名がパスに入るURLです。10月5日にマージしたもので、本物のGooglebotが10/7〜10/14に取得した55件も、すべて500でした。
直前まで、単体テストは通っていました。DBに触れないコンパイルのビルドも通っていました。ローカルの通常の起動では、日本語のパスでも200が返ります(最小のアプリで確かめました)。
原因は、日本語のパスに、ISR(generateStaticParams)を使ったことでした。
何を作って、何が壊れたのか
Googlebotの巡回が減った問題を調べていて、公開HTMLから直接たどれる企業が、日本321社・米国335社だけだと分かりました。残りの約9,600社への入口は、sitemapだけです。 そこで、全企業へたどれる導線として、業種別の企業一覧を足しました(#1877)。実装は、こう書いていました。
export const revalidate = 86400
// ビルド時にはDBを引かず、初回アクセス時に生成して revalidate の周期で再生成する。
export async function generateStaticParams() {
return []
}
ビルドのときにDBを引かず、初回のアクセスで生成して、24時間ごとに作り直す。そのための書き方でした。 この2ルートだけが、公開後から本番で500を返していました。Googlebotの取得は、こう推移しています。
どうやって気づいたか
公開後の測定です。導線の効果を測る作業の中で、sitemapに載っている業種別一覧68URLを全件取得したところ、68件とも500でした。
切り分けると、壊れているのは新しい2ルートだけでした。
- 存在しない業種(404を返す経路)は、正常
- 従来の業種詳細(
/industries/以下)、/companies、企業詳細は、すべて200
違いは、generateStaticParams を書いているかどうかだけです。
原因は、ヘッダーに日本語が入ること
本番のランタイムログにも、あとで作った最小アプリのサーバーログにも、同じメッセージが出ていました。
TypeError: Invalid character in header content ["x-next-cache-tags"]
Next.js 16.2.1では、generateStaticParams を export したルートはISR(静的生成+再生成)になります。そのルートの応答には、x-next-cache-tags というヘッダーが付き、値にパスから作った暗黙のタグが入ります。パスが日本語だと、ヘッダーの値にも日本語が入ります。
Node.jsは、HTTPヘッダーの値にU+00FFを超える文字を許さず、ERR_INVALID_CHAR で拒否します。応答を返せず、500になります。

タグという札に、押せない文字が入ったので、判が割れる。そういう壊れ方です。
最小のアプリで再現した
記事を書く前に、最小のNext.js 16.2.1アプリで確かめ直しました。次の4つのルートを作り、next build して起動します。
isr:generateStaticParams(空配列)とrevalidate = 86400fd:generateStaticParams(空配列)とexport const dynamic = "force-dynamic"rev:revalidate = 86400だけdyn: 何も書かない
next build は4つとも成功し、出力は isr と fd が ●(SSG、generateStaticParams を使う)、rev と dyn が ƒ(Dynamic)でした。
起動して、英数字のパス(abc)と日本語のパス(情報・通信業)を取得した結果です。
| ルート | 起動の仕方 | 英数字 | 日本語 |
|---|---|---|---|
| isr | 通常の next start | 200 | 200 |
| isr | NEXT_PRIVATE_MINIMAL_MODE=1 | 200 | 500 |
| fd | NEXT_PRIVATE_MINIMAL_MODE=1 | 200 | 200 |
| rev | NEXT_PRIVATE_MINIMAL_MODE=1 | 200 | 200 |
| dyn | NEXT_PRIVATE_MINIMAL_MODE=1 | 200 | 200 |
通常の next start では、4ルート×2種類のパスの8通りすべてが200でした。500が出るのは、NEXT_PRIVATE_MINIMAL_MODE=1 を付けたときの isr に日本語を渡した場合だけです。サーバーのログには、本番と同じ Invalid character in header content ["x-next-cache-tags"] が出ました。
文字の境界も測りました。isr に次のパスを渡します。
café(é = U+00E9)とÿ(U+00FF)は、200Ā(U+0100)は、500情報・通信業(最初の文字は U+60C5)は、500
Node.jsの http.validateHeaderValue でも、U+00FFまではエラーが出ず、U+0100から ERR_INVALID_CHAR になりました。「日本語だけ」ではなく、U+0100以上の文字が1つでも入れば落ちます。
なぜ、気づかなかったのか

テストもビルドも、嘘はついていませんでした。ただ、本番と同じ動作モードで、日本語のパスを1件取得する確認が、どこにも入っていませんでした。#1877のPR本文に書いてあった確認を並べます。
- 単体テスト: DBだけをモックして、ページを直接描画し、リンク・404・noindex・canonicalを確かめた。描画方式も、応答ヘッダーを付ける層も、通らない
- ビルド: DBに触れないコンパイルのみ(
next build --experimental-build-mode compile)が成功した。通常のビルドは未実施と書いてあった。ただ、上の最小アプリでは通常のビルドも成功した。未実施だった通常ビルドを足しても、この500は見つからなかった - ローカル起動: 通常の
next startでは、日本語のパスでも200 - 実画面の目視: 未実施と書いてあった。最小アプリの結果からすると、実施しても、ローカルでは200が返った可能性が高い
新しい一覧は、sitemapにも載せていました。Googlebotは10/7から取得を始め、その全件で500を受けました。私が気づいたのは、10/20にURLを全件取得したときです。
直したこと
#2106で、両ページから generateStaticParams と revalidate を外し、動的描画にしました。
- データは、取得関数の
unstable_cache(24時間)で守られています。取得関数もキャッシュキーも変えていないので、Tursoの読み取りは増えません - テストを2件足しました。両ページが
generateStaticParamsを export していないことを固定します。修正前の書き方に戻すと、2件とも落ちることを確かめました - 本番へデプロイしたあと、68URLが全件200になり、共通導線を確かめるスモークテストも通りました。デプロイ後の500のログは0件でした
テストが固定できるのは、「原因になる書き方がないこと」までです。描画方式の結果は、単体テストでは再現できません。コードにも、そう書いておきました。
まだ確かめていないこと
- Googlebotが500を受けたことが、ほかのURLの巡回に影響したか。 企業ページの取得は日に数件〜10件で、因果は言えません
- 修正後に、Googlebotが業種別一覧を200で取得するか。 効果測定の起点は10/20に決め直しました。2週間後(11/3)と4週間後(11/17)に測ります
force-dynamicを併用する形は、最小アプリでしか確かめていません。 本番では、generateStaticParamsを外す形を採りました- Next.jsの他のバージョン。 試したのは16.2.1だけです。本番の動作モードも、同じ環境変数で同じエラーが出ることまでしか確かめていません
学んだこと
- 日本語など、ASCII以外の文字が入る動的パスには、
generateStaticParamsを置かない。 英数字のIDで試しただけでは、気づけない - 新しい種類のURLを足したら、デプロイ直後に、sitemapに載っている全件を取得して、200を確かめる。 サーバーの外から実際に取得する。Pythonの既定のUAだとCloudflareに403を返されるので、UAを付ける
- 単体テストが固定できるのは、原因の書き方が無いことまで。 動作の結果は、本番と同じ条件の実機で確かめるしかない
- 効果を測る前に、測る対象が200を返しているかを見る。 今回は、効果の測定のために全件を取得して、初めて分かった
再現の手順と、対処3通りの比較は、WIKIにまとめました。
関連する記事です。
- 日本語パスとISRの500 — 最小アプリの作り方、見分け方、対処3通り
- 公開されたか確かめる — 「マージ済み」と「公開済み」を分けて確かめる手順
- Valscope — 業種別の一覧があるプロダクト