
はじめに
認証必須のSPA(Single Page Application)を運用していると、意外な落とし穴に気づくことがあります。
ログイン不要な公開ページ、たとえばトップページやヘルプ、料金表といったページまで、JSが実行されて初めて本文が描画される構成になっているケースです。クローラやリンクプレビュー、あるいはJSを十分に実行しない環境からアクセスされると、head(title・OGP・canonical)だけを整えても、肝心の本文(h1・主要な説明文・CTA・FAQなど)が空のまま届いてしまいます。
この記事では、「認証後のアプリ全体をSSR化するのではなく、indexableな公開ルートだけをビルド時に静的HTML化する」という、限定的なプリレンダリング戦略を紹介します。head用のメタデータとbody用の本文を同じデータソースから生成することで内容の乖離を防ぎつつ、リクエスト時SSRのようにcold startや障害点を増やさずに済む設計です。
こんな人におすすめ
- SPA構成のアプリで、公開ページのSEOやOGP表示が思ったように機能していない方
- 認証必須アプリの一部だけを検索エンジンにインデックスさせたい方
- アプリ全体をSSR化するほどのコストはかけたくないが、クロール対応はしたい方
- Reactのhydrationまわりでエラーに悩まされた経験がある方
- E2Eテストとビルド成果物の依存関係で困ったことがある方
SSR化ではなく「限定的な静的プリレンダリング」を選ぶ
公開コンテンツの多くが静的な内容であれば、リクエストのたびにサーバーサイドレンダリングを行う必要は本来ありません。
リクエスト時SSRは、cold startのレイテンシやコンピュートコスト、そして障害点を増やすだけになりがちです。それよりも、ビルド時にあらかじめ静的HTMLを生成しておき、配信側は「生成済みファイルをそのまま返すだけ」にする方が、構成としてはるかにシンプルで壊れにくくなります。
対象を「認証不要かつindexableな公開ルート」だけに絞ることで、アプリ全体のアーキテクチャを変えずに、SEO上重要な部分だけをピンポイントで解決できます。
head用メタデータとbody本文を単一ソースから生成する
このアプローチでハマりやすいのが、head(title・description・canonical・OGP・JSON-LD)と、body側の本文コンテンツを別々のロジックで組み立ててしまうことです。
別々に実装すると、たとえば「canonicalの内容と実際の本文がズレている」といった不整合が簡単に発生します。これを避けるため、両方を同じデータ定義から導出する設計にしています。
生成側と配信側でアセットパスの導出ロジックを共有する
もう一つの落とし穴が、プリレンダリングスクリプト(ファイル生成側)と配信ルーティング(Workerなど)が、それぞれ独自にファイル名を組み立ててしまうパターンです。
リネームや仕様変更が入ったときにこの2つがズレると、404が発生します。「pathnameからアセットキーを導出する関数」を1つだけ用意し、生成側・配信側の両方でimportして使うことで、キーの乖離を構造的に防げます。
// 生成側・配信側で共有する単一の関数
export function prerenderedAssetPath(pathname: string): string {
if (pathname === '/') return '/prerendered/index.html'
const key = pathname.replace(/^\//, '').replace(/\//g, '_')
return `/prerendered/${key}.html`
}
createRootのままにして、hydrateしない
プリレンダリングしたHTMLに対して、そのままReactのhydrateRootをぶつけると、hydration errorやDOM不一致のリスクが出てきます。
そこで、静的HTMLは「JSが無効な環境のためだけの表示」と割り切ります。JSが有効な環境では、通常のcreateRootでSPAとして丸ごと描き直す設計にすることで、hydrationまわりの複雑さそのものを避けることができます。
配信側は生成物の有無でフォールバックする
ローカル開発サーバのように、ビルド成果物がそもそも存在しない環境も考慮する必要があります。
配信側では、静的生成済みのHTMLをまず優先して取得し、生成物であることを示すマーカーが含まれていない場合は、SPAのfallback shellが返ってきたとみなして、従来の動的head書き換え処理へフォールバックします。
const prerendered = await env.ASSETS.fetch(
new Request(`${url.origin}${prerenderedAssetPath(url.pathname)}`, {
method: request.method,
headers: request.headers,
}),
)
if (prerendered.status === 200 && isHtmlResponse(prerendered)) {
const prerenderedText = await prerendered.text()
// 生成物であることを示すマーカーが無ければ SPA fallback shell とみなし、下段の処理へ進む
if (prerenderedText.includes('<!-- prerendered -->')) {
const headers = new Headers(prerendered.headers)
headers.set('Cache-Control', url.pathname === '/' ? 'no-cache, must-revalidate' : 'public, max-age=300')
return new Response(prerenderedText, { status: 200, headers })
}
}
// フォールバック: 従来の head 書き換え処理へ続く
「本番では静的HTML優先、無ければ動的書き換え」という二段構えにすることで、開発体験を壊さずに導入できる点がポイントです。
つまづきやすいポイント
- E2Eテストの実行環境制約: 静的成果物はビルド後にしか存在しないため、Dev Serverベースの通常のE2Eレーンでは本文が空のSPA shellしか手に入らず、テストが原理的に通りません。JS無効時の可読性を検証したいなら、ビルド+プレビューサーバ専用のテストレーン(専用のPlaywright project)を別途用意する必要があります
export const PRERENDER_SPEC_GLOB = '**/public-prerender.spec.ts'
// ...
{
name: 'chromium-prerender',
use: { ...devices['Desktop Chrome'], baseURL: baseURLPrerender },
testMatch: [PRERENDER_SPEC_GLOB],
},
- 生成物とソースデータのdrift: 静的生成後にソースデータ(価格やFAQの内容など)が変更されても、生成物の再生成を忘れると古い内容のまま配信され続けてしまいます。ビルド成果物に「期待する文字列が含まれているか」を機械的に検査するdriftチェックスクリプトをCIに組み込んでおくと、乖離を早期に検出できます
- 対象ルートの絞り込み: 最初から全ページを対象にせず、トップやヘルプ、料金表など、明らかにindexableな公開ルートだけに限定して始めた方が、設計をシンプルに保てます
まとめ
認証必須SPAの公開ページ問題は、アプリ全体をSSR化しなくても、「対象を絞ったビルド時プリレンダリング」で解決できる場合があります。
head用メタデータと本文を単一ソースから生成し、アセットパスの導出ロジックを生成側・配信側で共有し、hydrateせずcreateRootで描き直す。この3点を押さえることで、複雑さを最小限に抑えながらcrawlability を改善できます。
E2Eテストのレーン分離と、生成物のdriftチェックをCIに組み込んでおくことも、運用を安定させる上で欠かせないポイントです。


