個人サイトをオリジンサーバーなしで運用する — Cloudflare Workers SSR の構成とハマりどころ
このサイトはオリジンサーバーを持たない。SSR も外部 API 連携も Cloudflare Workers だけで完結し、運用コストはほぼゼロ。鍵は「外部 API への fetch をすべてエッジキャッシュに束ねる」設計で、 その実装と実際にハマった罠をこの記事にまとめる。
- 対象読者: 個人サイトや小規模サービスを Cloudflare Workers で SSR 運用したい人。React と fetch API の基本を前提とする
- 環境: TanStack Start 1.167 / @cloudflare/vite-plugin 1.39 / wrangler 4.95
- わかること: 外部 API をエッジキャッシュで束ねる実装パターン、ConnectRPC を CF キャッシュに乗せる方法、SSR バンドルで実際に踏んだ罠
- 扱わないこと: Workers の課金体系、D1/KV を使った永続化
01結論: リクエストはエッジで完結させる
構成の要点は3つ。① SSR は Worker、静的アセットは Workers Static Assets(Worker の起動すら発生しない)、② 外部 API(Zenn / GitHub / Bluesky / 自前の ConnectRPC)はすべて SSR の loader から fetch し、エッジに 1〜24 時間キャッシュ、③ どの連携が落ちてもセクションが消えるだけでページは壊れない。DB も cron も持たないため、障害点は実質 Worker 1 つになる。
02外部 API は fetch の cf オプションで束ねる
結論から言うと、自前のキャッシュ層を書く必要はない。Workers の fetch はcf オプションを渡すだけでレスポンスをエッジキャッシュに乗せられる。このサイトの全連携は次の 1 パターンで実装している。
const FETCH_INIT = {
headers: { "User-Agent": "ken109.dev (https://ken109.dev)" },
cf: { cacheTtl: 3600, cacheEverything: true }
} as RequestInit;
const response = await fetch(ZENN_API, FETCH_INIT);最初はモジュールスコープの変数に TTL 付きで持つ「メモリキャッシュ」を書いたが、すぐ捨てた。比較すると:
注意点は3つ。
cf.cacheTtlが効くのは GET リクエストのみ。POST は素通りする- JSON API のレスポンスは既定ではキャッシュ対象外のため
cacheEverything: trueで対象化する。オリジンのCache-Controlを上書きして TTL を強制するのはcacheTtlの役割 - キャッシュは colo(データセンター)単位でグローバル共有ではない。世界中からのアクセスは colo 毎に 1 回ずつ API を叩く。それでも個人サイトなら GitHub API の未認証レート制限(60 req/h/IP)に収まるし、 気になる場合は Smart Tiered Cache(全プラン無料・オプトイン)で上位 colo に集約できる
もう1つ、実際に踏んだ罠: dev.to API が User-Agent なしのリクエストを拒否し、Promise.allSettled で握りつぶしていたため「Zenn だけ表示されて dev.to が消える」状態に気づくのが遅れた。複数ソースを束ねるときは、片方だけ失敗した不完全な結果をキャッシュしないこと。
03ConnectRPC は NO_SIDE_EFFECTS で GET にする
自前 API(ConnectRPC)の読み取り RPC も同じパターンに乗せたい。しかし Connect の RPC はデフォルトで POST なのでエッジキャッシュに乗らない。結論: proto に idempotency_level = NO_SIDE_EFFECTS を付けると GET で叩けるようになる。
service StatsService {
rpc GetPublicStats(GetPublicStatsRequest) returns (GetPublicStatsResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
}
}クライアント側はコード生成すら不要で、素の fetch で叩ける:
GET https://api.example.com/pkg.v1.StatsService/GetPublicStats
?encoding=json&message=%7B%7D&connect=v1
// → {"publicWallpaperCount":19, "creatorCount":6, "totalLicenseCount":"81"}ここに罠が1つ。レスポンス末尾を見ると totalLicenseCount だけ文字列になっている。proto の int64 は JSON では精度保護のため文字列にシリアライズされる仕様で、Number() での変換を忘れると NaN や文字列連結バグになる。
04SSR バンドルのハマりどころ
結論: Worker にバンドルされるのは「実行されるコード」ではなく「import されるコード」全部。具体例を2つ。
- 巨大依存が SSR バンドルに入る: 図表描画に使っている elkjs はクライアントで動的 import しているが、チャンクとしては Worker 側にも含まれ gzip 537KB を占める。Workers のサイズ上限(無料 3MB / 有料 10MB gzip)に近づいたら最初に疑う場所
- 環境変数の取り回し:
process.envはnodejs_compatフラグ + 新しめの互換日付(本サイトは 2025-09-02 で確認)で Worker の env から自動的に展開される。secret はwrangler secret put、ローカルは.dev.vars(gitignore 必須)
もう1つ設計面で効いたのは graceful degradation。各連携の取得関数は失敗時に null / 空配列を返し、UI 側は「セクションごと非表示」に倒す。外部 API が全部死んでもページは壊れず、表示が減るだけになる。
export const fetchStats = createServerFn({ method: "GET" }).handler(async () => {
try {
const response = await fetch(API, FETCH_INIT);
if (!response.ok) return null; // ← throw しない
return await response.json<Stats>();
} catch {
return null; // ← UI 側: {stats && <StatsBand />}
}
});05この構成が向くケース・向かないケース
向くケース
- 個人サイト・ドキュメントサイト・LP: コンテンツがコードと API 集約で完結する
- 表示する外部データに「数時間の鮮度」で十分なもの(記事一覧・統計・SNS フィード)
向かないケース
- 書き込みが主体のアプリ: キャッシュ戦略よりデータストア(D1 / 外部 DB)の設計が支配的になる
- リアルタイム性が必要なデータ(価格・在庫など): TTL キャッシュの恩恵がない
- colo を跨いだ厳密なキャッシュ一貫性が必要な場合: cf オプションでは制御できず KV / DO が必要
逆に言えば、読み取り中心で鮮度要件が緩いサイトなら、この構成は「無料枠・デプロイ 1 分・運用ゼロ」で回る。 まず cf: { cacheTtl, cacheEverything } の 1 行から試してみてほしい。