🔑 重要なポイント
✦ AI·GEN著者が koimsurai.com のブログレンダリングを 100% CSR の React SPA から SSG/ISR へ作り直すまでの全過程を記録した一本。フレームワーク選定では本文を SSG、ダッシュボードを CSR に分けるハイブリッド構成を採り、Next.js は評価のうえ見送られ、React Router 7 が最有力だったところを「ISR が欲しい」という一点が TanStack Start に傾ける。実装編では記事本文を HTML に焼き込み、重い interactive は client に残したうえで、MDX をサーバー側でコンパイルしてクライアントで runSync により eval する構成と、その負債の畳み方までを扱う。さらに、111 個のファイルを生成しながら 1 つも配信されていなかった prerender を捨てて純粋な ISR に移行した経緯と、公開時にキャッシュを無効化する Rust ミドルウェアが、あやうくページ閲覧のたびに全キャッシュを消し飛ばしかけた事例も詳述する。終盤は記事の入場レンダリングのデバッグ記録で、スタイルなしのチラつき、二重レンダリング、scroll-behavior: smooth に握られたスクロールという 3 つの症状を突き止めていく。本稿は SSR/SSG/CSR/ISR とハイドレーションの基礎解説も兼ねている。
どのブログも、結局は同じ問いに答えることになります。1 本の記事は、どうやってデータベースから読者の画面までたどり着くのか?このサイトはこの 2 年、いちばん横着な答えを使っていました。 です。この記事は、その答えを3 回書き直した記録です。まず SSG に移し、次に自分の prerender に裏切られて ISR へ切り替え、最後は「記事に入った瞬間にチラつかせない」ためにレンダリングの時系列と一晩じゅう格闘しました。
先に用語を整理しておきます。この記事は最初から最後までこの 4 つを軸に回ります。
- SSR():リクエストのたびにサーバーがその場で HTML をレンダリングして返す方式。初期表示から中身がある。
- SSG():build 時にページを静的な HTML ファイルとして事前レンダリングしておき、以降はそのファイルをそのまま返す。
- CSR():ブラウザが空の殻を受け取って自分で描く。つまり私が元々使っていた方式。
- ISR():両者の中間。まず事前生成した静的 HTML を返し(速い)、バックグラウンドで定期的に、あるいはトリガー時に再生成する(新しい)。
判断を誤ったところも、自分で自分を止めて考え直したところも含めて、できるだけ忠実に過程を残します。
背景:クローラーには透明なブログと、全部 Rust にしたかった私#
改修前、このサイトはごく普通の React 19 SPA でした。BrowserRouter + <Routes>、native fetch + 手書きキャッシュ、SSR の使用量は 0、100% CSR。人間がトップページを開いて受け取るのは空のルートノードと JS の塊だけで、中身はすべてブラウザが生やします。それでもクローラーが meta を見られたのは、serve 層が user-agent で bot を判定し、<title> や OG、JSON-LD を静的 HTML に押し込んでいたからです。言い換えると、記事の body は、一度もサーバーでレンダリングされたことがなかったわけです。「中身がすべて」のブログにとって、この構成は逆さまでした。
同じ時期、頭の中にはもう 1 つ別のことがありました。バックエンドを全部 Rust にしたい。 これは背景を知らない読者のために少し前置きが要ります。というのも、これは「ブログを速くする」こととは何の関係もないからです。
ついでに書いておくと、当時いちばん気にしていた SEO の症状は、Google が「Koimsurai」を「Katsurai」(京都のとんかつ屋)に自動修正してくることでした。調べてみるとそれはブランドのエンティティ認識の問題で、SSG では直りません。とはいえ「人間もクローラーも、ちゃんと render された本文を受け取るべきだ」という一点は、それでもやる価値がありました。
技術選定:SSR / SSG / CSR、そしてなぜ Next ではなかったか#
並べてみると道は 3 本です。現状維持(全 CSR + bot 向け meta shim)、ハイブリッド SSG(本文ページは prerender、ダッシュボードは CSR のまま)、あるいはサイト全体 SSR。ブログにとっての答えは意外にも SSG 寄りで、しかも私の言う「全 Rust」の定義にむしろよく噛み合っていました。
- SSG の成果物は静的 HTML の束なので、Rust バックエンドがそのまま serve すればいい。production にレンダリング用の Node はゼロ。
- サイト全体 SSR にすると prod に常駐の Node レンダリングプロセスが 1 つ増え、かえって「全 Rust」が薄まる。
- そしてこのサイトは、そもそもコンテンツが 2 種類に分かれます。半静的な記事/静的ページ(SEO が要る)と、リアルタイムのダッシュボード(now / 視聴 / 音楽。ずっと変わり続けるし SEO はそもそも不要)。ちょうどいい。本文は SSG、ダッシュボードは CSR のまま。
では Next.js は?サイトを React から移行する案も人に評価してもらいましたが、最終的には選びませんでした。Next への恨みは、実は別のプロジェクトでとっくに溜まっていました。Next を使った Tauri のデスクトップアプリはビルドが我慢できないほど遅かったし、NAS のフロントエンドはもっと悲惨で、Next 16.0.6 の 脆弱性を突かれて侵入され、マイニングプログラムを仕込まれました。
評価した結果 Next は TanStack Start に勝てず、フレームワークの最終候補は TanStack Start vs framework mode に収束しました。Next の看板である も、私のところでは出番がありません。データはすべて Rust バックエンドにあるので、server component にできるのはせいぜい await fetch(rustApi) で、RSC の利点はそこで相殺されてしまいます。
天秤を本当に 側に傾けたのは、たった 1 つのことでした。
NOTE
ひっくり返したのは「ISR が欲しい」だった
React Router 7 の framework mode は、実はあと一歩で当選するところでした。もともと RR7 に乗っていたので、framework mode に切り替えて SSG を取るのが移行の摩擦が最小です(あの 83 個の <Link> も 24 個の useNavigate もほぼそのまま)。ですが RR7 の framework mode にはネイティブの ISR がありません。ISR が欲しければ、prod にあの Node レンダリング層を足すことを受け入れるしかない。そして私は本当に ISR が欲しかった(公開した瞬間に反映され、rebuild を待たなくていい)。加えて「いま最も旬な TanStack 一式をひと通り走らせてみる」ことは、私のような個人の vessel プロジェクトにとって正当な dogfood の動機でもあります。router のこの一題は、こうして Start に決まりました。
ついでに、このスタックがどれだけ新しいかも書いておきます。TanStack Start v1.0 は 2026 年 3 月に出たばかり、Vite は 8.x(内部は Rolldown に置き換わった)、その下のサーバーエンジンである に至ってはまだ v3 の beta(TanStack Start がそれを直接統合しています)。まるごと bleeding edge です。後で見ていくとおり、この「新しさ」は楽しみでもあり、何度も私に噛みついてもきました。
では、もっと JS を節約できる は?第 1 ラウンドでは three.js や Monaco のような重い interactive があるので見送りました(React-first のほうが現実に合う)。その後「eval を外して CSP を有効にする」ために、もう一度真剣に評価しています。結論は後述します。最終的なメンタルモデルはとてもすっきりしていました。「SSG/ISR による初期表示 + SPA 的なその後の遷移」。初回ロードはサーバーが事前生成した HTML(SEO と初期表示のボーナスはここから来る)、 したあとはサイト内のページ切り替えが SPA のように動く。両方のおいしいところを取ります。
実装:本文を HTML に焼き込み、重い interactive は client に残す#
まず PoC で検証。実際の記事で prerender を走らせ、本文が HTML に焼き込まれていることを確認しました。/blog/39/index.html は 38KB、body には完全な <article>、見出し、テーブルが入っています。4 項目すべてグリーン。核心の結論は一言で、本文は prerender、重い interactive は CSR のまま。 本実装のレンダリングパイプラインはこうなりました。
重要な決定が 2 つ。1 つめは、どのページを prerender するかを API から列挙することです。/en/blog/:id を 5 本ベタ書きするのではなく、$locale/blog/$id という 1 本の動的ルートで全言語を処理します。build 時に /api/posts を叩いて各記事の available_locales を取得し、実在する言語だけを生成。 もこれに従って出力し、決して捏造しません。
2 つめは、本文を HTML に焼き込み、重い interactive は client に残すことです。1800 行あって mermaid や shiki を引き込む interactive 版の BlogPost は lazy + にして、three.js や mermaid のような Node の中で爆発するものがサーバー側のバンドル(server bundle)に決して入らないことを保証しました。
この道では server bundle と hydration の落とし穴を一通り踏みました。代表的なものをいくつか。
- LinkCard が BlogPost 丸ごとを道連れにした。
HistoryとAboutSiteの 2 ページがimport { LinkCard } from './BlogPost'していました。LinkCard 自体は完全に SSR できます(リンクプレビューカードで window には触らない)。しかし mermaid と一緒にあの 2000 行のファイルに閉じ込められているので、import した瞬間に mermaid 一式が server bundle に引きずり込まれます。解決策は、LinkCard を独立した SSR-safe なモジュールに切り出して mermaid と切り離すこと。 - ナビゲーションバーが韓国語をしゃべる 。 サイト全体の外殻(Header/Footer)は
__rootにぶら下がっているのに、各ページのLocaleProviderの外側にありました。そのため外殻の翻訳がグローバルな i18next インスタンスにフォールバックします。複数ページをまとめて prerender すると言語が漏れて、SSR HTML は<html lang=en>、Hero は英語(正しい)なのに、ナビゲーションバーだけ韓国語。SSR のナビは ko、client のナビは en → #418 です。解決策は、root に URL から言語を判定する provider をもう 1 層かぶせて、外殻にも正しいロケールを渡すこと。 - 古い Service Worker の亡霊。 旧 SPA には PWA plugin が登録した、古いアセットを precache する SW がありました。新しい構成にはそれがありません。再訪者は古い外殻 + 新しい HTML を受け取り → スタイル崩壊 + #418。解決策は、serve 層から自害する
/sw.js(登録解除 + キャッシュ削除 + reload)を配ること。
レンダリング経路上の細かい穴もいくつか、ついでに記録しておきます。どれも「SSR と client は一字一句一致していなければならない」に関係するからです。
- タイトルのコロンで主題と副題を自動分割。 schema に副題のカラムがないので、フロントエンドの
splitTitleが最初のコロンで切り、主題はh1、副題はpに入れます。純粋な表示層の処理で、document.titleとog:titleは完全なタイトルのままなので SEO に影響はありません。 - scroll-spy が脚注の id に乗っ取られた。 は元々「ページ上の
idを持つ全要素」を追跡していたため、脚注や alert のuser-content-fn-…という id が active 状態を奪い、目次のハイライトが消えてしまいました。解決策は「目次に実際に載っている見出しの id 集合」だけを見ること。アンカーにはscroll-margin-topも足して、ジャンプ後に見出しが sticky header に隠れないようにしました。 - mermaid の ELK layout が SSR サイトでは circular JSON でクラッシュする。 Adaptive(ELK)レイアウトに切り替えるといきなり
Converting circular structure to JSONが飛びます。掘り下げると、@mermaid-js/layout-elkが ELK のグラフ全体に対してJSON.stringifyを行っており、このサイトの<html>は TanStack Start が React でレンダリングしているためdocumentElementに__reactFiberが乗っていて、シリアライズが循環参照に突き当たっていました。これは React SSR サイト固有の落とし穴です。解決策は参照カウント式の guard で、render の最中だけJSON.stringifyを DOM ノードをスキップする版に差し替えること。
prerender は 111 個のファイルを生成し、1 つも配信されなかった#
実装の途中で、かなり馬鹿げた発見にぶつかりました。docker build の中の prerender は速くきれいに走っている(7.8 秒、111 ファイル、エラーゼロ)し、.output/public/blog/index.html も確かに生成されています。ところが実際に /blog/index.html をリクエストすると必ず 404 が返り、しかも /en を 2 回続けて叩くと md5 が違う。つまり、リクエストのたびに SSR し直していて、あの 111 ファイルは 1 つも配信されたことがなかったわけです。根本原因は、Nitro が静的アセットを登録するリストを、prerender がファイルを書く前にスキャンし終えていたこと。大量のファイルを生成しても誰も配信せず、build 時間を純粋に浪費したうえ、build 中に本番サイトに接続して記事一覧を取りに行かせていました。
そこで prerender をまるごと取り除き、純粋な ISR に切り替えました。ここで先に ISR の仕掛けを説明しておきます。公式のやり方は、私の環境では通らないからです。
WARNING
公式の ISR は CDN 頼み。self-host で CDN がなければ再生成されない
TanStack Start 公式の ISR の仕組みは、build 時に prerender し、レスポンスに Cache-Control: stale-while-revalidate を付けるというもので、バックグラウンドの再生成は CDN が実行します。私は nginx を自前ホストし、DNS-only で、意図的に他社の CDN proxy の下にぶら下げていません。CDN がなければ、あの ヘッダーには実行者がおらず、バックグラウンド再生成はまったく起きません。
救ってくれたのは Nitro の でした。SWR が内蔵されていて routeRules 一行で片付き、しかもバックグラウンド再生成が自分のサーバーの中で起きます。
// vite.config.start.ts —— ISR は 1 行で済む。自前で 100 行書く必要はない
const ISR_ROUTE_RULES = {
...swrRules(ISR_PAGES.flatMap(localeVariants), 3600), // UI ページ:1 時間
...swrRules(localeVariants('blog'), 300), // 一覧ページ:新着記事を早く出したい → 5 分
...swrRules(localeVariants('blog').map((p) => `${p}/**`), 3600), // 記事ページ:内容はほぼ動かない → 1 時間
};サイト全体のリクエストの流れはこうなりました。Rust が唯一の本物のバックエンドで、Node/Nitro はレンダリングの殻が 1 枚あるだけ(これがまさに私の言う「ビジネスロジックは全部 Rust、前段の Node はレンダリングだけ」の実装形です)。
ここには展開する価値のある落とし穴が 3 つあります。
一覧ページが空の殻をキャッシュしていた。 ISR がキャッシュした /blog は最初、中身が空でした。Blog コンポーネントが useEffect の中でデータを取っていて、useEffect は server では実行されないからです → SSR は loading のスケルトンしか吐きません(prerender でも同じで、あれも useEffect を走らせないので救えません)。解決策はデータ取得をルートの loader に移し、useLoaderData で初期データを流し込むこと。/blog の SSR は 19,541 bytes の空の殻から、74,242 bytes、見出しと 8 本の記事リンク入りに変わりました。
公開したら即反映:オンデマンド再生成。 TTL だけでは足りません。新しい記事はすぐにクローラーから見えてほしい。やり方は、Nitro の server route /_revalidate を 1 本用意し(header の secret で保護。/api/* に置かないのは意図的で、nginx が /api/ を全部 Rust に送ってしまうためフロントエンドまで届かないからです)、Rust バックエンドが記事の公開/更新に成功したあと fire-and-forget でそれを叩いてキャッシュを消す、というもの。しかもこれは 14 個の書き込みエンドポイントに個別に付けるのではなく、axum の として実装しました。1 つずつ付けていけば必ず漏れますし、漏れてもエラーは出ず、ただ静かに更新されなくなるだけです。
CAUTION
1 文字違うだけでサイト全体のキャッシュが消し飛ぶ
「これは記事を書き込むリクエストだ」と判定するとき、直感のまま path.contains("/posts") と書くと、非常に静かに爆発します。/api/posts/:id/view(誰かが記事を閲覧するたびに叩かれる)にも /posts が含まれるからです → 誰かが記事を読むたびにサイト全体の ISR キャッシュが消える → ISR は事実上無効化され、しかもエラーは出ません。/view、/like、/reactions、/comments を明示的に除外し、さらに unit test を書いて固定する必要があります。
キャッシュルールはホワイトリストにし、/** は使わない。 サイト全体を包む書き方(/**)は fail-open です。今後 cookie を読むページやユーザーデータを render するページを追加すると、既定で公開キャッシュされ、しかも誰も気づかない。ホワイトリストならその逆で、新しいページは既定でキャッシュされません。明示的にキャッシュしないのは、/(cookie / Accept-Language を読んで言語振り分けをするので、これをキャッシュするのは最初の訪問者の言語を全世界に配るのと同じ)、/admin、/auth です。
番外編:すべての SSR ページが無言でハングした、3 つ重なったバグ
Nitro に移行した日、すべての SSR ページが無言でハングしました(000 を返し、エラーも出ず、timeout もしない)。一方で /api の proxy も静的アセットも 200。調べると、それぞれ単独でもサイト全体を落とせるバグが 3 つ重なっていて、いちばん致命的な 1 つがいちばん陰湿でした。nitro@3.0.0 は 9 か月前の古いバージョンだったのです。package.json には ^3.0.0-beta と書いてあり、semver の prerelease 比較ルールは日付で番号付けされた beta には永遠にマッチしません(latest = 3.0.260610-beta)。これが前に書いた「まるごと bleeding edge だと噛まれる」の具体的なひと噛みです。古いバージョンには routeRules.swr × ssr-renderer の衝突があり、リクエストが自分自身に戻って無限の自己ループになっていました。最新に上げたら全部グリーンです。
もっと記録に値するのは後半です。一度は直ったと思ったのですが、立ち止まって自分にこう問い返しました。「この修正は本物か、それとも症状を迂回しているだけか?」——アブレーションテストをしてみて分かったのは、5 つの「修正」のうち本物は 2 つだけ(nitro のアップグレード、SSR をローカルのバックエンドに向けたこと)で、残りの 3 つ(server.ts、noExternals、/api proxy)はすべて古いバージョンのバグを迂回していただけ、新しいバージョンではまったく不要でした。あの /api proxy に至っては「存在しない問題を解決していた」ものです。最終的に設定は公式の最小形 plugins: [tanstackStart(), viteReact(), nitro()] + SWR ホワイトリスト 1 枚に落ち着きました。迂回は簡単に「直った」ように見える。本当に直すには、まず自分がただ迂回しただけかもしれないと認めるところから始めるしかありません。
MDX:eval するパイプラインと、返したい借金#
ここまで記事はまだ react-markdown でレンダリングしていました。その後 <Note>、<Annot>、<Diff>、<Chart> といった独自 block が欲しくなり(読者の没入感を十分に深くするため)、 のパイプラインを 1 本重ねました(記事ごとの opt-in で、format=mdx の記事だけが通ります)。このパイプラインが本稿でいちばん技術的な部分で、しかも私の理解には最初から訂正すべき点がありました。
IMPORTANT
eval は interactive が持ち込むのではなく、「記事の中に実行可能な JS がある」ことが持ち込む
私は元々「interactive な block があるときだけ eval が要る」と思っていましたが、これは不正確です。react-markdown はパース(文字列を AST に解析してコンポーネントに対応づける)であって、何も実行しません。一方 MDX は「JS にコンパイルしてから実行する」。記事の中の {new Date().getFullYear()} のようなインライン式は本物の JavaScript で、コンパイル済みの JS をブラウザ側で実行する必要があります。つまり eval を踏むかどうかは「記事の中に実行可能な JS があるか」で決まるのであって、「interactive があるか」ではありません。
コンパイルは server でのみ行う。 @mdx-js/mdx のコンパイラは micromark + acorn で非常に重く、client bundle に入れるべきではありません。そこで TanStack の を使いました。SSR では in-process で走り、client 側のナビゲーションでは RPC で server に戻ってコンパイル結果を取ってきて、function-body の文字列(シリアライズ可能で、dehydrate して HTML に埋め込める)を生成します。実行は client で行う。フロントエンドはその文字列を受け取って runSync で React コンポーネントとして実行しますが、runSync の内部は new Function、つまり です。
// server-only:コンパイラは重いので client bundle に入れない。出力の function-body 文字列はシリアライズ可能
export const compileMdx = createServerFn({ method: 'POST' })
.handler(async ({ data: source }) => {
const { compile } = await import('@mdx-js/mdx');
return String(await compile(source, { outputFormat: 'function-body', remarkPlugins: [remarkGfm, remarkAlert] }));
});
// client:runSync で同期的にコンポーネント化(SSR でも hydration でも動く);⚠️ 内部は new Function = eval
const { default: Content } = runSync(compiled, jsxRuntime);ついでにエディタ方面の寄り道も書いておきます。一度 MDXEditor(市場で唯一 MDX ネイティブな WYSIWYG エディタ)を入れて spike してみたのですが、依存が 131 個あり、しかも私の独自 block をすべて「歯車 + ラベル」の汎用 UI にレンダリングしてしまい、本物のコンポーネントにはなりません。私自身 Monaco の見た目のほうが好みでもありました。そのあと腑に落ちました。MDXEditor は「MDX を動かす」ための必要条件では最初からなかったのです。Monaco で MDX のソースを書き、レンダラー(compileMdx + runSync)がそれを描く。これで十分でした。spike はきれいに撤収し、Monaco を残しました。ほかにいくつかの決定として、MDX のコンパイルに失敗したら自動で markdown にフォールバック(タグの打ち間違い 1 つで記事全体を吹き飛ばさない)、MDX 記事と markdown 記事は同じ基礎コンポーネント群を共有(shiki のハイライト、mermaid、リンクカード、見出しアンカー)。
その返したい借金は、この runSync にあります。
NOTE
実は今のところ CSP をまだ有効にしていない
この eval は今のところ制御下にあります。コンテンツはすべて自分で書いて自分でレビューしたもので、ユーザー投稿ではありません。しかも少し笑えるのですが、私は今に至るまで をまだ有効にしていません。__root.tsx の中にチラつき防止/intro 用の inline script が入っていて、迂闊に CSP を入れるとサイトが壊れるからです。先にそれを nonce 化する必要があります。ですからコード中の unsafe-eval のコメントの意味は「いつか厳格な CSP を入れるとき、この eval が unsafe-eval の許可を強いてくる」です。これは残しておきたくありません。
では、この eval をどう畳むか?最初に考えたのは でした。interactive な block を「CSS + bundle 済みの vanilla JS」に書き換え、重い block は registry でマウントする。ただしこれは大量の書き換えが必要で、挙動のリグレッションリスクもあります。その後、はるかにきれいな道に思い当たりました。eval が存在するのは、単に私が MDX を function-body にコンパイルしているからです(あの形式は new Function でなければ動かせない)。もし「本物の ES module」にコンパイルすれば、client は import() で読み込める——そして同一オリジンの import() は、厳格な CSP script-src 'self' の下でも許可されます(それはモジュール読み込みであって eval ではないからです)。 パイプライン全体も、すべての block も、すべてのアニメーションもまったく触らずに済み、書き換えゼロ・リグレッションゼロ。islands はこの道が壁にぶつかったときの退避路にすぎません。目標のフローはこうです。
(これはまだ着手しておらず、TODO に入れました。同じ目的のために Astro も改めて真剣に評価しています。Astro はネイティブに islands で、生まれつき CSP と相性がいい。ですが Astro は独立したフレームワークで独立した build を持ち、既存の TanStack Start には埋め込めません。「blog だけ Astro」は実務上サイトを 2 つのアプリに割るのと同じで、共有している Header / 目次 / リアクション / コメントは全部 island に書き直し、i18n / ISR / SEO / Rust データ層も全部作り直しになります。牛刀で鶏を割いたうえに厨房まで解体する話で、dynamic-import のほうがコスパが圧倒的に高い。)
初回フレームがもうチラつかない:入場デバッグの長征#
以上はすべて本番に出ましたが、本当に深夜まで削られたのは「記事に入るその一瞬」でした。ここから先はレンダリングの時系列の落とし穴ばかりで、本稿の核心でもあります。まず結果から。左が当時の惨状、右が仕上げ後です。
最初に気づいたのは、記事に入る一瞬だけチラつき、Query にキャッシュされたあとは起きない、という現象でした。ですが真相は Query ではありません。あの ClientOnly の fallback がまず素の sans-serif、記事用 CSS ゼロの本文を吐き、重い FullBlogPost の chunk(BlogPost.css + shiki + mermaid 入り)が読み込まれてから丸ごと差し替わっていた——つまり全面再描画であって、スタイルを 1 枚かぶせているのではありませんでした。
第 1 版の対処は idle 中にその chunk を予熱することで、サイト内ナビゲーションは救われました。ですが URL を直接貼ってコールドで開くとやはりチラつきます。ClientOnly は必ず先に fallback を送るからです。根治は第 2 版でした。fallback を FullBlogPost とまったく同じ構造に作り直し、同じ CSS を読み込ませる——JS を全部切っても、初回フレームから完全なスタイルの記事が出ます。
「元の HTML にスタイルを足す」という中間版は、自分で見ても違いました。私が欲しかったのはスケルトンローディングです。レイアウト全体(サイドバー、目次を含む)が初回フレームで枠として出て、各区画がそれぞれ loading して埋まっていく。そこで shimmer のスケルトンを追加しました。サイドバーと目次は先にスケルトン、本文だけは本物のまま(SEO を欠かせません)。
私が見た問題は明らかに「ページ要素に消され、アニメーションが走り直して挿入される」でした。根本原因は同じ本文が 2 回レンダリングされていたことです。BlogPostPage(SSR で出る)→ そのあと FullBlogPost(ClientOnly)がマウントされてもう一度描いて上から被せる。2 本の別々のコンポーネントツリーなので、React は壊して作り直すしかなく、それが目に見える「消してから挿入」になります。2 段階に分けて治しました。
Tier 1(引き継ぎを見えなくする): 入場アニメーションを initial={false} に変更(すでにそこにある内容を新規ロード扱いでもう一度スライドさせない)、目次は loader の中で直接計算(server 側で、本文がすでに SSR されているので実質タダ)、さらに heading 抽出と読了時間計算のロジックを共有 lib に切り出して、2 つの版が一字一句一致するようにしました。引き継ぎは約 95% シームレスに。
Tier 2(本丸): まずレンダリングツリー全体を洗って SSR blocker をすべて洗い出し、それからあの 1800 行のファイルに手を入れます。FullBlogPost を SSR-safe にし(localStorage は「SSR では既定値、useEffect で読み直す」方式に、日付にはタイムゾーンを補完、window アクセスは guard で包み、mermaid の zoom は小さな ClientOnly の島に切り出す)→ そのうえで外側の ClientOnly を丸ごと撤去 → 単一の SSR レンダリング、その場で hydrate になりました。二重レンダリングは根元から消え、スケルトンの残留ゼロ、hydration エラーゼロ、BlogPostPage はここでデッドコードになりました。
記事をリロードして、アンカーが画面を元の位置に戻すとき、まず上に行き、一瞬下がり、また上に行って、一拍引っかかる。私の直感は「高さの計算の問題ではなく、何かがそれを握っている感じ」でした——この直感は正しく、それでも私は一度は誤った方向に修正しかけました。
真因は 2 つの重なりでした。グローバルな html { scroll-behavior: smooth } と、.post-content に付けた が高さを外していたこと(推測 1200px に対し実際は約 9648px、8 倍のずれ)。そして scroll-behavior: smooth はプログラムからのスクロールを毎回アニメーションに変えます。次の呼び出しが前の呼び出しを中断してその場から再スタートするため、永遠に目標に届かない。アンカー復元に使う scrollTo はこうして最後まで足を引っ張られていました。A/B の実測を経て決断しました。グローバルな smooth を外す。目次のクリックやトップへ戻るなど、滑らかにしたい箇所は JS で明示的に指定します。
単一レンダリングになったあと、入場アニメーションは復活できました。ただし本体テキストのアニメーションは CSS @keyframes でなければならず、framer-motion の initial={{opacity:0}} は使えません。理由は硬派です。Chrome の は opacity:0 の要素を勘定に入れません。framer で本文を透明からフェードインさせるのは、LCP を hydration 以降に縛り付けるのと同じです。そこで本体は CSS transform(opacity には触らない)で行い、framer は退場や stagger など LCP に影響しない箇所だけに残しました。
ここまで書いていて気になりました。同じくコンテンツ中心のサイトで、みなさんは最終的にどのマスに止まっているのでしょう。
まとめ:3 度の書き直しの決算#
このレンダリングの長征は、実のところ層の違う 3 つの勘定でした。
- SEO と初期表示は SSG/ISR の配当。hydration は、その引き換えに手に入る新種のバグ一式。 これ以降、記事の本文もタイトルも hreflang も本当に HTML に render され、クローラーは JS を走らせなくても見られます。代償は Node のレンダリング層が 1 枚増えたことと、「SSR が送ったものと client が描くものは一字一句一致していなければならない」という、以前は存在しなかった種類のバグです。
- 測れ、推測するな。 この一晩でいちばん価値があったのは、無理にでも立ち止まって「これは本当に直ったのか、それとも迂回したか/たまたま当たっただけか?」と問い返したことです。それが、印象で下した 2 つの誤診(3 つのバグの偽の修正、スクロールのシリアライズバグ)を押し戻し、アブレーションテストと Playwright で真相を測らせました。
- 繰り返し噛みついてくるルールがいくつか:
useEffectは server で走らないので、それでデータを取っているページは SSR も prerender も空の殻しか吐かない。fail-open なサイト全体のキャッシュルールは地雷。パスのプレフィックスでキャッシュを消すときは、高頻度のエンドポイントを除外し忘れない。
返し切っていない借金も、正直に並べておきます。MDX の runSync は client の eval で、今のところ CSP をそもそも有効にしていないので当面は無害ですが、この eval は厳格な CSP を入れる前に畳んでおきたい。計画は MDX を ESM モジュールにコンパイルし、runSync を import() で置き換えること(islands は退避路にすぎません)。ISR のキャッシュはまだメモリ上にあり、デプロイや再起動のたびにサイト全体がゼロに戻ります(fs driver をまだ繋いでいない)。古い SEOHead(react-helmet)も完全には引退していません。レンダリングというテーマはおそらく永遠に改修し終わらないでしょう。それでも少なくとも今は、1 本の記事がデータベースからあなたの画面まで届くまでの一歩一歩が、なぜそういう形をしているのか、私は全部わかっています。
- TanStack Start —— フルスタックフレームワーク(SSR / server functions / Nitro)公式サイト
- MDX —— Markdown の中に JSX を書く公式サイトcompile / run
- Nitro —— route rules と ISR / SWRRoute Rules
まだコメントがありません
✨ 最初のコメントを残しませんか