SSR (Server-Side Rendering)
サーバーサイドレンダリング (SSR) とは、サーバー側で HTML コンテンツを生成し、クライアントに送信する手法を指します。 SSR は、 クライアントサイドレンダリング (クライアントが JavaScript を使用して HTML コンテンツを生成する)とは対照的です。両方の手法は相互に排他的ではなく、同じアプリケーション内で併用できます。
MDN Web Docs より
サーバーサイドレンダリング (SSR) とは、サーバー側で HTML コンテンツを生成し、クライアントに送信する手法を指します。 SSR は、 クライアントサイドレンダリング (クライアントが JavaScript を使用して HTML コンテンツを生成する)とは対照的です。両方の手法は相互に排他的ではなく、同じアプリケーション内で併用できます。
静的サイトは、 SSR と見なすことができ( SSR インフラストラクチャを使用して生成することもできます)、微妙な違いがあります。静的サイトのコンテンツは、リクエスト時ではなくビルド時に生成されます。静的サイトは 1 つのサーバー上ですべてを配信する必要がなく、 CDN から配信できます。
SSR / CSR の区別は、リアルタイム更新やユーザー固有のコンテンツなど、動的なコンテンツを持つサイトでより意味を持ちます。このような場合、サーバーはリクエストごとに HTML コンテンツをオンザフライで生成します。これは、ありうるページすべてを事前に生成することは現実的ではないためです。HTML ファイルにはほぼ完全なページコンテンツが含まれており、 JavaScript アセットはインタラクティブ機能を実現するためのみに使用されます。
SSR の利点は次のとおりです。
- アクセシビリティ: JavaScript なしでも(ある程度)利用可能です。例えば、インターネットの速度が遅い場合、ユーザーが JavaScript を無効にしている場合、またはブラウザーが古く JavaScript が実行されない場合などです。ただし、インタラクティブ機能やクライアントサイドのロジックは動作しません。
- クローラーフレンドリー: 検索エンジン、ソーシャルメディアクローラー、その他のボットは、 JavaScript を実行せずにコンテンツを簡単に読み取ることができます。主要な検索エンジンは JavaScript を実行できるため、純粋な CSR サイトはインデックスできますが、ソーシャルメディアクローラーは通常 JavaScript を実行できないことに注意してください。
- パフォーマンス: サーバーは必要なコンテンツを事前に把握でき、必要なデータをすべて一度に取得できます。一方、 CSR では、クライアントは最初のページをレンダリングするときにのみ追加の依存関係を認識することが多く、リクエストのウォーターフォールが発生します。
SSR と CSR はどちらもパフォーマンス面でトレードオフがあり、 SSR と CSR を組み合わせることで両方の手法の利点を活かすことができます。例えば、サーバーは空のプレースホルダーを含むページのスケルトンを生成し、クライアントは必要に応じて追加データを取得してページを更新することができます。
先に押さえる指標: FCP / LCP / TTI
この後のスイムレーン図には FCP / LCP / TTI のマイルストーンが登場する。それぞれが「ユーザー体験のどの瞬間」を測っているのかを先に整理しておくと、図の中で『なぜここが重要なのか』が腹落ちしやすい。
- FCP First Contentful Paint — 最初の視覚コンテンツ描画
- テキスト・画像・SVG・非空の Canvas のような「視覚的なコンテンツ」がブラウザ画面に最初に描画された時点。背景色のみの画面は含まれない。Core Web Vitals の補助指標で、1.8 秒以内が良好とされる。
- LCP Largest Contentful Paint — 最大コンテンツ描画
- ページ内で最大のコンテンツ要素 (ヒーロー画像・主要見出し等) が描画された時点。「意味のある初回描画」の代理指標で、Core Web Vitals の主指標。2.5 秒以内が良好、4 秒以上は要改善。
- TTI Time to Interactive — 操作可能になった時刻
- ページが完全にインタラクティブ (クリックや入力に反応できる) になった時点。全てのイベントリスナーが付与され、main thread が 5 秒間 Long Task を出さなくなるタイミング。2024 年に Core Web Vitals 主指標から外れ INP に置き換わったが、実ユーザーの「使えるようになる時刻」として依然重要。
レンダリングライフサイクル
SSR の 17 フェーズを 8 レーンに分解。サーバー側の renderToString (04–06) → ネットワーク転送 → Browser / Compositor / GPU / Display のパイプラインで即座にピクセル表示 (12)。FCP / LCP はこのピクセル表示時点で達成される。その後にハイドレーション (15–16) が走って TTI に到達、という二段構造が見える。
-
01 URL 入力 / リンククリック
ユーザーが URL をアドレスバーに入力する、もしくは既存ページのリンクをクリック。
-
02 HTTP リクエスト発出
ブラウザが認証クッキー・条件付き GET ヘッダ等を付けてリクエストを準備。
-
03 DNS / TCP / TLS
ネットワーク層で DNS 解決 → TCP 接続 → TLS ハンドシェイクが順次走る。
-
04 renderToString() 開始
Node.js 上で React / Vue / Svelte などが
renderToString()を呼び出す。レンダリング関数が実行され、コンポーネントツリーの構築が始まる。 -
05 DB / API アクセス
レンダリング途中で
getServerSideProps/loader/ Server Component の fetch 等が呼ばれ、DB や外部 API からデータを取得。この時間が TTFB を直接支配する。 -
06 HTML 文字列 + 埋め込み state 生成
取得したデータを元に HTML 文字列を完成させ、ハイドレーション用の state (
__NEXT_DATA__/window.__NUXT__等) を<script>に埋め込む。これがクライアントに渡す「完成品 + 再構築用の情報」。 -
07 完成 HTML を転送
サーバーが HTTP レスポンスとして完成 HTML を返す。ストリーミング SSR の場合はここからチャンクごとに流れ始める。
-
08 HTML パース → DOM (完成形)
ブラウザが届いた HTML をパースして DOM を構築。SPA と違い、このツリーはコンテンツを含む完成形。
-
09 Style / Layout / Paint-record (コンテンツ込み)
CSSOM と DOM を合成して Style 計算 → Layout → Paint-record を実行。今回の DOM にはすでに実コンテンツが詰まっているため、生成される描画コマンドも豊富。SPA の初回と決定的に違う点。
-
10 Raster + レイヤー合成
コンポジタースレッドが Paint-record をタイルに分割してラスタライズし、レイヤーツリーから合成コマンドを組み立てる。
-
11 GPU でフレーム生成
GPU プロセスが合成コマンドを実行し、完成したフレームをフレームバッファに書き込む。
-
12 コンテンツが画面に表示
vsync に同期して画面に出力。ユーザーの目にコンテンツが届くのはこの瞬間。SSR の真骨頂で、SPA と違い「初回フレーム = 空 + 2 回目で内容」ではなく「初回フレームでいきなり内容が出る」。ただしまだ JS は動いていないので操作はできない。
- FCP / LCP 到達 — ただしまだ操作できない
-
13 JS バンドル転送 (並行)
HTML 転送と並行、あるいは直後に JS バンドルがダウンロードされる。SSR だからといって JS が不要になるわけではない — ハイドレーションに同じコンポーネントコードが必要。
-
14 JS パース / コンパイル
V8 等の JS エンジンがソースをパースしバイトコードにコンパイル。メインスレッドが止まる時間がここから始まる。
-
15 hydrateRoot() → VDOM 再構築
React なら
hydrateRoot()が呼ばれる。仮想 DOM を再構築しつつ、既存の実 DOM と 1 ノードずつ突き合わせ (reconciliation)。マークアップが一致すれば DOM は再生成されない。 -
16 イベントリスナー付与 / state 初期化
各コンポーネントにイベントリスナーが付与され、useState / ストアが初期化される。useEffect / onMounted もこの後発火する。ハイドレーションは原則として既存 DOM を再利用する設計なので、通常は Browser → Compositor → GPU → Display のフルパイプラインを再度走らせる必要はない (ハイドレーション不一致が起きたサブツリーだけは再レンダリング + 再描画になる)。
- TTI 到達 — uncanny valley (見えるのに押せない) が終わる瞬間
-
17 以降: SPA 相当の挙動
ハイドレーション後のナビゲーションや状態変更は SPA と同じ仕組みで処理される。JS Engine で状態変更 → DOM パッチ → Browser → Compositor → GPU → Display のフルパイプラインが毎回走るが、ネットワーク往復は API データだけ。
ハイドレーションの実体
ハイドレーションは DOM を作り直しているのではなく、「既にある DOM を再利用して、そこに JavaScript 側の “動作” を後付けする」プロセス。前後で変わるもの・変わらないものを項目ごとに見ると理解しやすい。
| 項目 | ハイドレーション前 | ハイドレーション後 |
|---|---|---|
| DOM | サーバーが生成した完成形がすでに存在 | ハイドレーション中は原則として再生成しない (差分があれば警告 + 上書き) |
| イベントリスナー | ゼロ (クリックは無反応) | 各コンポーネントに付与される |
| state / props | 未初期化 (サーバー side の値が HTML に焼き付いているだけ) | クライアントランタイムで再初期化される |
| useEffect / onMounted | 未実行 | ハイドレーション完了後に実行 |
| ブラウザ API (window / localStorage) | SSR 中はサーバーに window が無いのでアクセス不可 | ハイドレーション後は安全にアクセス可能 |
ここから導かれる重要な原則: 「サーバーとクライアントで同じマークアップを生成する」ことがハイドレーションの前提。ここが崩れると警告 / 再生成が発生する (hydration mismatch)。
よくある疑問
SSR を学習する過程で必ず詰まる論点と、その根本原因。
Q. 「ハイドレーション」って結局何をしているの?
Q. FCP は速いのに TTI が遅いのはなぜ?
Q. `window` や `localStorage` を使うとサーバーでクラッシュするのはなぜ?
window や document が存在しないから (ブラウザ環境限定の API)。renderToString() 実行中にこれらを参照すると ReferenceError: window is not defined でクラッシュする。対策: (a) typeof window !== 'undefined' でガード、(b) useEffect / onMounted 内に処理を移す (これらはクライアントでしか実行されない)、(c) dynamic import で SSR 時に対象モジュールを読み込まない ({ ssr: false } オプション)。Q. hydration mismatch の警告が止まらない。原因は?
Date.now() / Math.random() を描画に使う、(b) ユーザーエージェントで分岐、(c) タイムゾーンで表示が変わる、(d) ライブラリが SSR 中とクライアントで異なるマークアップを出す。解決の基本戦略は「不確定な値を使う処理は useEffect に逃がして初回レンダリングでは固定値を使う」。それでも直らない場合は警告の差分箇所を読んで、どの要素が食い違っているか特定する。Q. SSR なのにログイン情報が他のユーザーに見える? 何が危ない?
renderToString() はリクエストごとに呼ばれるが、Node.js プロセスは使い回されるため、グローバル変数やモジュールスコープの変数にログイン情報を入れると次のリクエストで別ユーザーから見える。対策: (a) 認証情報はサーバー関数の引数 (context / request) で渡す、(b) React の Context や Vue の provide/inject をレンダリング関数内で初期化する、(c) グローバル state は絶対に使わない。フレームワーク側の仕組み (Next.js の cookies()、Nuxt の useRequestHeaders()) を素直に使うのが一番安全。Q. SSR と「ストリーミング SSR」はどう違う?
<Suspense> ドキュメントが説明している通り、Suspense 境界で囲まれた遅いコンポーネントはフォールバックを表示しつつ、データが揃った段階で実コンテンツに差し替わる。React 18 の renderToPipeableStream がサーバー側 API の起点。トレードオフとして、バックエンドがストリーミング対応の HTTP インフラを必要とする。設計判断のサイン
SSR を選ぶべきケースと、選んではいけないケース。
認証済み & 個別内容 & SEO 必須
ログインユーザーごとに異なる内容を返しつつ、その内容をクローラーに読ませたい (例: EC のパーソナライズされた商品一覧、ユーザー固有のダッシュボード)。SSG は使えず、CSR-only な SPA では SEO が弱い。SSR の出番。
コンテンツ更新が頻繁でビルドが追いつかない
在庫・価格・コメントのようにリアルタイム性が求められるデータを毎リクエストで最新化したい。SSG でも ISR で近いことはできるが、秒オーダーの鮮度が必要なら SSR が素直。
ほぼ静的で、更新頻度が低い
マーケティングサイト、ドキュメント、ブログ、企業サイト。これらで SSR を選ぶとサーバーコストと運用複雑さだけ増える。SSG で十分。
サーバーコストを最小化したい / トラフィック変動が激しい
SSR はリクエスト数 × 処理時間が直接コストになる。スタートアップのように流量が読めないサービスは、SSG or Edge Function のほうがコストが予測しやすい。Edge SSR (Vercel / Cloudflare) で部分的に SSR を使う選択もある。
よくある落とし穴
ハイドレーションミスマッチ
サーバーとクライアントで DOM が食い違う状況。典型例: Date.now() をそのまま使う、UA 分岐、useLayoutEffect の副作用。React 公式 hydrateRoot のトラブルシューティングでは「意図的にサーバーとクライアントで異なる内容をレンダリングする必要がある場合は、isClient のような state 変数を立てて effect 内で true にセットする two-pass rendering を使う」というパターンが案内されている (= 不確定な値を effect 経由で適用する)。suppressHydrationWarning でピンポイント抑制する選択肢もあるが、まずは差異の発生源を特定して effect 経由のレンダリングに分離する手順が公式の入口。
認証情報のグローバル漏洩
Node.js プロセスがリクエスト間で共有されるため、グローバル変数やシングルトンにユーザー情報を入れると別ユーザーに露出する。必ずリクエストスコープの context オブジェクトで受け渡すこと。
CDN キャッシュと個別データの衝突
SSR レスポンスを Cache-Control: public でキャッシュすると、ログイン中ユーザー A の HTML がユーザー B に配信されてしまう。個別データを含むページは Cache-Control: private / no-store、ユーザー固有要素は CSR で別 fetch に切り出す。
TTFB のサイレント劣化
renderToString 内で重いクエリや N+1 を仕込むと TTFB が一気に悪化するが、FCP や LCP ほど直接的に可視化されないため見逃しやすい。対策: ページ単位のレンダリング時間を APM (Datadog / New Relic) で計測、ストリーミング SSR や <Suspense> で遅い部分を分離。