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

公開: 2026-10-21
開発日記Next.jsISRVercelValscope

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の取得は、こう推移しています。

10/5 #1877をマージ10/20 修正を本番へ(68URL 全件200)3498211000000000567891011121314151617181920日付(10月)500を返した取得。200は0件
本物のGooglebotによる業種別一覧の取得。10/7に34件、10/14までに計55件で、すべて500だった(Cloudflareの配信ログ、/_next/ を除く)。10/15〜10/19は0件。

どうやって気づいたか

公開後の測定です。導線の効果を測る作業の中で、sitemapに載っている業種別一覧68URLを全件取得したところ、68件とも500でした。

切り分けると、壊れているのは新しい2ルートだけでした。

違いは、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になります。

木札に判を押そうとして、判が真ん中から割れる番所の机の上

タグという札に、押せない文字が入ったので、判が割れる。そういう壊れ方です。

リクエストISRの応答Nodeのヘッダー検証結果/companies/industry/情報・通信業暗黙のタグをヘッダーへx-next-cache-tags値の文字はU+00FFまで許可500ERR_INVALID_CHAR英数字のパス(/companies/industry/abc)同じ経路を通るが、タグがASCIIだけなので検証を通り、200が返る。通常のnext startでは、日本語のパスもこれと同じ200になる。
500になるのは、ISRのルートで、パスにU+0100以上の文字が入ったときだけ。

最小のアプリで再現した

記事を書く前に、最小のNext.js 16.2.1アプリで確かめ直しました。次の4つのルートを作り、next build して起動します。

next build は4つとも成功し、出力は isr と fd が ●(SSG、generateStaticParams を使う)、rev と dyn が ƒ(Dynamic)でした。 起動して、英数字のパス(abc)と日本語のパス(情報・通信業)を取得した結果です。

ルート起動の仕方英数字日本語
isr通常の next start200200
isrNEXT_PRIVATE_MINIMAL_MODE=1200500
fdNEXT_PRIVATE_MINIMAL_MODE=1200200
revNEXT_PRIVATE_MINIMAL_MODE=1200200
dynNEXT_PRIVATE_MINIMAL_MODE=1200200

通常の next start では、4ルート×2種類のパスの8通りすべてが200でした。500が出るのは、NEXT_PRIVATE_MINIMAL_MODE=1 を付けたときの isr に日本語を渡した場合だけです。サーバーのログには、本番と同じ Invalid character in header content ["x-next-cache-tags"] が出ました。

文字の境界も測りました。isr に次のパスを渡します。

Node.jsの http.validateHeaderValue でも、U+00FFまではエラーが出ず、U+0100から ERR_INVALID_CHAR になりました。「日本語だけ」ではなく、U+0100以上の文字が1つでも入れば落ちます。

なぜ、気づかなかったのか

谷の道に長く連なる旅人の行列と、閉じた関所の内側で行列に背を向けて帳面を書く番人

テストもビルドも、嘘はついていませんでした。ただ、本番と同じ動作モードで、日本語のパスを1件取得する確認が、どこにも入っていませんでした。#1877のPR本文に書いてあった確認を並べます。

新しい一覧は、sitemapにも載せていました。Googlebotは10/7から取得を始め、その全件で500を受けました。私が気づいたのは、10/20にURLを全件取得したときです。

直したこと

#2106で、両ページから generateStaticParams と revalidate を外し、動的描画にしました。

テストが固定できるのは、「原因になる書き方がないこと」までです。描画方式の結果は、単体テストでは再現できません。コードにも、そう書いておきました。

まだ確かめていないこと

学んだこと

  1. 日本語など、ASCII以外の文字が入る動的パスには、generateStaticParams を置かない。 英数字のIDで試しただけでは、気づけない
  2. 新しい種類のURLを足したら、デプロイ直後に、sitemapに載っている全件を取得して、200を確かめる。 サーバーの外から実際に取得する。Pythonの既定のUAだとCloudflareに403を返されるので、UAを付ける
  3. 単体テストが固定できるのは、原因の書き方が無いことまで。 動作の結果は、本番と同じ条件の実機で確かめるしかない
  4. 効果を測る前に、測る対象が200を返しているかを見る。 今回は、効果の測定のために全件を取得して、初めて分かった

再現の手順と、対処3通りの比較は、WIKIにまとめました。

関連する記事です。