空だったHTMLがエッジを通りながら埋められ、クローラーがその結果を読む流れを表した代表画像

概要

この記事は Cloudflare Workers 静的サイトガイド - デプロイからSEOまで シリーズの一部です。

Reactで作ったSPAを静的ホスティングに載せて使うケースが多いです。デプロイが単純で、サーバーがないので運用するものもありません。コストもほとんどかかりません。

ところが検索を気にし始めると、すぐに壁にぶつかります。サーバーが返す生のHTMLに、検索エンジンが読むものが一つもないからです。

この記事は、その問題をバックエンドサーバーを新しく立てずにCloudflare Workersだけで解いていった記録です。3つの段階に分けて扱います。後ろに行くほど得られるものが大きく、手間もかかります。

0段階 — 何もしない。 生のHTMLが空の殻です。ほとんどのSPAがここにいます。

1段階 — Workersでメタを埋める。 タイトル・説明・OG・構造化データがレスポンスのHTMLに入ります。作業量が少なく効果が明確です。

2段階 — Workersで本文まで描く。 実際の画面をサーバーでレンダリングして入れます。最も手間がかかりますが、クローラーが読む内容ができます。

この記事で扱うこと

  • GoogleがJSをレンダリングするのに、なぜ自分で入れる方がよいのか
  • Workersで <head> を埋める方法と、そのときの罠
  • entry-client / entry-server の分離と vite build --ssr
  • サーバーが受け取ったデータをブラウザに渡し、APIの往復を増やさない方法
  • 実際に変わった数値と、踏んだ罠

扱わないこと

前提

Cloudflareに静的サイトを載せたことがあり、wrangler でローカルにWorkerを立ち上げたことがあると仮定します。初めてなら、上のデプロイの記事を先に読む方がよいです。


問題 — 生のHTMLに検索エンジンが読む内容がない

ビルドされたSPAのHTMLをそのまま受け取ってみましょう。

1curl -s https://example.com/products/1234
 1<!doctype html>
 2<html lang="ko">
 3  <head>
 4    <title>My Service</title>
 5    <meta name="description" content="A generic site description" />
 6  </head>
 7  <body>
 8    <div id="root"></div>
 9    <script type="module" src="/assets/index-a1b2c3.js"></script>
10  </body>
11</html>

1.6KBほどの殻です。商品名も価格も説明もありません。すべてJSが実行され、APIのレスポンスが届いた後にはじめて作られます。

人には何の問題もありません。ブラウザがJSを実行するからです。問題は読む側がJSを実行しないときです。

  • カカオトーク・LINE・Xのリンクプレビューボット
  • JavaScriptを実行しない検索エンジンのクローラー
  • 各種AIクローラー

彼らにとってこのページは「My Service」というタイトルの空の文書です。数千の詳細ページがすべて同じタイトル・同じ説明を付けているということでもあります。

実際に筆者が扱ったサービスでは、詳細ページ8万件がすべて同一の <title> で、<div id="root"> の中は0文字でした。


「Googleはレンダリングしてくれるらしいけど?」

その通りです。これは事実です。

Googleの公式ドキュメントは、Googlebotがクロール → レンダリング → インデックスの3段階を経て、レンダリング段階でヘッドレスChromiumでJSを実行すると明記しています。SPAだからインデックスされない時代は終わりました。

ところが同じドキュメントにこんな文章があります。

Googlebot queues all pages with a 200 HTTP status code for rendering (…) the page may stay on this queue for a few seconds, but it can take longer than that.

server-side or pre-rendering is still a great idea because it makes your website faster for users and crawlers, and not all bots can run JavaScript.

読んでみると3つです。

  1. Googleは実行する — その通り
  2. レンダリングはキューに入る — クロール直後ではなく、リソースが空いたときです。「数秒かもしれないし、それより長くかかることもある」
  3. Googleのドキュメント自身がSSR/プリレンダーを勧める — 理由として「すべてのボットがJavaScriptを実行できるわけではない」を挙げています

