0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js の静的書き出しで、Google がスラッシュ無しの URL を拾い続けた原因は RSC ペイロードの中の path だった

0
Posted at

全国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 は /{県}/{町}(スラッシュ無し)
web/next.config.mjs
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 として渡していました。

web/src/views/TownView.tsx(抜粋)
<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" は、そのままの形でページに残ります。

修正前の町ページの HTML(抜粋)
<script>self.__next_f.push([1,"…\"path\":\"/iwate/yamada\"…"])</script>

trailingSlash: true は、Next.js が作るルートと出力のファイルの形を決める設定です。自分のデータの中の文字列までは変えてくれません。

直し方:スラッシュを付けるのは、データを読む1か所だけ

データ(町ごとの JSON・当時1,393ファイル)は変えずに、web の側で読み込むところで1回だけ / を付けることにしました。

web/src/lib/data.ts
export function withTrailingSlash(path: string): string {
  return path.endsWith("/") ? path : `${path}/`;
}

町の一覧と検索用の索引を読む関数でこれを通し、public/ に配る JSON を作るスクリプトにも同じ処理を入れました。

web/scripts/sync-data.mjs(抜粋)
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 に書いています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?