React(SPA)のLPがSEOで検索に出ない原因は、クローラーに本文が見えていないこと|curlで本文・robots・sitemapの中身を確かめ、ビルド時にHTMLへ書き出す直し方
React(SPA)のLPがSEOで検索に出ない原因は、クローラーに本文が見えていないこと|curlで本文・robots・sitemapの中身を確かめ、ビルド時にHTMLへ書き出す直し方
React(画面をJavaScriptで組み立てる仕組み)やVite(その開発・ビルドツール)で作ったSPA(1つのHTMLで全ページを表示する作り)のLPが、狙った言葉で検索しても出てこない。SEOのために文章を書き足す前に、まず確かめてほしいのは「クローラーが受け取るHTMLに、本文が入っているか」です。ブラウザで見ると文章も写真も表示されているのに、配信されているHTMLの中身は <div id="root"></div> の1行だけ、ということがよくあります。
結論から書くと、正しい直し方は次の3点です。
- 点検はブラウザではなく
curl(URLの中身をそのまま取得するコマンド)で行います。 見るのは「本文の文字数」「robots.txt と sitemap.xml の中身」「HTMLに埋め込まれたスクリプト」「存在しないURLの応答」の4つです。 - 直し方の基本は、ビルドのときに本文入りのHTMLを書き出すことです。 Reactの画面をサーバー側で文字列にし、
index.htmlの空の箱に差し込んでから公開します。見た目は変わりません。 - 公開後は「元に戻っていないか」を機械で確かめます。 素の版で上書きされても画面は変わらないため、目視では気づけません。
以下、2026年9月に点検・改修した採用LP(AIのサイト生成ツールで作られたVite+React構成)の作業をもとに説明します。このLPは、配信HTML上の本文が0字の状態から、空白を除いて約9,000字が入った状態になりました(下の①の方法で計測)。
なぜクローラーに本文が見えないと検索に出にくいのか
ReactのSPAは、多くの場合「中身が空のHTML」と「画面を組み立てるJavaScript」の2つで動いています。ブラウザはJavaScriptを実行して文章を描くので、人が見る分には問題ありません。クローラー側の事情はこうです。
| 読み手 | JavaScriptの実行 | 空のHTMLを受け取ったときの見え方 |
|---|---|---|
| Googleの検索クローラー(AIによる概要も含む) | 実行する(ただし描画は後回しになることがある) | 読めるが、反映が遅れやすい |
| 自前のクローラーで集めるAI(GPTBot・ClaudeBot・PerplexityBot など) | 基本的に実行しない | title・description・構造化データだけ。本文は白紙 |
つまり「Googleには読めるが遅れやすい」「ChatGPT・Claude・Perplexityのように自前のクローラーで情報を集めるAI検索には、本文が届かない」という状態です。AIの回答に引用してもらうには、本文がHTMLに入っていることが前提で、構造化データやllms.txtは本文の代わりになりません(AI検索向けに何を書くかは「AI検索に店舗サイトを拾わせるために書くこと」で整理しています)。
手順1:curlで4つの中身を確かめる
開発ツールの「要素」タブはJavaScriptが動いた後の状態なので使えません。ターミナルで次を実行します。
U=https://example.com
# ① <body>の中の文字数(スクリプトとタグを除いて数える)
curl -s -A "GPTBot" $U/ | perl -0777 -ne 's/.*?<body[^>]*>//s;
s/<\/body>.*//s; s/<(script|style).*?<\/\1>//gs; s/<[^>]+>//g;
s/\s//g; print' | wc -m
# ② robots.txt / sitemap.xml / llms.txt の中身(先頭だけ見る)
for f in robots.txt sitemap.xml llms.txt; do
echo "--- $f"; curl -s $U/$f | head -3
done
# ③ HTMLの大きさと、読み込んでいるスクリプトの一覧
curl -s $U/ | wc -c
curl -s $U/ | grep -o '<script[^>]*>' | sort | uniq -c
# ④ 存在しないURLの応答
curl -s -o /dev/null -w '%{http_code}\n' $U/no-such-page
①で <body> の中だけを、スクリプトを除いて数えるのは、title・description・構造化データにも同じ言葉が入っているからです。HTML全体を言葉で検索すると、本文が空でもそちらにヒットして「入っている」と誤判定します。
判定は次のとおりです。
| 点検 | 正常 | 要対処のサイン |
|---|---|---|
| ① 本文 | 画面の文章量に見合う文字数(数百字以上) | ほぼ0。<body> の中が <div id="root"></div> だけ |
| ② robots・sitemap | User-agent: や <?xml・<urlset を含む、それぞれの書式 |
<!doctype html> で始まる=トップページのHTMLが返っている |
| ③ HTML・スクリプト | 本文量に見合う大きさ | 本文が無いのに数百KBある。属性の無い <script> が並ぶ、開発用らしい名前のスクリプトを読み込んでいる |
| ④ 存在しないURL | 404 | 200=存在しないURLが全部トップと同じ内容で返る |
③の目安として、本文の無いSPAの index.html は5KB前後のことが多く、それより極端に大きいのに本文が0なら、何かが直接埋め込まれています。
②と④は、SPAでよく使われる「どのURLに来ても index.html を返す」転送設定から起こります。robots.txt や sitemap.xml の実ファイルが無いと、その転送がトップのHTMLをステータス200で返します。ステータスだけ見ると「ある」と判定してしまうので、必ず中身まで見ます。④を放置すると、旧URLや打ち間違いのURLがすべてトップと同じ内容の別ページとして残り、重複扱いの原因になります。
冒頭の採用LPは、4つすべてに該当していました。本文は0字、robots.txt・sitemap.xml・llms.txt はどれもトップのHTML、そしてHTMLの中には生成ツールの開発用スクリプトが合計約620KB入っていました(うち約260KBは index.html に直接埋め込み)。AIの生成ツールで作ったLPは、手を入れずに公開するとこの状態になることがあるので、公開前の点検に組み込んでおくのが安全です。
ページごとの <title> と canonical も並べて確認します。index.html に1つだけ書いたSPAは全URLが同じになります(「検索結果のサイトリンクが変な文言になるとき」参照)。
手順2:直し方を3つから選ぶ
本文をHTMLに入れる方法は大きく3つあります。手元の状況で選びます。
| 方法 | 向いている状況 | 注意点 |
|---|---|---|
| A. ビルド時に書き出す | ソースとビルド環境が手元にあり、今後も同じ構成で更新する | 画面のコードがサーバー側で動くように直す必要がある場合がある |
| B. 公開中のページを描画して保存する | ソースやビルド環境が別のパソコンにしか無い、店舗数が多い | 生成した時点の内容が焼き込まれる。定期的な再生成が要る |
| C. 静的生成のフレームワークへ移す | ブログなど、ページが増え続ける | 作り直しの工数がかかる |
Bは、ヘッドレスブラウザ(画面を表示せずに動くブラウザ)で本番ページを開き、描画後のHTMLを保存して配置する方法です。名古屋の美容サロンFCの店舗サイト25店ではこの方法で、各店の本文が0字から2,000〜4,700字になりました。Cについては「WordPress+Astroのヘッドレス構成が中小企業サイトに向いている理由」で判断基準をまとめています。
1枚もののLPでソースが手元にあるなら、Aが最も素直です。
手順3:ビルド時に本文入りHTMLを書き出す(Vite+React)
サーバー側の入口を1つ作る
画面の一番外側のコンポーネントを、文字列にして返すだけのファイルを用意します。
// src/entry-server.tsx
import { renderToString } from "react-dom/server";
import App from "./App";
export function render() {
return renderToString(<App />);
}
ビルドを3段にし、公開フォルダとサーバー用を分ける
まず vite.config.ts で、通常ビルドの出力先を公開用のフォルダ(例:build.outDir: "dist/public")にします。そのうえで package.json に、通常のビルド → サーバー用ビルド → 差し込み、の順で動くコマンドを1本作ります。
"build:static": "vite build && vite build --ssr src/entry-server.tsx --outDir dist/server && node scripts/prerender.mjs"
サーバー用のビルドは dist/server に出るので、アップロードするのは dist/public だけにします。出力先を分けずに dist/ ごと上げると、サーバー用のコードまで公開されます。
scripts/prerender.mjs では、dist/public/index.html を読み、<div id="root"></div> の中に render() の結果を差し込んで保存します。
ブラウザ側の起動コードは createRoot のままにしました。同じ中身で描き直すだけなので、表示崩れやJavaScriptのエラーは出ていません(既存の中身を引き継ぐ hydrateRoot に切り替える方法もあります)。
画面のコードが読み込み時に window や document を触っていると、サーバー側のビルドで止まります。ブラウザでしか動かない処理は useEffect の中へ移します。
同じ設定ファイルから head と付属ファイルも作る
title・description・OGP・構造化データ(JSON-LD)と、robots.txt・sitemap.xml・llms.txt は、1つの設定ファイルから生成するようにしました。手書きで別々に持つと、募集条件を変えたときに片方だけ直す事故が起こりやすくなるためです。
開発用のプラグインを本番ビルドから外す
AIの生成ツールの画面編集用プラグインなどは、本番ビルドでは外す条件を設定に入れます。
// vite.config.ts(抜粋)
const isBuild = process.argv.includes("build");
export default defineConfig({
plugins: isBuild
? [react(), tailwindcss()]
: [react(), tailwindcss(), devToolPlugin()],
build: { outDir: "dist/public" },
});
採用LPでは、これで index.html が377KBから157KBになりました。
開いたときだけ描く部品を、最初から描く形に変える
よくある質問(FAQ)を「クリックしたら回答を表示する」作りにしていると、回答は開くまでHTMLに存在せず、書き出したHTMLにも入りません。開閉は <details> と <summary> のタグで作ると、閉じた見た目のまま回答がHTMLに入ります。採用LPではFAQを9問から16問に増やし、すべてこの形にしました。
存在しないURLは404で返す
「どのURLでも index.html を返す」転送をやめ、実在しないURLは404ページをステータス404で返します。サブページは末尾スラッシュの有無も301で片方へ揃えます。
手順4:公開後に確かめること、戻さない運用
公開したら、手順1の4点をもう一度実行します。加えて次の2つを確認します。
- 画像などのファイルが403になっていないか。 lftpの
mirror -Rなど一部の転送ツールは、手元のファイル権限をそのまま持ち込みます。手元で600になっていたファイルは、公開後に403になります。次の転送で戻らないよう、アップロード前に手元でfind dist/public -type f -exec chmod 644 {} +を実行します。対象はファイルだけで、フォルダは755のままにします(フォルダまで644にするとサイト全体が開けなくなります)。 - 書き出したHTMLに目印があるか。
<!-- prerendered -->のようなコメントを入れておき、curlでその有無を見ます。
目印が必要なのは、本文入りのHTMLは素の版で上書きされても誰も気づかないからです。画面は同じように表示され、HTTPステータスも200のままです。起こりうる経路は主に2つあります。
- 古いビルドコマンド(
vite buildだけのもの)で作ったindex.htmlを上げてしまう - 転送ツールの「手元に無いファイルはサーバーから消す」設定(
mirror --deleteなど)で、書き出したファイルや404ページ、robots.txt が消える
対策として、ビルドコマンドは書き出し付きの1本だけにし、目印・title・本文の文字数・robots.txtの書式・存在しないURLの404をまとめて確かめる検査スクリプトを、公開のたびに最後に実行します。異常なら失敗で終わる形にしておけば、人の記憶に頼らずに済みます。本番を直接直した内容が次の更新で消える構造については「本番サイトを直接直した修正が次の更新で消えるとき」も参考にしてください。
方法Bは料金や口コミなど生成時点の内容が焼き込まれるので、月1回など再生成の周期を決めます。
まとめ
React(SPA)のLPがSEOで成果を出せないとき、最初に疑うのは文章量ではなく、クローラーが受け取るHTMLに本文があるかどうかです。curl で4点を中身まで見て、ソースが手元にあればビルド時に本文入りHTMLを書き出す。head情報と付属ファイルも同じ設定から作り、公開後は素の版に戻っていないかを目印で機械的に確かめる。これで検索とAI検索に本文が届きます。
既存のLPがこの状態かどうかの確認だけでも、お問い合わせからお気軽にご相談ください。