3番が核心です。検索トラフィックの相手はGoogleだけではなく、他のボットがJavaScriptを実行するかについては、ほとんど公開された根拠がありません。

韓国ならNaverのYetiが気になるところですが、筆者はYetiのJS実行有無についての公式ドキュメントを見つけられませんでした。「実行しない」という話が広く出回っていますが、根拠を確認できなかったのでここでは断定しません。確認された事実だけでも改善する理由は十分です。

そしてもう一つ。Googleが結局読んでくれるとしても、リンクプレビューボットは待ってくれません。 共有したリンクにタイトルが付かないのは、インデックスとは別の損失です。

なので結論は**「Googleがやってくれるから大丈夫」ではなく「HTMLに直接ある方がよい」**です。


1段階 — Workersでメタタグを埋める

アイデア

本文をすべて描くのは手間がかかります。ところがタイトル・説明・OG・構造化データだけ埋めても得られるものがかなり大きいです。

  • 検索結果に出るタイトル・説明がページごとに変わる
  • リンクプレビューがきちんと付く
  • JSON-LDで「このページの事実」を構造化して渡せる — 本文がなくても

そしてこれは静的アセットの前に薄い層を一つ置くだけでできます。

Workerをどこに通すか決める

概要で述べた前提のうち、この段階に直接関わるものが一つあります。**基本ルールは「アセットがあればWorkerは動かない」**ということです(ルーティングドキュメント)。/ のように index.html が実際に存在するパスは、そのままにしておくとWorkerを通りません。

そこで通すパスを指定します。

 1// wrangler.jsonc
 2{
 3  "main": "./cloudflare/workers/main.ts",
 4  "assets": {
 5    "binding": "ASSETS",
 6    "not_found_handling": "single-page-application",
 7
 8    // HTML routes only. Never `true` - static asset requests are free,
 9    // but anything routed through the worker becomes billable.
10    "run_worker_first": ["/", "/products/*"]
11  }
12}

Workerはアセットを代わりに返すのではなく、受け取って直してくれます。env.ASSETS.fetch(request) でHTMLの殻を取り出し、加工してから送り出す構造です。

Workerのコード

 1// cloudflare/workers/main.ts
 2export default {
 3  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
 4    // Only touch HTML. Everything else passes through untouched.
 5    const asset = await env.ASSETS.fetch(request);
 6    if (!asset.headers.get('content-type')?.includes('text/html')) return asset;
 7
 8    const path = new URL(request.url).pathname;
 9    const meta = await metaFor(path, env, ctx);
10    // On failure, ship the shell unchanged. A generic title beats a broken one.
11    if (!meta) return asset;
12
13    return new HTMLRewriter()
14      .on('title', {
15        element(e) {
16          e.setInnerContent(meta.title);
17        },
18      })
19      .on('meta[name="description"]', {
20        element(e) {
21          e.setAttribute('content', meta.description);
22        },
23      })
24      .on('head', {
25        element(e) {
26          e.append(headTags(meta), { html: true });
27        },
28      })
29      .transform(asset);
30  },
31} satisfies ExportedHandler<Env>;

HTMLRewriterWorkers内蔵のストリーミングHTMLパーサーです。文字列を丸ごと読んで置換するのではなく、レスポンスが流れている途中でタグを差し替えます。メモリも遅延もほとんどかかりません。

入れる値が人の書いたテキストなら、エスケープを忘れないようにしましょう。商品名に & や " が入ると属性が壊れ、JSON-LDの値に が入るとブラウザがそこでスクリプトを切ってしまいます。

細かい罠を一つ。ハンドラーは void を返す必要がありますが、HTMLRewriter のメソッドはチェーンできるように Element を返します。element: (e) => e.setInnerContent(...) のようにアロー短縮形で書くと型が合いません。ブロック本体で書くか、void を付ける必要があります。

データはAPIから取得する

タイトルに商品名を入れるには値を知る必要があります。WorkerからAPIを呼びます。

