全国1,741市区町村を同じ形式で紹介する図鑑サイト「まちがたり」を、Next.js の静的書き出しで作っています。公開して1か月ほどで、Search Console に スラッシュ無しの URL(/iwate/yamada のような形)が出てくるようになりました。サイトの URL はすべて /iwate/yamada/ のようにスラッシュ付きです。
301 で転送しても止まらなかったので、出どころを探しました。原因は、HTML の中に埋め込まれる RSC ペイロード(self.__next_f.push(...))に入っていた、スラッシュ無しの path という文字列でした。調べた順に書きます。
結論
- App Router では、Server Component から Client Component に渡した props が、RSC ペイロードとしてそのまま HTML に入ります
-
<a href>・canonical・サイトマップを全部スラッシュ付きにしても、渡したデータの中のpathがスラッシュ無しなら、その文字列はページに載ります - 直し方:URL の形は「データを読み込む1か所」でそろえ、描画のたびに
${path}/と足すのをやめる
構成
| 項目 | 内容 |
|---|---|
| フレームワーク | Next.js 15(App Router)・React 19 |
| 書き出し |
output: "export"・trailingSlash: true
|
| 配信 | Cloudflare Workers の静的アセット配信(html_handling: "auto-trailing-slash") |
| ページ数 | サイトマップで3,711 URL(日本語と英語) |
| 町のデータ | Python で作った町ごとの JSON。path は /{県}/{町}(スラッシュ無し) |
const nextConfig = {
output: "export",
trailingSlash: true,
images: { unoptimized: true },
};
起きたこと
2026年10月20日、Search Console の「検索パフォーマンス」を書き出すと、京都府京田辺市のページが /kyoto/kyotanabe(スラッシュ無し)で61回表示されていて、正しい /kyoto/kyotanabe/ より多く出ていました。
このとき本番では、スラッシュ無しの URL へのリクエストは 307(一時的な転送) でスラッシュ付きに転送されていました。Workers の静的アセット配信の auto-trailing-slash が返す転送で、状態コードを変える設定はありません。
1手目:301 にする(これだけでは止まらなかった)
一時的な転送だと、Google が元の URL を残すことがあります。そこで、Cloudflare ダッシュボードのリダイレクトルールで、手前で 301 を返すようにしました。
| 項目 | 設定 |
|---|---|
| 条件 | host が machigatari.com/パスが / で終わらない/パスに . を含まない |
| 転送先(動的) | concat("https://machigatari.com", http.request.uri.path, "/") |
| 状態コード | 301・クエリ文字列は残す |
「パスに . を含まない」を入れたのは、JS・JSON・画像と、クライアント遷移で読む index.txt まで転送しないためです。
_redirects で /kyoto/:city /kyoto/:city/ 301 のように書く方法は使えませんでした。/kyoto/index.txt(県のページの遷移用のデータ)にも当たって、ページの遷移が壊れます。
curl -sI で 301 が返るのを確かめ、「あとは Google が再クロールすれば消える」と考えていました。
2手目:「再クロール待ち」では説明できない
5日後の10月25日の書き出しでは、スラッシュ無しの URL が京田辺のほかに 36ページ・約56表示に増えていました(その前は約17表示)。
増えたうち9ページは、10月21〜23日に作った町のページでした。301 を入れたのは10月20日なので、スラッシュ無しの URL が最初から 301 を返していたページです。307 のころの索引が残っているだけ、という説明は成り立ちません。Google はどこかで、スラッシュ無しの URL を見つけ続けていることになります。
作った日が分かっている新しいページが、検索にどんな URL で出てくるかを見ると、「古い索引の残り」か「いまも出どころがある」かを分けられます。
出どころを探す
本番の HTML を調べると、<a href>・canonical・hreflang・サイトマップは、どれもスラッシュ付きでした。
<a class="absolute inset-0 z-0 rounded-xl" aria-label="次の町へ → 岩手県岩泉町" href="/iwate/iwaizumi/">
そこで、リンクに限らず「町のパスの形をした文字列」を全部探しました。RSC ペイロードは JS の文字列の中にあるので、引用符が \" とエスケープされています。grep の式もそれに合わせます。
# 町ページの HTML(RSC ペイロード):スラッシュ無しの "path"
grep -oE '\\"path\\":\\"/[a-z0-9-]+/[a-z0-9-]+\\"' out/iwate/yamada/index.html
# 配っている JSON:スラッシュ無しの "p" や "path"
grep -oE '"(p|path)":"/[a-z0-9-]+/[a-z0-9-]+"' out/reel.json out/search-index.json
スラッシュ無しが出てきたのは、次の3か所だけでした。
| 場所 | 中身 | 読まれるとき |
|---|---|---|
| 町ページの RSC ペイロード |
\"path\":\"/iwate/yamada\"(自分と前後の町で、1ページに4つ) |
すべての町ページの HTML に入っている |
reel.json |
"p" が1,393件(当時の町の数) |
トップページが読み込む |
search-index.json |
"path" が1,393件 |
検索欄を使ったときだけ |
原因:Client Component に渡したオブジェクトが、そのまま HTML に載る
町のデータの path はもともとスラッシュ無しで、画面を描くところで毎回 / を足していました。
<Link href={localePath(lang, `${next.path}/`)}>
href はこれで正しくなります。ところが町ページでは、町のオブジェクト(自分と前後の町)を、スマホ用のバーやスライド表示といった Client Component に props として渡していました。
<MobileBar name={name} slug={muni.slug} code={muni.code} path={muni.path} lang={lang} />
<SlideMode
next={next ? toNextPeek(next, lang) : null}
prev={prev ? toPeek(prev) : null}
/>
Client Component の props は、ブラウザで組み立て直すために、RSC ペイロードとして HTML に埋め込まれます。描画した href には / が付いていても、渡した元のデータ "path":"/iwate/yamada" は、そのままの形でページに残ります。
<script>self.__next_f.push([1,"…\"path\":\"/iwate/yamada\"…"])</script>
trailingSlash: true は、Next.js が作るルートと出力のファイルの形を決める設定です。自分のデータの中の文字列までは変えてくれません。
直し方:スラッシュを付けるのは、データを読む1か所だけ
データ(町ごとの JSON・当時1,393ファイル)は変えずに、web の側で読み込むところで1回だけ / を付けることにしました。
export function withTrailingSlash(path: string): string {
return path.endsWith("/") ? path : `${path}/`;
}
町の一覧と検索用の索引を読む関数でこれを通し、public/ に配る JSON を作るスクリプトにも同じ処理を入れました。
const withTrailingSlash = (rows) =>
rows.map((r) => (typeof r.path === "string" && !r.path.endsWith("/") ? { ...r, path: `${r.path}/` } : r));
あとは、描画のところで ${x.path}/ と足していた、約20ファイルの25か所を外しました。
- <Link href={localePath(lang, `${next.path}/`)}>
+ <Link href={localePath(lang, next.path)}>
/ を足す場所が1か所なら、新しい部品で足し忘れたり、二重に // を付けたりすることもなくなります。
確かめたこと
- 書き出し(
out/)と直す前の本番で、HTML のhref・src・contentの値を比べると、同じでした(山田町の日英・岩手県・トップで。違いは webpack のチャンク名だけ)。サイトマップの URL も同じです - スラッシュ無しの町のパスは、町ページの RSC ペイロード・
reel.json・search-index.jsonのどれも0件になりました - 町のパスの後ろの
//も0件です
トップページでは、スラッシュ無しの町のパスが4件残って見えました。中を見ると、RSC ペイロードがチャンクの境目で切れていて、/wakayama/kushimo のところで "])</script> が入り、続きが次のチャンクにあるだけでした。grep の結果は、ヒットした前後も目で確かめたほうが安全です。
本番では、町ページの中が \"path\":\"/iwate/yamada/\" の形になっています。
結果
直したのは10月25日です。10月31日の書き出しで、直した後に作った町(10月28〜29日に公開)を見ました。
| 検索に出たページ | うちスラッシュ無し | |
|---|---|---|
| 直す前(10月28日の書き出し・日本語の町ページ) | 553 | 138(約25%) |
| 直した後に作った町(10月31日の書き出し・日本語) | 60 | 0 |
| 直した後に作った町(10月31日の書き出し・英語) | 47 | 0 |
直す前と同じ割合なら、日本語の60ページのうち15ページほどにスラッシュ無しが出るはずでした。直す前は、作った町が1〜3日でスラッシュ無しで出始めていたので、2〜3日たっても0なのは、効いたと見てよいと考えています。
直す前からある町のスラッシュ無し(123ページ・317表示)は、まだ残っています。これは再クロールで減っていくのを待っています。
3か所を同時に直したので、Google がどれを拾っていたのかまでは切り分けていません。すべての町ページに入っていた RSC ペイロードがいちばん疑わしい、と見ています。
まとめ
-
trailingSlash: trueでそろうのは、Next.js が作るルートと出力のファイルの形です。データの中の URL の文字列は、自分でそろえる必要があります - Client Component に渡した props は HTML に載ります。
<a href>だけを見ていると気づけません - URL の形は、データを読み込む1か所でそろえる。描画のたびに足さない
- 書き出しを grep で調べるときは、RSC ペイロードの中の引用符が
\"になっていることに注意する - 作った日が分かっている新しいページの出方を Search Console で見ると、「古い索引の残り」か「いまも出どころがある」かを分けられます
おわりに
この切り分けと直しは、Claude Code で進めました(本体が出どころを切り分けて直し方を決め、Sonnet の agent が実装しました)。サイト全体を AI でどう作ったかは note に書いています。
- サイト:まちがたり
- 作り方の全体:オープンデータを活用したWEBコンテンツ制作にAI駆動開発を全面適用した話