

1/1/1970
2日でSSGを実装した方法
やあ! 1年前のぼくなら「そんなの無理でしょ」と思っていました。けどついさっき、Foony 用の Static Site Generation (SSG) を2日で実装し終えて、かなりワクワクしています。
Foony の SSG に挑戦するのはこれが初めてじゃありません。これまでにも NextJS、Vike、Astro、Gatsby などいろいろ試してきました。NextJS では一度本格的に手を付けたこともあるんですが、Foony の SPA の複雑さと何千というファイル数のせいで、すぐに壁にぶつかりました。あのまま行っていたら地獄のような大移行になって、数か月は溶けていたと思います。さらに、チーム全員が NextJS とそのクセを一から覚えないといけなくて、サイトの開発に関わる人たちにとっても大きな負担になるところでした。
ぼくが欲しかったのは、軽くて実装が簡単なもの。今までとほぼ同じ感覚でコードを書き続けられて、SSG のことをほとんど意識しなくていい仕組みです(useMediaQuery だけはさすがに例外ですが、ここはもう仕方ない)。このあとで、なぜフルスクラッチの解決策を選んだのか、どんな問題にぶつかったのか(特に React の Suspense 境界まわり)、そしてそれをどう解決したのかを順番に書いていきます。
既存の標準的な解決策を使わなかった理由
最初に Foony に SSG を入れようと考えたとき、まず真っ先に NextJS(事実上の標準)、Vike、Astro を検討しました。
NextJS: 移行コストが大きすぎる
NextJS は強力です。でも Foony の既存の React SPA を NextJS に載せ替えるには、とんでもない大移行が必要でした。うちは何千というファイルがあって、ルーティングもかなり複雑、しかも自前のインフラも多いです。もし NextJS に移行するとしたら:
- ルーティングシステムをすべて書き直し
- ゲームやコンポーネントの読み込み方を全面的に変更
- 元の機能レベルに追いつくまでに何か月もかかる
- ユーザーにとっての破壊的変更が発生する可能性
- 画像まわりの扱いを変える必要がある
- ビルド時間の大幅な悪化(5〜30分かかる可能性あり。これはこの5年前の GitHub ディスカッションくらいしか根拠がありません)
- チーム全員が新しいもの(NextJS)を覚え続けなきゃいけなくて、開発速度が恒常的に落ちる
- NextJS が破壊的変更を出すたびに、こちらもコードの移行作業をする羽目になる
実際に NextJS で一度やりかけたんですが、すぐに「これ、コストに全然見合わないな」と悟りました。複雑さに対して得られるものが少なすぎる。
Vike: 同じような複雑さ
Vike(旧 vite-plugin-ssr)も検討しました。NextJS より柔軟ではあるんですが、それでもコードベースの大きな組み替えが必要でした。学習コストと移行の手間を考えると、得られるメリットに対して重すぎました。
Astro: アーキテクチャが合わない
Astro はコンテンツ主体のサイトにはすごく向いています。でも Foony は複雑なマルチプレイヤーゲームのプラットフォームです。リアルタイムの更新、WebSocket 接続、ダイナミックな React コンポーネントが必要です。Astro の設計は、ぼくらがやりたいことにはフィットしませんでした。
解決策: フルスクラッチの SSG
数日前に i18n のあとに実装した「なんちゃって SSG(fake SSG)」でいい感じの手応えを得られたので、最終的に Foony 用の SSG は、小さくて軽いフルスクラッチの実装に落ち着きました。
「なんちゃって SSG」は、ブログ記事があるページ(
/postsのルートやゲームページ)から記事コンテンツだけを引き抜いて、クライアントが本来レンダリングする位置にサーバー側であらかじめ差し込むやり方です。検索エンジンや LLM に Foony をきちんと理解してもらうためのもので、ld+json のスキーマやちょっとした SEO も同時に適用していました。
今回のアプローチはシンプルです:
- 既存の React SPA の上に載せる: 既存コードの大規模な移行は不要。ビルド時に SSG 用の生成処理を足すだけ。
renderToReadableStreamを使う: React 18 のストリーミング SSR API で Suspense をネイティブに扱う。- 静的 HTML を生成する: ビルド時にルートをあらかじめレンダリングして静的ファイルとして配信。ルート一覧には SitemapGenerator を利用。
- 既存コードの変更は最小限: ほとんどのコンポーネントはそのまま動く。
実装のコアは client/src/generators/GenerateShellSsgFromSitemap.ts にあります。ここでサイトマップを読み込み、各ルートを React の renderToReadableStream でレンダリングして、静的 HTML ファイルとして書き出しています。とてもシンプルで、ぼく好みの形です。
しかもけっこう速いです。約 2,800 ルートを 10 秒でレンダリングできました。いい感じ。NextJS、Gatsby、Astro よりかなり速いです。 <img alt="SSG console log showing time taken" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
シンプルさについてはいくらでも語れます。大企業だと「複雑さが足りない」と言われて昇進にはつながらないかもしれないけど、シンプルなコードは美しくて、保守しやすくて、開発速度という意味でもだいたいの場合ベストです。禅の思想のこういうところがすごく好きです。
Suspense 境界の問題
これで SSG が動いて、HTML にちゃんとコンテンツも出るようになりました。なのにブラウザで見るとページが真っ白。えっ、なんで? <img alt="SSG blank page" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
調べてみると、renderToReadableStream は await stream.allReady を呼んだあとでも、それでも Suspense の境界が残ることが分かりました。おそらく「ストリーム」であることが前提で、バイトを受け取ったそばからクライアントに流す設計だからだと思います。
React が出力するもの
renderToReadableStream を Suspense と一緒に使うと、React はだいたいこんな HTML を出してきます:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>
<template id="B:0"> がコンテンツが入るべき場所のプレースホルダで、<div hidden id="S:0"> の中に本当の中身が入っています。B:0 と S:0 は 0 から始まるインデックスで対応しています。
JavaScript が動かない環境だと、検索エンジン(特に君のことだよ、Bing)や LLM から見えるのは、このプレースホルダの template くらいで、ほぼ空っぽのページです。これだと SSG をやる意味が完全になくなります。
この Suspense 境界をきれいに取り除く方法は見当たらなかったので、テストを書いてから resolveSuspenseBoundaries という関数を作って、自前で差し替えることにしました。HTML をパースしてスクリプトを実行する(JSDOM みたいなものを使う)よりもずっと速いし、何よりぼくがやりたかった「検索エンジン / LLM 向けに JavaScript なしでも読みやすいサイトを提供しつつ、クライアント側では Suspense とハイドレーションをちゃんと動かす」という要件にピッタリでした。
変換処理のテスト
最初にやったのは、変換のテストを書くことでした。JavaScript オフの状態で実際の DOM を見て、そこから「こうなっていてほしい」という JavaScript 有効時の DOM を用意して、そのペアを LLM に投げてテストケースを量産してもらいました。こういう「入力と期待する出力」がはっきりしているケースは、LLM が得意なところです。
テストは client/src/generators/ssr/renderRoute.test.ts にあり、変換処理が正しく動いているか確認しています。カバーしているのは:
- シンプルな境界の差し替え(ブログ一覧)
- template と閉じコメントの間にコンテンツが挟まっている複雑なケース
- 複数の境界があるケース
- コメントマーカーのない境界
- いろいろな端っこのケース
こういう、期待される入力と出力がはっきりしている処理には、ある種の「TDD」がけっこう役に立ちます。
これは「Robert C. Martin が言ってたから全部 TDD で書こう」というノリとは別物です(それをやるとチームの開発速度は確実に落ちます)。UI や、頻繁に変わる領域に TDD を持ち込むのはやめたほうがいいです!
解決策: resolveSuspenseBoundaries
テストが揃ったところで、LLM に resolveSuspenseBoundaries 関数の実装を書いてもらいました。正規表現だけで頑張ると壊れやすくなりそうだったので、ここでは cheerio を採用しました。RegEx だけを使えば SSG 時間が 40% くらい短くなりそうではあるんですが、信頼性を優先しました。
export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
const originalHtml = html;
const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];
// Collect hidden divs with their content and positions.
const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
$('div[hidden][id^="S:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const content = $(el).html() || '';
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset === 'number' && typeof endOffset === 'number') {
hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
}
});
if (hiddenDivs.size === 0) {
return {html: originalHtml, didResolveSuspense: false};
}
// Find templates (B:0) and replace them with the matching hidden content (S:0),
// following React’s internal $RV behavior.
$('template[id^="B:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const divInfo = hiddenDivs.get(boundaryId);
if (!divInfo) {
return;
}
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
return;
}
const templateIndex = startOffset;
const templateLength = endOffset - startOffset;
const afterTemplate = originalHtml.substring(templateIndex + templateLength);
const closingCommentMatch = afterTemplate.match(/<!--\/[amp;]-->/);
const removeEndIndex = closingCommentMatch
? templateIndex + templateLength + closingCommentMatch.index!
: templateIndex + templateLength;
const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1;
const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex);
const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex);
operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex});
operations.push({index: templateIndex, removeLength: 0, insertText: divContent});
operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex});
operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex});
operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex});
});
operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength));
let resultHtml = originalHtml;
for (const operation of operations) {
resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength);
}
return {html: resultHtml, didResolveSuspense: true};
}
これで、ほぼ空っぽのページではなく、検索エンジンや LLM から見ても、ちゃんとレンダリングされたページが見えるようになりました。
これで JavaScript なしでも SSG がいい感じに動きます。
<img alt="No JavaScript SSG for Foony's blogs" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
長い目で見ると、React 側で Suspense のフォーマットを変えてくる可能性はあります。その場合、遅延ロードされるページ(Suspense 境界が必須)をもっときれいに扱えるようになったタイミングで、この Suspense 解決コードは丸ごと消すかもしれません。
ハイドレーション戦略(追記: 3日 + さらに1日かかった)
ハイドレーションが難しいのは分かっていました。でも、ちょっと頑張ったらちゃんと動くところまで持っていけました。
ハイドレーションにかかった時間の合計: 3日 + 結局デハイドレーション方式を差し替えるのにさらに 1日。
いちばんキツかったのは、最初の「とりあえず動く最小限のハイドレーション」を通すところです。ナビバー付きで "Hello World" を出すところまでいけた瞬間、「あ、これならさすがに1か月案件にはならないな」と自信が持てました。
<img alt="Foony's Hello World hydrating successfully with navbar" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
その最初のハイドレーション MVP では、ちょっと変わった希望がありました: ハイドレーションは欲しいけど、開発者には Suspense 境界のことをできるだけ意識させずに、検索エンジンと LLM に対してはちゃんと SEO の効いたページを出したい、という欲張り仕様です。
何が難しいのか
React のハイドレーションはものすごく「字面どおり」です。最初のレンダリングで React が期待する DOM と、実際の DOM が少しでも違うと、コンソールに分かりづらいエラーメッセージが出て、React は全部投げ捨てて最初から描き直します。どこが違ったのかを教えてくれる diff すらありません。
SSG を入れると、この問題はさらに悪化します:
- React 18 のストリーミング用 Suspense の残骸を、HTML のポストプロセスで潰している(これはボット向けには最高)。
- クライアント側の「時刻 t = 0」で、サーバーレンダリング時と同じデータが揃っているとは限らない(SSG 用のデータやブログのメタデータなど)。
- i18n はデフォルトだと「遅延ロード」なので、どの翻訳キーが SSG で使われたのかを記録しておいて、それを React が描画を始める前に注入しないと、最初のレンダリングで翻訳が足りないことがある。
うまくいった方法(最初のアプローチ: デハイドレーション)
最初にぼくが試したのは、ちょっとだけ賢くて、ちょっとだけかわいいやり方でした。HTML の Suspense 境界を解決するときに使ったコマンドを記録しておいて、あとからその逆変換コマンドを実行すれば、React がハイドレーションで期待しているマークアップを復元できる、という作戦です。
この「コマンド方式」を使えば、index.html に含めるバイト数をかなり減らせるんじゃないかと期待していました。でも、だいたいいつものパターンで、そういう「ちょっと賢い」解決策はブラウザの微妙な挙動に負けます。ブラウザが勝手に ; や / を付けたり消したりしてしまうので、記録していたインデックスがずれてしまうんです。
がんばればブラウザによるこういう微妙な変化を全部吸収できるかもしれません。でも、そんな壊れやすいものを本番に出す気にはなれませんでした。
そこで、Suspense 境界の変換を「逆戻し」しようとする代わりに、もっとずっとシンプルなやり方を取りました:
元の、React が出したままの HTML を <script type="text"> の中に一緒に入れておく。
この「デハイドレーション」方式はちゃんと動きました。ただ、その後もう1日かけて、さらにいいやり方に差し替えました。
より良い方法: クリティカルパスの Suspense 境界置き換え
最初の実装が終わったあとも、Suspense 境界まわりでちょくちょく問題が出ていました。そこで、もっときれいで、簡単で、シンプルなやり方があることに気付きました。デハイドレーション方式の代わりに、クリティカルパスの Suspense 境界置き換えに切り替えました。この方式は:
- ハイドレーション前にクリティカルパスを読み込む: SSR 中にプリロードされたコンポーネントを特定し、そのインポートパスをもとにクライアントでもハイドレーション前に同期的にプリロードする。
- メンテしやすい: React の内部実装や AST パースに依存しない(デハイドレーション方式は HTML を解析して復元する必要があった)。
- 送るバイト数が減る: React の元の SSR レスポンスを script タグで同梱する必要がなくなる。
- チラつき防止になる: HTML をデハイドレートしてからリハイドレートする必要がなくなり、描画のチラつきが起きにくい。
実装としては、SSR 中にどの lazy コンポーネントがプリロードされたかを SSRLazyComponentTracker 経由で記録しておき、そのインポートパスをハイドレーション用のデータに含めます。クライアントでは、ハイドレーションの前にそれらを同期的にプリロードします。クリティカルパスのコンポーネントは Suspense 境界なしで直接レンダリングされるので、SSR の出力と完全に一致します。
それ以外の部分については、最初のクライアントレンダリングを SSR / SSG と同じものとして扱う ことにしました。つまり、同じ入力を使って、その入力を hydrateRoot が呼ばれる前に 同期的に 揃える、ということです。これは「ssg-data」という仕組みでバンドルしています。
具体的には、こんな調整をしました:
SSR の入力を 1 つのテキストスクリプトにまとめてバンドル
- SSG 時に、Vite のエントリポイントモジュールの直前に
<script type="text/foony-ssg" id="foony-ssg-data">...</script> を差し込みます。
- このスクリプトには次が入っています:
html: 静的ファイルとして実際に配信した「境界解決済み HTML」
ssgData: SSR ラッパーが使った SSGData のシリアライズ版。将来的には Proxy などを使って、実際にアクセスされたデータだけが含まれるようにしたいと思っています。
translationData: SSR 中にアクセスされた翻訳キーと値のペア
ハイドレーションの直前にそれらの入力を注入
main.tsx の中で、同期的に:
#root.innerHTML にシリアライズ済みの解決後 HTML をそのまま突っ込む(DOM をハイドレーション時に React が期待する形と完全に揃える)
- アプリ全体を
SSGDataProvider でラップして、コンポーネントが最初のレンダリングから SSR 時と同じ SSGData にアクセスできるようにする
翻訳値を注入して i18n を即時対応にする
- SSR 中にどの翻訳オブジェクトが実際に使われたかを記録しておき、それを SSG スクリプトに詰めます。
- クライアント側では、それらを
LocaleQueryer.inject() という専用メソッドで LocaleQueryer のキャッシュに直接注入します。これで最初のレンダリングから翻訳が揃った状態になります。
これで、最初のクライアントレンダリングと SSR の入力が完全に揃うようになりました。
useIsSSRMode() フックはすでに client/src/generators/ssr/isSSRMode.ts に実装済みです:
export function useIsSSRMode(): boolean {
const [isSSRMode, setIsSSRMode] = React.useState(true);
React.useEffect(() => {
// After mount (hydration complete), switch to client mode
setIsSSRMode(false);
}, []);
return isSSRMode;
}
このフックは、SSR 中とクライアントの最初のレンダリング(ハイドレーション中)は true を返し、マウント後に false に切り替わります。UserBanner、Navbar、Dialog などのコンポーネントでは、すでにこれを使ってハイドレーション時の食い違いを防いでいます。
- React をパッチして、より分かりやすい diff を出す
最初は hydration-overlay をそのまま使えればいいなと思っていました。でもこれはメンテが止まっていて、React 18 までしかサポートされていないし、本番投入できるレベルでもありませんでした。なので、リポジトリをクローンして雰囲気だけ参考にしつつ、LLM にミニマルなハイドレーションオーバーレイを作ってもらいました。欲しかったのは派手な機能ではなく、開発中に「どこが噛み合っていないのか」を見つける手がかりになるものだけです。
この新しいオーバーレイはかなりシンプルなので、diff はまだ完璧とは言えません。React がコメントを削ったり、style 属性のあとに ; を足したり、空白を変えたり、いろいろ細かいことをするのですが、今のところそこまでは追いかけていません。React がハイドレーション時に無視する HTML コメントも、こちらのオーバーレイには含まれています。
<img alt="Our new hydration overlay" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
それでも、何を直せばいいのかを知るには十分役に立ちます。
<img alt="diff of our SSG vs client first-page render for React hydration" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
数字で見た今回の実装
今回どれくらいの作業だったのか、イメージしやすいように数字でまとめておきます:
- 2日 の作業(スタートから SSG が動くまで)。ちょうど 24 時間ちょっと、休暇中にやりました。
- 4日 かけて、翻訳の非同期レースや
useMediaQuery の問題が出ないように、ハイドレーションをいい感じに整えました。
- さらに 1日 かけて、デハイドレーション方式をやめて、クリティカルパスの Suspense 境界置き換えに差し替え(よりシンプルで、バイト数も減り、チラつきもなし)。
- 約 200 行 の SSG 生成コアコード(
GenerateShellSsgFromSitemap.ts)
- 約 120 行 の Suspense 境界解決処理(
renderRoute.tsx の resolveSuspenseBoundaries)
注: のちにクリティカルパス方式に置き換えました
- 約 50 行 の SSR ユーティリティ(
isSSRMode.ts)
- 約 100 行 のテスト(
renderRoute.test.ts)
- 約 150 行 の SSR 用ポリフィル(
setupSSREnvironment)
- 既存コンポーネント側の変更は最小限(ほとんどは
useIsSSRMode() のチェック追加程度)
全体として、とても軽量でメンテしやすい構成になりました。フレームワークの大移行は不要で、今までの React SPA のまま動いています。
学んだこと
ときどき、フルスクラッチのほうが正解
問題によっては、必ずしもフレームワークが正解とは限りません。Foony にとっては、小さなフルスクラッチの SSG がベストな選択でした。これは:
- 軽量: 重い依存やフレームワークのオーバーヘッドがない
- メンテしやすい: 自分たちでちゃんと理解できるシンプルなコード
- 柔軟: 必要に応じて簡単に拡張や変更ができる
- 互換性が高い: 既存の React SPA をほぼそのまま使えるので移行不要
React のストリーミング SSR にはクセがある
React の renderToReadableStream は、Suspense を扱うには便利ですが、クセがあります。await stream.allReady を呼んでも、出力には Suspense 境界が残ります。これはバグではなく「ストリーミング前提の仕様」です。でも SSG でほしいのは、境界が解決された完成形の HTML です。このケースを React 側でうまく扱えるようにしてくれていないのは、ちょっと残念に感じます。
今回は、HTML をあとからポストプロセスして、境界を自前で解決する方法を取りました。美しいかと言われると微妙ですが、ぼくのユースケースでは十分速くて柔軟な方法です。
TDD は LLM と相性がいいことがある
HTML の変換処理はミスが起きやすいです。ちょっとしたバグで SSG の出力全体が壊れて、ユーザー体験も台無しになりかねません。そこで LLM に(ぼくの入力をもとに)しっかりしたテストを書いてもらい、変換が正しく動いているかを確認しました。
おわりに
Foony の SSG は、いま無事に動いています。検索エンジンや LLM に対して、きちんとレンダリングされたページを返せるようになり、そのうえで実装も軽くてメンテしやすい形に収まりました。SSG ルートのハイドレーションは想定より時間がかかって(3日)、さらに最初のデハイドレーション方式をクリティカルパスの Suspense 境界置き換えに差し替えるのに 1日使いました。でも今のやり方は、メンテナンスがしやすく、送るバイト数も減り、HTML のデハイドレーション / リハイドレーションによるチラつきも防げています。
それにしても、自前の SSG を 2日で形にできたのは、自分でもちょっとびっくりしています。でも、ときにはいちばんシンプルな解決策こそが正解なんですよね。
今後の宿題としては、ハイドレーションの一致度をさらに高めることと、デバッグをもっとやりやすくするために React 側をパッチする可能性もあります。でも今の時点で、Foony にはちゃんと動く SSG が入りました。これから数週間、Google Search Console と Bing Webmaster Tools を眺めながら、SEO にどんな影響が出るのかを追いかけていくつもりです。