この記事の例は、筆者が運営する console.plzhans.com の公開APIを使ったコードを一般化したものです。以下のコードに X-Client-IdOrigin が一緒に登場するのもそのためです — そのAPIは(クライアントID、登録されたOrigin)のペアで呼び出しを確認します。使うAPIによっては、この部分が Authorization ヘッダー一つで済むこともあります。

 1/**
 2 * env comes from `wrangler deploy --var`, e.g.
 3 *   API_BASE_URL  https://api.example.com
 4 *   SITE_URL      https://example.com
 5 *   CLIENT_ID     pub_1a2b3c
 6 */
 7interface Env {
 8  ASSETS: Fetcher;
 9  API_BASE_URL: string;
10  SITE_URL: string;
11  CLIENT_ID: string;
12}
13
14async function fetchProduct(id: string, env: Env, ctx: ExecutionContext) {
15  // https://api.example.com/products/1234
16  const url = `${env.API_BASE_URL}/products/${id}`;
17
18  try {
19    const res = await fetch(url, {
20      headers: {
21        // Not a browser, so Origin is not set automatically.
22        // Needed if the API validates (client id, Origin) as a pair.
23        Origin: env.SITE_URL,
24        'X-Client-Id': env.CLIENT_ID,
25      },
26      // Give up rather than delay the page.
27      signal: AbortSignal.timeout(1500),
28      // Edge-cache it. Most requests never reach the API.
29      cf: { cacheTtl: 3600, cacheEverything: true },
30    });
31    if (!res.ok) return null;
32    return (await res.json()) as Product;
33  } catch {
34    return null; // timeout or network error - fall back to the shell
35  }
36}

ここには重要な設計原則が2つあります。

① 失敗してもページは表示されなければならない。 メタを付けるのは付属機能です。APIが落ちたからといってサイトが落ちてはいけません。そのため、すべての失敗経路が「殻をそのまま返す」に収束します。

cf オプションでエッジキャッシュをかける方法は Request ドキュメント に整理されています。

② タイムアウトを置く。 人が待つのはキャッシュミスのときだけで、そのときも最初の1バイトが遅れるだけです。それでも上限は必要です。

何を付けるか

headTags() が作り出す文字列が、そのまま付く内容です。何を入れるかはサービスごとに異なりますが、この場所で価値が大きいものはおおむね次の通りです。

  • canonical · 多言語なら hreflang
  • OGog:title · og:description · og:image · og:url)と twitter:card
  • JSON-LD 構造化データ

特に canonicalhreflang はここで付ける理由が明確です。画面からJSで入れると、ボットにとっては無いのと同じだからです。JSON-LDは本文がなくても検索エンジンに事実を渡せるので、本文をまだ描けない1段階では特に価値が大きいです。

各項目を何で埋めるのがよいかはSEO側のテーマなので、ここでは扱いません。この記事の関心事は**「それをどこでどう差し込むか」**です。項目別の書き方はSEO最適化の記事に別途まとめてあります。

1段階で得られるものと得られないもの

得られるものページ別のタイトル・説明、リンクプレビュー、canonical・hreflang、構造化データ
得られないもの本文。 <div id="root"> は依然として空のまま

作業量に対する効果が大きいです。ここまでで止めても十分よい選択です。

ただしクローラーが読む内容がないのはそのままです。詳細ページの本文とページ同士をつなぐ内部リンクがありません。後者が思ったより大きいです — クローラーから見ると、すべての詳細ページが互いにつながりのない島になります。


2段階 — 本文までサーバーで描く

アイデア

ブラウザがやっていたレンダリングをWorkerが代わりにやってHTMLに入れます。Reactコンポーネントをそのまま実行するので、マークアップを手で移す必要がありません。スタイルが変わればサーバーのHTMLも自動的について来ます。

核心はエントリーポイントを2つに分けることです。

1src/
2  app/
3    routes.tsx       route definitions only. no router is created here
4    Providers.tsx    shared shell (StrictMode · i18n · QueryClient)
5  entry-client.tsx   for the browser — hydrate
6  entry-server.tsx   for the server — renderToReadableStream

