

1/1/1970
SSGを2日間で実装した話
どうも! 1年前は不可能だと思っていたんです。でも、Foonyに静的サイト生成 (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への移行は、こんな作業を意味していました:
- ルーティングシステム全体の書き直し
- ゲームやコンポーネントの読み込み方法の再構築
- 既存の機能水準に戻すだけで何ヶ月もの作業
- ユーザーへの破壊的変更の可能性
- 画像の扱い方の変更
- ビルド時間が大幅に遅くなる (おそらく5〜30分。具体的な数字は持ち合わせていませんが、この5年前のGitHubでの議論を参考にしました)
- チーム全員が新しいもの (NextJS) を学ぶ必要があり、開発速度が永続的に低下する
- NextJSが破壊的変更を行うたびにコードを移行する必要がある
実際にNextJSでフライング気味に始めてみましたが、移行コストが高すぎることにすぐ気づきました。複雑さに見合う価値はありませんでした。
Vike: 同じく複雑
Vike (旧vite-plugin-ssr) も同様の問題を抱えていました。NextJSより柔軟ではありますが、コードベースの大幅な再構築が必要でした。学習コストと移行の労力は、得られるメリットに見合いませんでした。
Astro: アーキテクチャが合わない
Astroはコンテンツ中心のサイトには最適ですが、Foonyは複雑なマルチプレイヤーゲームプラットフォームです。リアルタイム更新、WebSocket接続、動的なReactコンポーネントが必要です。Astroのアーキテクチャは、私たちが作ろうとしているものには合いません。
解決策: オーダーメイドのSSG
数日前にi18nの後で実装した「フェイクSSG」のアプローチに勇気をもらって、Foonyには小さくて軽量なオーダーメイドの解決策を採用することにしました。
私の「フェイク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のコンソールログ" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
シンプルさについてはいくらでも語れます。大企業では「複雑さに欠ける」という理由で昇進にはつながらないかもしれませんが、シンプルなコードは美しく、保守しやすく、開発速度全般にとってずっと良いものです。これはZenの原則で本当に尊敬するところです。
Suspense境界の問題
さて、SSGができてコンテンツがHTMLに表示されるようになりました...が、ページが真っ白! なんで?! <img alt="SSGの真っ白なページ" 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は番号 (0始まりのインデックス) でS:0と対応しています。
JavaScriptがないと、検索エンジン (Bingさん、君のことだよ) やLLMはテンプレートのプレースホルダーだけのほぼ空白なページを目にすることになります。これではSSGの目的が完全に台無しです!
これらのSuspense境界をきれいに取り除く方法が見つからなかったので、私の解決策はテストといくつかのresolveSuspenseBoundaries関数を書いてこれらを置き換えることでした。HTMLをパースしてJSDOMのようなもので スクリプトを実行するより速かったです。さらに重要なのは、これは私が計画していたこと、つまりJavaScriptなしで検索エンジン / LLM向けにきれいで読みやすいサイトを提供しつつ、クライアント側ではSuspense境界とハイドレーションをサポートする、という要件のために必要だったということです。
変換のテスト
まずは、手元にあるもの (JavaScript無効) と欲しいもの (JavaScript有効) のDOMサンプルを取ってきて、変換のテストを書くところから始めました。それらをLLMに渡してテスト生成を任せました。LLMはこれが得意なんです。
これらのテストはclient/src/generators/ssr/renderRoute.test.tsにあり、変換が正しく動作することを保証します。テストがカバーするのは:
- シンプルな境界の置換 (ブログ一覧)
- テンプレートと終了コメントの間にコンテンツを持つ複雑な境界
- 複数の境界
- コメントマーカーなしの境界
- エッジケース
このタイプの「TDD」は、入力と出力が予測できるこのようなユースケースで実は非常に有用です。
これは「Robert C. Martinが言ったから何でもTDDする」 (チームの開発速度を低下させます) とは混同しないでください。UIや常に変化するコード領域でTDDを使うべきではありません!
解決策: resolveSuspenseBoundaries
テストが整ったので、LLMにresolveSuspenseBoundaries関数を書いてもらいました。RegExを使うとSSG時間が約40%短縮されるのですが、RegExの脆さを避けるためcheerioを選びました。
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="JavaScriptなしのFoonyブログのSSG" 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のHello World" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
最初の最小限の動くハイドレートには、独特の課題がありました: ハイドレーションが欲しい一方で、開発者がSuspense境界を意識せずに、検索エンジンとLLMにとって良いSEOも欲しかったのです。
課題
Reactのハイドレーションは極めて文字通りです: 最初のレンダリングでDOMがReactの期待する形と合わないと、コンソールにあのきれいで、ほぼ役に立たないエラーメッセージが出て、Reactは全部捨ててゼロから再レンダリングします。何が悪かったかを示すdiffすら出してくれません!
私たちのケースでは、SSGによって状況がいくつかの面で悪化していました:
- React 18のストリーミングSuspenseの痕跡を取り除く / 解決するためにHTMLを後処理していました (これはボットには素晴らしい) 。
- クライアントには、サーバーレンダリング時の (t = 0) の時点でサーバーが持っていたのと同じデータが常に揃っているわけではない (SSGデータ、ブログメタデータなど) 。
- 私たちのi18nはデフォルトで「遅延」式なので、SSGで使われた翻訳を記録してReactのレンダリング前に注入しない限り、最初のレンダリングで翻訳が欠けることがあります。
効いたもの (初期アプローチ: 脱水)
最初は、ちょっと賢くてかわいい方法を試しました: コマンドパターンを使ってHTMLのSuspense境界を解決するのに使ったコマンドを記録し、HTMLをReactがハイドレーションに必要な形に戻すための逆変換コマンドを返したのです。
このコマンド方式でindex.htmlのバイト数を大幅に減らせるのではないかと期待していました。でも、ほとんどの賢い解決策にありがちですが、ブラウザがHTMLを微妙に変更すること (;や/を削除したり追加したり) で、置換のインデックスがずれてこの方法は失敗しました。
技術的にはこれらの微妙なブラウザ変更を考慮することはできるでしょうが、そんな脆いものをリリースする気にはなりませんでした。
Suspense境界の変換をReactのストリーミングマークアップに「逆戻り」させようとするのではなく、超シンプルなことをしました:
元の未解決のHTMLを<script type="text">にバンドルする。
この「脱水」アプローチは効きましたが、より良い解決策に置き換えるのに追加で1日かかりました。
より良いアプローチ: クリティカルパスのSuspense境界置換
初期実装の後でも、Suspense境界に関するいくつかの問題に遭遇していました。そのとき、よりクリーンで、より良く、よりシンプルな解決策があることに気づきました。脱水アプローチを クリティカルパスのSuspense境界置換 に置き換えました。これは:
- ハイドレーション前にクリティカルパスを読み込む: SSR中にプリロードされたコンポーネントを特定し、
hydrateRootが呼ばれる前にクライアントでプリロードします
- 保守がよりシンプル: Reactの内部やASTパースは不要 (脱水アプローチではHTMLのパースと復元が必要でした)
- 少ないバイト数で配信: Reactからの元のSSRレスポンスをスクリプトタグにバンドルしなくなります
- 潜在的なフラッシュを防ぐ: HTMLを脱水 / 再水和する必要がなく、潜在的な視覚的フラッシュを排除
実装ではSSR中にどの遅延コンポーネントがプリロードされたかを追跡し (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がハイドレーションが見るものと完全に一致するように)
- アプリを
SSGDataProviderでラップして、コンポーネントが最初のレンダリングで同じ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などのコンポーネントは、ハイドレーションのミスマッチを防ぐためにすでにこれを使っています。
- より良いdiffのためにReactをパッチ
hydration-overlayを使えるかと期待していました。でもアクティブにメンテナンスされておらず、React 18までしかサポートされておらず、本番環境向けには未完成でした。なので、LLMにインスピレーション用にリポジトリをクローンしてもらい、数分で最小限のハイドレーションオーバーレイを作ってもらいました。派手なものは必要なかったんです。開発中に表示されて、何がうまくいかなかったのか把握できれば十分でした。
この新しいオーバーレイは超基本的なので、diffが完全に完璧というわけではありません。Reactはコメントを取り除き、style属性の後に;を追加し、空白を変更し、その他いくつかの小さなことをしますが、私たちのオーバーレイは (まだ) それを考慮していません。私たちのオーバーレイにはReactがハイドレーションで無視するHTMLコメントも含まれています。
<img alt="新しいハイドレーションオーバーレイ" 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="ReactハイドレーションのためのSSGとクライアントの最初のページレンダリングのdiff" 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変換はミスを起こしやすいです。小さなバグ1つで、SSG出力全体が壊れて、エンドユーザー体験が損なわれかねません。私はLLMに (私の入力で) 包括的なテストを書いてもらい、変換が正しく動作することを保証しました。
結論
FoonyでSSGが動くようになりました。ページは検索エンジンとLLM向けに完全にレンダリングされ、ソリューションは保守しやすく軽量です。SSGルートのハイドレーションは予想より時間がかかりました (3日) し、初期の脱水アプローチをクリティカルパスのSuspense境界置換に置き換えるのに追加で1日かかりました。新しいアプローチは保守がよりシンプルで、配信バイト数も少なく、HTMLの脱水 / 再水和による潜在的な視覚的フラッシュを防ぎます。
オーダーメイドのSSGソリューションを実装するのにたった2日しかかからなかったことに、いまだに驚いています。でも時には、正しい解決策が最もシンプルなものなんですよね。
今後の作業には、ハイドレーションマッチングの完成と、より良いデバッグのためにReactをパッチすることが含まれます。でも今のところ、FoonyにはちゃんとしたSSGがあります。これがSEOにどんな影響を与えるか、これからの数週間、Google Search ConsoleとBing Webmaster Toolsで見守っていきます。