蜘蛛は巣を張る前に全経路を知っている:Prerender Spider が問い直したSSRの必然性

はじめに
Remixを使い始めたとき、loader 関数を書いた。
リクエストが来るたびに、サーバーがデータを取得して、HTMLを生成して返す。それが「フルスタックフレームワーク」の動き方だと思っていた。
ブログ記事も同じように作った。loader でMarkdownを読んで、HTMLに変換して、返す。
あるとき気づいた。この記事、昨日から一行も変わっていない。
それでも、1000リクエストが来たら、1000回Workers CPUが動いている。
この記事をお勧めしない人
- 記事が頻繁に更新される動的コンテンツを扱っている人
- SSRの柔軟性(ユーザーごとの出し分け等)を必要としている人
- ビルド時間より運用のシンプルさを優先している人
もし一つでも当てはまらないなら、読み進める価値があるかもしれません。
【思想衝突】
- SSR On-Demand vs Static Prerender
- Real-time Freshness vs Build-time Certainty
① 問題提起:SSRブログが払っているコスト
Markdown記事を配信するSSRルートは、動く。Lighthouseスコアも悪くない。ユーザーから見ると何も問題がないように見える。
問題は、【その「動く」がどれだけのコストを隠しているか】だ。
- 【Workers CPU時間】:リクエストのたびにCloudflare Workerが起動し、MarkdownをHTMLに変換する。記事が100本あっても、1000リクエストが来れば1000回変換が走る。記事の内容は変わっていないのに。
- 【コールドスタート遅延】:Workerがアイドル状態から起動するまでのレイテンシが加算される。CDNエッジに到達しても、そこからWorkerの起動を待つ。
- 【動的処理の偽陽性】:「動的に処理が必要」に見えているが、実際には完全に静的なコンテンツだ。サーバーはリクエストごとに同じHTMLを生成し続けている。
これは「設計ミス」ではない。SSRフレームワークを使う、という正当な選択の副産物だ。
② なぜ皆SSRブログを作るのか
Remixを使う以上、loader を書くことが自然な出発点になる。loader はサーバーで動く。だからSSRになる。
それが「Remixの使い方」として広まっている。
そして実際、多くのケースで正しい。ユーザーごとに違うコンテンツを返す必要がある。認証状態によって表示を変える。データベースの最新状態を反映する。そういった要件がある場合、SSRは必然だ。
| 解決しようとした問題 | 生まれた副産物 |
|---|---|
| リクエスト時の最新データ配信 | 静的コンテンツにも同じコストがかかる |
| フレームワークの統一的な書き方 | 「静的で十分」という判断の機会が失われる |
| データと描画の一元管理 | コンテンツの種類による最適化が後回しになる |
誰かが間違えたのではない。SSRの汎用性が、静的コンテンツへの静的配信という自然な選択を見えにくくした。
③ React Router v7 のprerenderは何を優先したのか
react-router.config.ts に prerender オプションが加わったのは、この問いへの直接的な回答だ。
// react-router.config.ts
export default {
prerender: async () => {
const slugs = await getBlogSlugs(); // ビルド時にcontent/blog/を読む
return ['/blog', ...slugs.map(s => `/blog/${s}`)];
}
}ビルド時に prerender 関数が実行される。その戻り値のパス一覧に対して、Viteプラグインが静的HTMLを生成する。
build/client/blog/index.html
build/client/blog/first-post/index.html
build/client/blog/second-post/index.html
...Cloudflare Pagesはこれらを静的ファイルとして配信する。リクエストが来ても、Workerは起動しない。CDNエッジから直接HTMLが返る。
これが 【Spiderパターン】 だ。ビルド時に「蜘蛛」が全スラッグを巡回し、それぞれのURLに対して静的なHTMLを織り上げる。完成した「巣」は、その後のリクエストを何も考えずに受け止める。
優先した価値
- 【配信の確実性】:静的ファイルはWorkerを経由しない。CDNエッジが直接返すため、コールドスタートが存在しない。
- 【コストの透明性】:何が静的で何が動的かが、設定ファイルに明示される。「これはSSRが必要か」という問いがルートごとに可視化される。
- 【ビルド時の完結性】:「このURLは存在するか」「このコンテンツは何か」をビルド時に確定させる。実行時の不確実性がゼロになる。
意図的に後退させた価値
- 【リアルタイム性】:記事を更新したら、再ビルド・デプロイが必要になる。リクエスト時にファイルを読むSSRなら、デプロイなしに内容が反映される。
- 【柔軟な動的処理】:プリレンダリングされたルートでは、リクエスト時の情報(クッキー、クエリパラメータ、認証状態)を使ったコンテンツ変化ができない。
④ 実装でどう解決しているか
Spiderパターン:ビルド時の全スラッグ巡回
Spiderパターンの核心は、「どのURLが存在するか」をビルド時に確定させることだ。
// react-router.config.ts
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
async function getBlogSlugs(): Promise<string[]> {
const contentDir = join(process.cwd(), 'content/blog/posts');
const files = await readdir(contentDir);
return files
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''));
}
export default {
prerender: async () => {
const slugs = await getBlogSlugs();
return [
'/blog',
...slugs.map(s => `/blog/${s}`),
];
},
};content/blog/posts/ ディレクトリを読み、.md ファイルのスラッグ一覧を返す。これが蜘蛛の「巡回」だ。
prerender 関数が返したパス一覧に対して、Viteプラグインがビルド時にそれぞれのルートを実行し、静的HTMLを生成する。loader 関数もビルド時に呼ばれる。Markdownのパースとシンタックスハイライトもビルド時に完了する。
Cloudflare Pagesでの静的配信
Cloudflare Pagesは生成されたHTMLを静的アセットとして扱う。
リクエスト: GET /blog/prerender-spider-static-html
→ Cloudflare CDNエッジが build/client/blog/prerender-spider-static-html/index.html を返す
→ Workers CPUを消費しない
→ コールドスタートが発生しないWorkers Pagesは「静的ファイルが存在するURLはWorkerをバイパスする」という動作をする。プリレンダリングされたHTMLはこの静的配信に完全に乗る。
守ろうとした価値は、【リクエスト処理をゼロにする】ことだ。配信の仕組み自体をシンプルにする。
⑤ 何を捨てたか
- 【ISR(Incremental Static Regeneration)】:Vercelが提供するISRは、Cloudflare Pages Workersでは利用できない。記事を更新するたびにフルビルドが必要になる。「更新した記事だけ再生成」はできない。
- 【リクエスト時の動的判断】:プリレンダリングされたルートでは、Cookieや認証状態に基づいた出し分けができない。パーソナライズされた表示は別ルートで処理する必要がある。
- 【デプロイとコンテンツの分離】:記事更新がそのままデプロイを意味する。CMS等で記事をサクッと公開するモデルとは相性が悪い。
- 【ビルド時間の増加】:記事が増えるにつれて、ビルド時に生成するHTMLが増える。記事が数百本になると、ビルド時間への影響が出る可能性がある。
これを許容できるかどうかが、採用判断の核心になる。
ClaudeMixのブログは、Markdownファイルをgitで管理しており、記事追加がデプロイを伴うことは自然なフローだ。この制約は許容できる。
⑥ ClaudeMix への適用
ClaudeMixでは、ブログ記事は content/blog/posts/ 配下のMarkdownファイルで管理されている。スラッグはファイル名から確定する。ビルド時に全スラッグが分かる。プリレンダリングに必要な条件が揃っている。
【実装済み】。react-router.config.ts に prerender() 関数を追加し、全ブログ記事とトップページを静的HTMLとして生成するよう変更した。loader 関数の変更は不要だった。変わったのは「このルートをいつ実行するか(リクエスト時 vs ビルド時)」という設定だけだ。
// react-router.config.ts(現在の実装)
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
async function getBlogSlugs(): Promise<string[]> {
const contentDir = join(process.cwd(), 'content/blog/posts');
const files = await readdir(contentDir);
return files
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''));
}
export default {
ssr: true,
prerender: async () => {
const slugs = await getBlogSlugs();
return [
'/blog',
...slugs.map(s => `/blog/${s}`),
];
},
};実装後に発見した問題:Cloudflare Pages の trailing-slash リダイレクト
プリレンダリングを導入して初めて顕在化した問題がある。
ViteはプリレンダリングされたHTMLを build/client/blog/slug/index.html というディレクトリ構造で生成する。Cloudflare Pagesはこのファイルを配信するとき、/blog/slug にアクセスが来ると /blog/slug/ に 308 リダイレクトする。
GET /blog/prerender-spider-static-html
→ 308 Redirect → /blog/prerender-spider-static-html/
→ GET /blog/prerender-spider-static-html/
→ 200 OK (index.html)これはSSR時には存在しなかった問題だ。SSRではWorkerが直接HTMLを返していたため、trailing-slashのリダイレクトは発生しない。プリレンダリングで静的ファイル配信に切り替えた結果として現れた。
【計測した影響】:308リダイレクトによりLCPが最大750ms増加していた。PageSpeed InsightsでのPerformanceスコアはリダイレクト解決前 74〜78、解決後 【98】 まで改善した。
【解決策】:public/_redirects にCloudflare Pagesの200 rewriteルールを追加する。
# public/_redirects
/blog /blog/ 200
/blog/:slug /blog/:slug/ 200Cloudflare Pagesの _redirects は、ブラウザへのリダイレクト(308)ではなくエッジ上での内部書き換え(200 rewrite)として処理される。ブラウザはリダイレクトを経由せず、最初のリクエストでHTMLを直接受け取る。
「静的ファイルを配信するだけだから追加設定は不要」という期待は、Cloudflare Pagesのtrailing-slash動作によって裏切られた。プリレンダリング導入のコストとして事前には予測していなかった落とし穴だ。
⑦ この設計は誰向けか
向いている
- コンテンツが静的:記事・ドキュメント・ポートフォリオなど、リクエストごとに変わらないページを持つプロジェクト
- Cloudflare Pages を使っている:静的ファイルのCDN配信がWorkerをバイパスする動作を活かせる
- gitベースのコンテンツ管理:記事追加がデプロイを伴うフローが自然なチーム
向いていない
- ユーザーごとの動的コンテンツが必要:認証状態・パーソナライズ・A/Bテストなどをルート単位で制御している場合
- CMSで記事を頻繁に更新する:Contentfulや Notion 等で非エンジニアがコンテンツを更新するフローでは、「公開 = デプロイ」の制約が障壁になる
- ISRが必要:「部分的に再ビルド」という戦略が必要な規模のコンテンツを抱えている場合(Cloudflare Pages では不可)
⑧ 思想の衝突
フルスタックフレームワークは「SSRで統一する」という引力がある。
loader を書けば動く。action を書けばデータが更新できる。全部サーバーで処理すれば、クライアントの複雑さが減る。その一貫性は価値だ。
Prerenderは、その引力に「待って、これは本当にSSRが必要か」と問いを返す。
Remixが prerender オプションを提供したのは、「SSRとStaticを選べる」という設計判断の公式な表明だ。ルートごとに選択できる。全部SSRでなくてもいい。全部Staticでなくてもいい。
蜘蛛は巣を張る前に全経路を知っている。ビルド時にURLを確定させ、静的なHTMLを織り上げ、それ以降は何もしない。この「何もしない」という価値は、実行時のシステムを単純にすることへの強いコミットメントだ。
【「リアルタイムに何でも対応できる柔軟性が欲しい」】 という価値観では、SSRが選ばれる。
【「存在が確定しているものは、確定した形で配信したい」】 という価値観では、Prerenderが選ばれる。
どちらが正しいかではなく、コンテンツの性質とシステムの複雑性トレードオフをどこで引くかだ。
設計とは、何を便利にするかではない。
どの価値観を、どの順番で優先するかだ。
⑨ あなたはどちらを選ぶか
静的なコンテンツをリクエストごとに処理するか。ビルド時に確定させて後は配信するだけにするか。
更新の即時反映を優先するか。配信の確実性とゼロCPUを優先するか。
「SSRで統一する」という一貫性を取るか。「コンテンツの性質に合わせて選ぶ」という適切性を取るか。
この記事はClaudeMixで動いている。設計判断を二択で問い続けるプロダクトだ。あなたが今読んだ問いも、そこから来ている。