Before — エントリーポイントが一つ

 1// src/main.tsx
 2import { StrictMode } from 'react';
 3import { createRoot } from 'react-dom/client';
 4import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 5import App from '@/app/App';
 6
 7const queryClient = new QueryClient({
 8  defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
 9});
10
11createRoot(document.getElementById('root')!).render(
12  <StrictMode>
13    <QueryClientProvider client={queryClient}>
14      <App />
15    </QueryClientProvider>
16  </StrictMode>,
17);
1// src/app/App.tsx
2const router = createBrowserRouter(routes);   // routes are defined in this same file
3
4export default function App() {
5  return <RouterProvider router={router} />;
6}

このコードがサーバーで動かない箇所が3つあります。

コード理由
document.getElementById('root')サーバーに document がない
createBrowserRouter(...)history APIを使う。リクエストURLという概念がない
モジュール最上位の new QueryClient()プロセスに一つしかないのでリクエスト同士でデータが混ざる

3番目が最も危険です。ブラウザではタブ一つにユーザー一人なのでグローバルなキャッシュが正しいのですが、サーバーは同じインスタンスが同時に複数のリクエストを処理します。商品Aのデータが商品Bのレスポンスに混ざって出ていきます。

After ① ルート定義をルーター生成から切り離す

 1// src/app/routes.tsx
 2import type { RouteObject } from 'react-router-dom';
 3
 4export const routes: RouteObject[] = [
 5  {
 6    element: <Root />,
 7    children: [
 8      { index: true, element: <Home /> },
 9      { path: 'products/:id', element: <ProductDetail /> },
10    ],
11  },
12];

ルーターをここで作らないことが要点です。ブラウザは createBrowserRouter、サーバーは createStaticHandler で互いに異なるルーターを作りますが、ルートの配列は同じでなければなりません。 異なると、サーバーが描いた画面とブラウザの初回レンダリングが食い違い、hydrationが壊れます。

After ② Providerの殻を共有する

 1// src/app/Providers.tsx
 2export function Providers({
 3  queryClient,
 4  children,
 5}: {
 6  queryClient: QueryClient;
 7  children: ReactNode;
 8}) {
 9  return (
10    <StrictMode>
11      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
12    </StrictMode>
13  );
14}
15
16/**
17 * A factory, not a module-level constant.
18 * The server creates one per request and throws it away.
19 */
20export function createQueryClient() {
21  return new QueryClient({
22    defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
23  });
24}

i18nを使っているなら、同じ理由でインスタンスを注入してもらう必要があります。グローバルなi18nの言語を変えると、同時に処理中の別の言語のリクエストがその値を一緒に見てしまいます。

After ③ ブラウザのエントリーポイント

 1// src/entry-client.tsx
 2import { createRoot, hydrateRoot } from 'react-dom/client';
 3import { hydrate, type DehydratedState } from '@tanstack/react-query';
 4import { Providers, createQueryClient } from '@/app/Providers';
 5import App from '@/app/App';
 6
 7const queryClient = createQueryClient();
 8
 9// Data the server rendered with. Absent on non-SSR routes and in dev.
10const ssrState = (window as { __RQ_STATE__?: DehydratedState }).__RQ_STATE__;
11if (ssrState) hydrate(queryClient, ssrState);
12
13const container = document.getElementById('root')!;
14const tree = (
15  <Providers queryClient={queryClient}>
16    <App />
17  </Providers>
18);
19
20/*
21  Adopt existing markup if there is any, otherwise render fresh.
22  hydrateRoot attaches events without repainting - no flash.
23  Hydrating an empty container makes React discard it and re-render everything.
24*/
25if (container.firstElementChild) {
26  hydrateRoot(container, tree);
27} else {
28  createRoot(container).render(tree);
29}

hydrateRoot は既存のマークアップを描き直さず、イベントだけを付けます。逆に空のコンテナにhydrateすると、Reactが不一致とみなして丸ごと描き直します。一つのエントリーポイントが3つの状況(SSRされた経路 / されていない経路 / 開発サーバー)をすべて引き受ける必要があるので、ここで分岐します。

After ④ サーバーのエントリーポイント

ここが核心です。

 1// src/entry-server.tsx
 2import { renderToReadableStream } from 'react-dom/server';
 3import {
 4  createStaticHandler,
 5  createStaticRouter,
 6  StaticRouterProvider,
 7} from 'react-router-dom';
 8import { dehydrate, type QueryClient } from '@tanstack/react-query';
 9import { Providers, createQueryClient } from '@/app/Providers';
10import { routes } from '@/app/routes';
11
12export type RenderResult = { html: string; state: string };
13
14export async function render(
15  url: string,
16  seed: (queryClient: QueryClient) => void,
17): Promise<RenderResult> {
18  const queryClient = createQueryClient();
19  seed(queryClient);
20
21  const handler = createStaticHandler(routes);
22  const context = await handler.query(new Request(url));
23  if (context instanceof Response) {
24    throw new Error(`unexpected Response: ${context.status}`);
25  }
26  const router = createStaticRouter(handler.dataRoutes, context);
27
28  const stream = await renderToReadableStream(
29    <Providers queryClient={queryClient}>
30      <StaticRouterProvider router={router} context={context} hydrate={false} />
31    </Providers>,
32  );
33
34  // Wait for React.lazy routes to resolve.
35  await stream.allReady;
36
37  return {
38    html: await new Response(stream).text(),
39    state: JSON.stringify(dehydrate(queryClient)),
40  };
41}

ここで知っておくべき4つのこと

**react-dom/server はReactにすでに入っています。** 別途のインストールはありません。react-dom パッケージのサブパスです。新しく導入するフレームワークもプラグインもありません。

Workersのようなウェブ標準ランタイムでは renderToReadableStream を使います — Nodeの renderToPipeableStream ではありません。どの版が当たるかは、後で出てくるViteの設定が決めます。

renderToString ではなくストリーミングレンダラーを使う理由。 ルートを React.lazy で遅延ロードしていると、renderToString はSuspenseのfallback(ローディングスピナー)だけを描いて終わります。ストリーミングレンダラーはlazyが解決されるまで待つことができます。

ただしストリーミングで流し込むことが目的ではありません。私たちが欲しいのはクローラーが一度に読める完成したHTMLなので、await stream.allReady ですべて終わるのを待ってから文字列として受け取ります。

hydrate={false}。StaticRouterProviderは基本的にloaderデータを入れた

dehydrate でデータを一緒に渡す。 — 次の節で詳しく。

ビルド — vite build --ssr

**Viteに内蔵された機能です。** プラグインは不要です。

1// package.json
2{
3  "scripts": {
4    "build": "vite build && vite build --ssr src/entry-server.tsx --outDir dist-server"
5  }
6}

同じソースから成果物が2つ出ます。

1dist/         browser bundle — unchanged
2dist-server/  server bundle — a single entry-server.js chunk

Viteの設定には2行必要です(SSR options)。

 1// vite.config.ts
 2export default defineConfig(({ isSsrBuild }) => ({
 3  ssr: {
 4    // Workers have no node_modules. Bundle every dependency in.
 5    // The default externalizes them, which fails at runtime with
 6    // "Cannot find package 'react'".
 7    noExternal: true,
 8    // Web-standard runtime, not Node. This is also what makes
 9    // react-dom/server resolve to the ReadableStream build.
10    target: 'webworker',
11  },
12  build: {
13    // The client build already copied public/. The server bundle does not need it.
14    copyPublicDir: !isSsrBuild,
15  },
16}));

noExternal: true が抜けると、デプロイした後に Cannot find package 'react' で落ちます。Workersには node_modules がないからです。

ビルド時間は1.7秒増えました。サーバーバンドルの1.7MBはWorkerにだけ上がり、ユーザーにはダウンロードされません。

Workerにつなぐ

1段階のWorkerに2行が増えます。

@@PLACEHOLDER_3@@

APIの往復を増やさないことが重要です

サーバーがデータを受け取って画面を描いたのに、ブラウザが立ち上がるなり同じAPIをまた呼ぶと、SSRで得たものをネットワークで返してしまいます。

解決は setQueryData + dehydrate の組み合わせです(TanStack Query SSRガイド)。

1// server: seed the cache directly - no fetch
2queryClient.setQueryData(getProductQueryKey(id), product);
1// browser: adopt that cache as-is
2if (window.__RQ_STATE__) hydrate(queryClient, window.__RQ_STATE__);

注意する点が2つあります。

クエリキーが両側で完全に同じでなければなりません。 一文字でも違うとキャッシュに当たらず、ブラウザが静かに再度呼びます。エラーが出ないので気づきにくいです。キーを作る関数を両側で共用するのが安全です。

staleTime がないとマウント直後にrefetchが走ります。

1useQuery({ ...options, staleTime: 60_000 });

この2つを揃えたところ、筆者の場合は詳細・ホームともにブラウザからの追加API呼び出しが0件になりました。SSRを付けながら、ネットワークの往復はむしろ減りました。


結果

同じURLを curl で受け取ったものです。

0段階1段階(メタ)2段階(プリレンダー)
<title>全ページ同一ページ別ページ別
OG・canonicalなし(JSのみ)ありあり
JSON-LDなしありあり
<div id="root"> の本文0文字0文字1,745文字
内部リンク0個0個6個
ブラウザからの追加API呼び出し1件1件0件

レンダリングはCPU 10msに収まるのか

2段階で最も気になる部分です。無料プランはリクエストあたりCPU 10msで、ここでAPIを待つ時間はカウントされません — 上限に当たるのは、Reactがツリーを描く間に実際に使ったCPUだけです。何が含まれ、どう測るのかは Cloudflare Workers 静的サイトホスティング - リクエストの流れと課金基準 にまとめてあります。

なので測るべきはレンダリング自体のコストです。データをあらかじめ渡しておき(=ネットワークなしで)レンダリングだけを繰り返してみました。

1cpu       wall
21回目   53.1ms    49.0ms    (cold start)
32回目    6.8ms     5.5ms
43回目    7.1ms     5.5ms
54回目   12.7ms     5.1ms
65回目   12.1ms     4.7ms

この数字をそのまま信じてはいけません。4・5回目を見ると、壁時計で5msかかった作業のCPUが12msです。Nodeの process.cpuUsage()すべてのスレッドのCPUを合算するためです(GCなど)。このワークロードにはI/Oがないので、実際のレンダリングコストはむしろ wall の方(3〜5ms)に近いです。さらにNodeと workerd はランタイムもGC圧も異なります。桁を見積もる用途にだけ使う値です。

デプロイ後に互いに異なるページ12件を連続でリクエストしたとき、CPU超過エラー(1102)は出ませんでした。ただしページの複雑度によって各自が確認すべき値です。推測せず、Workers Logsに記録される実際のCPU timeを見る方がよいです。

エッジキャッシュが当たればAPIの往復がなくなり、レスポンスは9.8msで終わります。これは壁時計の時間で、そのうちCPUはレンダリング分だけです。

hydrationが本当に合っているか確認する

「警告が出ないから合っているのだろう」は根拠が弱いです。プロダクションビルドのReactは不一致の警告を取り除き、React 19は食い違ったノードを静かに通すこともあります。実際にサーバーのHTMLに <i> を一つこっそり挟んでみたところ、コンソールには何も出ませんでした。

より確実な方法は2つの経路のDOMを直接突き合わせることです。

  1. デプロイされたそのままで開き、#rootinnerHTML を取る
  2. レスポンスからサーバーが入れたマークアップだけを消してブラウザだけで描かせてから、同じものを取る
  3. 2つを比較する

筆者の場合は1,118ノード対1,118ノードで構造が完全に一致しました。違いは style="top:var(--x)"style="top: var(--x);" のように、ブラウザがCSSOMを経て再シリアライズした表記だけでした。


踏んだ罠

ソース表示が一行で出る

Reactのサーバーレンダラーにはインデントのオプションがありません。インストールされた react-dom から renderToReadableStream が読むオプションをすべて洗い出しても、フォーマット関連の項目は一つもなく、developmentバンドルとproductionバンドルのオプション一覧も同じです — デバッグ用のスイッチが隠れているわけでもありません。

これは怠慢ではなくそうできないからです。インデント用の空白は本物のテキストノードになってhydrationの突き合わせを壊し、インライン要素の間の空白は実際に一マスとしてレンダリングされます。

読みたければ受け取る側で広げればよいです。

1curl -s https://example.com/page | npx prettier --parser html | less

モジュール最上位でブラウザのグローバルを触るコード

エフェクト(useEffect)の中にあるものはサーバーで動かないので安全です。問題はモジュールスコープです。サーバーバンドルを読み込んだ瞬間に落ちます。

1grep -rnE "^(const|let|export const) .*(window|document|navigator|localStorage)" src

ステージングがプロダクションと重複文書になる

SSRを付けるとステージングがプロダクションと同じ内容を持つ完全なサイトになります。以前は本文がなかったのでインデックスされても被害が小さかったのですが、今は互いに競合します。

1// Anything but production is excluded. Missing value fails closed.
2if (env.APP_ENV !== 'production') {
3  response.headers.set('X-Robots-Tag', 'noindex, nofollow');
4}

robots.txtDisallow でブロックしてはいけません。クロール自体が起きないので noindex を読む機会がなく、すでにインデックスされたURLはそのまま残ります。クロールは開き、インデックスだけをブロックします。

キャッシュを必ず一緒に設計する

SSRはリクエストのたびにAPIを呼ばせます。 エッジキャッシュをかけなければ、オリジンの負荷がページビューの分だけ増えます。1段階ではメタを一つ付けるために呼ぶものだったので見過ごせましたが、2段階ではそうはいきません。

かける方法と罠(データセンターごとにキャッシュが別であること、Tiered Cache、cache.put() との衝突)は Cloudflare Workers キャッシュ設定方法 - エッジキャッシュとTiered Cache にまとめてあります。


どこまでやるか

すべてをプリレンダーする必要はありません。

経路段階理由
詳細2段階クローラーが読む内容がある。ページ数も多い
ホーム2段階クローラーが最初に来る場所であり、内部リンクの出発点である
検索・一覧1段階結果がユーザー入力に左右される。キャッシュも当たらない
規約・ポリシー1段階または noindexインデックスの対象ではない

1段階で止めても大丈夫です。 作業量に対する効果が大きく、リンクプレビューと検索結果のタイトルはそれだけで解決します。

2段階は「クローラーが読む本文が実際にあるページ」にだけ価値があります。そして内部リンク — 詳細同士をつなぐリンクがHTMLになければ、クローラーにとってはすべてのページが孤立した島だという点は、一度確認してみる価値があります。


まとめ

  • GoogleはJSをレンダリングします。 しかしキューに入り、Googleのドキュメント自身が「すべてのボットが実行できるわけではない」としてSSRを勧めています。
  • 1段階:Workers + HTMLRewriter でメタ・OG・JSON-LDを埋めます。作業量が少なく効果が明確です。
  • 2段階entry-client / entry-server でエントリーポイントを分け、本文までサーバーで描きます。
  • react-dom/serverReactにすでに入っておりvite build --ssrViteにすでに入っています。 新しく導入するフレームワークはありません。
  • サーバーが受け取ったデータを dehydrate で渡せば、APIの往復は増えません。
  • バックエンドサーバーを立てる必要はありません。 すでにCloudflareに静的サイトを載せているなら、追加インフラは0で、無料プランで始められます。

参考

Cloudflare

React · Vite · ライブラリ