Cloudflareのサービス群を組み合わせてSaaSを作ろうとしたとき、最初に直面したのは「何を使えばいいのかよくわからない」という問題でした。D1、Workers、Pages、R2、KV、Workers AI…。それぞれのサービスのドキュメントを読んでもピンとこず、「とりあえず全部使ってみよう」とした結果、コストが想定外に膨らんだり、Workers のCPU制限に引っかかったりと、痛い目を見ました。
Antigravityを使うようになってから、このCloudflareスタックの構築体験が大きく変わりました。各サービスの設定ファイルを書かせたり、Drizzle ORMのスキーマをD1の制約に合わせて修正したりする作業を任せられるようになって、自分は「どう設計するか」の判断に集中できるようになりました。
ここでは私が実際にAntrigravityを使って構築した、Cloudflare エッジSaaSのアーキテクチャ設計を体系的に解説します。「動くサンプル」ではなく、「本番で使えるパターン」を中心に据えました。
Cloudflare エッジスタックを選んだ理由と、最初にぶつかった壁
個人開発者としてSaaSを作る場合、インフラコストは死活問題です。Vercel + Supabase、AWS Lambda + RDS、Firebase Hosting + Firestore…と試してきた中で、Cloudflareに落ち着いた理由は主に3つです。
1つ目はエッジでの実行です。Workers は世界300か所以上のエッジロケーションで動くため、ユーザーのそばで処理が完結します。特に、APIのレスポンスタイムがUXに直結するアプリケーションでは、この差は体感できるレベルです。
2つ目はコストモデルの透明性です。Workersの課金はリクエスト数ベース(無料枠で1日10万リクエスト)で、初期のトラフィックが少ない段階でのコストが実質ゼロに近くなります。D1もストレージ5GBまで無料なので、MVPフェーズはほぼ無コストで動かせます。
3つ目は開発体験です。wrangler dev でローカル環境がCloudflareの本番環境に近い状態でエミュレートされるため、「ローカルで動いたのに本番で動かない」という事態が減りました。
ただし、最初につまずいたポイントがいくつかあります。D1のSQLite互換性、WorkersのCPU制限(デフォルト10ms)、Workers AIのモデルロード時間——これらは公式ドキュメントを読んでいても想定しにくい問題で、本番で初めて気づくことが多いです。
全体アーキテクチャの設計図: 役割分担の考え方
エッジSaaSの設計で最も重要な判断は「何をエッジで処理するか」です。私が実際に使っているアーキテクチャの役割分担を整理します。
ユーザー
↓
Cloudflare Pages(Next.js / フロントエンド)
↓ API リクエスト
Cloudflare Workers(APIサーバー / 認証 / ビジネスロジック)
├─ D1(SQLiteデータベース / ユーザーデータ・コンテンツ)
├─ KV(セッション / 短期キャッシュ / Feature Flags)
├─ R2(ファイルストレージ / 画像・ドキュメント)
└─ Workers AI(軽量AI推論 / テキスト分類・感情分析)
↑ ヘビーな推論は外部API(Gemini / OpenAI)へ
D1を使う場面: ユーザーデータ、コンテンツ、トランザクションが必要なデータ全般。SQLなので既存の開発知識がそのまま使えます。
KVを使う場面: セッションストア、短期キャッシュ(TTL付き)、Feature Flagsの格納。D1より高速ですが、強一貫性が保証されません(結果整合性)。
R2を使う場面: ユーザーがアップロードするファイル、生成した画像、PDFなど。S3互換なので既存のコードが流用できます。
Workers AIを使う場面: テキスト分類、感情分析、短文生成など軽量な推論。複雑な推論は外部APIを使ったほうが品質・速度ともに優れています。
Antigravityに「このアーキテクチャ図を基に、Workersのルーティング設計を実装して」と依頼すると、Hono.jsを使ったルーター実装を出力してくれます。ゼロから書くより格段に速いですが、Workers固有の制約(CPU時間・メモリ制限)への対応は自分でレビューが必要です。
D1 + Drizzle ORM: スキーマ設計とN+1問題の現実
D1はSQLiteをCloudflareのエッジで動かすサービスです。「本番で使えるのか?」と疑問に思う方も多いと思いますが、読み取り中心のユースケース(ブログ、コンテンツ配信、SaaSのダッシュボード表示)なら十分実用的です。
Drizzle ORMとの組み合わせが現時点でベストプラクティスです。TypeScriptの型推論が優れており、D1との相性も良好です。
// src/db/schema.ts
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // ULIDを使うと時系列ソート可能
email: text('email').notNull().unique(),
planType: text('plan_type', { enum: ['free', 'pro', 'premium'] }).notNull().default('free'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
emailIdx: index('email_idx').on(table.email),
planTypeIdx: index('plan_type_idx').on(table.planType),
}));
export const subscriptions = sqliteTable('subscriptions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
stripeSubscriptionId: text('stripe_subscription_id').unique(),
status: text('status', { enum: ['active', 'canceled', 'past_due'] }).notNull(),
currentPeriodEnd: integer('current_period_end', { mode: 'timestamp' }).notNull(),
}, (table) => ({
userIdIdx: index('subscription_user_id_idx').on(table.userId),
}));
N+1問題の現実と対策
D1はエッジのSQLiteのため、クエリのラウンドトリップコストが通常のデータベースより高くなることがあります。N+1問題が発生すると、本番環境での体感速度が著しく低下します。
// ❌ N+1問題が発生するコード
async function getUsersWithSubscriptions(db: DrizzleD1Database) {
const users = await db.select().from(usersTable);
// userごとに1回クエリが走る = N+1問題
const result = await Promise.all(
users.map(async (user) => ({
...user,
subscription: await db.select()
.from(subscriptionsTable)
.where(eq(subscriptionsTable.userId, user.id))
.get(),
}))
);
return result;
}
// ✅ JOIN で1クエリにまとめる(推奨)
async function getUsersWithSubscriptions(db: DrizzleD1Database) {
const result = await db
.select({
user: usersTable,
subscription: subscriptionsTable,
})
.from(usersTable)
.leftJoin(subscriptionsTable, eq(usersTable.id, subscriptionsTable.userId));
return result;
}
バッチAPIの活用
どうしてもN+1になる構造の場合は、D1の batch APIを使います。複数クエリを1回のRTTで処理できます。
// ✅ D1 Batch APIで複数クエリを1RTTで実行
async function batchFetchUserData(env: Env, userIds: string[]) {
const db = drizzle(env.DB);
const batchResult = await env.DB.batch([
env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userIds[0]),
env.DB.prepare('SELECT * FROM subscriptions WHERE user_id = ?').bind(userIds[0]),
env.DB.prepare('SELECT COUNT(*) as count FROM articles WHERE author_id = ?').bind(userIds[0]),
]);
// 期待する出力:
// batchResult[0].results → ユーザー情報
// batchResult[1].results → サブスクリプション情報
// batchResult[2].results → [{ count: 42 }]
return batchResult;
}
Antigravityにこのパターンを教えると、以降のコード生成でN+1を避けたクエリを書いてくれるようになります。agents.mdに「D1のN+1問題を避けるため、必ずJOINかBatch APIを使う」と記載しておくのが効果的です。
Workers API: 認証・ルーティング・レート制限の実践実装
WorkersをAPIサーバーとして使う場合、Honoが最もよく使われているフレームワークです。軽量でWorkers向けに最適化されており、Antigravityとの相性も良好です。
// src/worker.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { bearerAuth } from 'hono/bearer-auth';
import { rateLimiter } from './middleware/rate-limiter';
type Bindings = {
DB: D1Database;
KV: KVNamespace;
R2: R2Bucket;
AI: Ai;
};
const app = new Hono<{ Bindings: Bindings }>();
// CORS設定(本番では origin を限定する)
app.use('/api/*', cors({
origin: ['https://your-app.pages.dev', 'https://yourapp.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
}));
// レート制限: KVを使ったシンプルな実装
app.use('/api/*', async (c, next) => {
const ip = c.req.header('CF-Connecting-IP') ?? 'unknown';
const key = `rate_limit:${ip}`;
const current = await c.env.KV.get(key);
if (current !== null && parseInt(current) >= 100) {
return c.json({ error: 'Rate limit exceeded' }, 429);
}
// リクエストカウントを増加(TTL: 60秒)
const newCount = current ? parseInt(current) + 1 : 1;
await c.env.KV.put(key, newCount.toString(), { expirationTtl: 60 });
await next();
});
// JWT認証ミドルウェア
app.use('/api/protected/*', async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
const token = authHeader.slice(7);
// KVにセッションを保存する実装
const sessionData = await c.env.KV.get(`session:${token}`);
if (!sessionData) {
return c.json({ error: 'Invalid or expired token' }, 401);
}
// コンテキストにユーザー情報をセット
c.set('user', JSON.parse(sessionData));
await next();
});
// ヘルスチェック(WorkersのCPU制限テスト用)
app.get('/api/health', (c) => c.json({ status: 'ok', timestamp: Date.now() }));
export default app;
WorkersのCPU制限への対応
WorkersはデフォルトでCPU時間10ms(Paid Planで50ms)の制限があります。これはCPU時間であり、I/O待機時間はカウントされません。つまり、D1クエリやR2アクセスの待機時間は問題ありませんが、複雑な計算処理や大きなJSONのパースは制限に引っかかることがあります。
// ❌ 大きな配列のソートはCPU時間を消費する
function processLargeDataset(data: unknown[]) {
return data
.filter(/* 複雑なフィルタリング */)
.sort(/* 複雑なソート */)
.map(/* 重い変換処理 */);
}
// ✅ D1のSQLでソート・フィルタリングを処理する(データベース側に委譲)
async function processLargeDataset(db: DrizzleD1Database, filters: FilterOptions) {
return db
.select()
.from(articlesTable)
.where(and(
eq(articlesTable.status, 'published'),
gte(articlesTable.createdAt, filters.fromDate),
))
.orderBy(desc(articlesTable.createdAt))
.limit(50); // 必ずLIMITをつける
}
R2 ファイル管理: 署名付きURL設計とコスト計算
R2はS3互換のオブジェクトストレージです。S3と比較してエグレス料金(データ転送費用)がゼロというのが最大の強みです。
// src/handlers/upload.ts
import { Hono } from 'hono';
type Bindings = { R2: R2Bucket };
const uploadRouter = new Hono<{ Bindings: Bindings }>();
// ✅ 署名付きURLを発行してクライアントから直接R2にアップロード
// Workers を経由するとメモリ・CPU制限に引っかかるため、必ず事前署名URLを使う
uploadRouter.post('/api/upload/presign', async (c) => {
const { filename, contentType } = await c.req.json<{
filename: string;
contentType: string;
}>();
// ファイル名をサニタイズ
const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
const key = `uploads/${Date.now()}-${sanitizedFilename}`;
// 許可するContent-Typeをホワイトリスト管理
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (!allowedTypes.includes(contentType)) {
return c.json({ error: 'Unsupported file type' }, 400);
}
// R2の署名付きURLを発行(有効期限: 5分)
// 注意: createPresignedUrl はCloudflare Workers環境で動作する
const url = await c.env.R2.createPresignedUrl(key, {
method: 'PUT',
expiresIn: 300, // 5分
httpMetadata: { contentType },
});
// 期待する出力: { uploadUrl: "https://...", key: "uploads/1234567890-file.jpg" }
return c.json({ uploadUrl: url, key });
});
// アップロード完了後のDBへの記録
uploadRouter.post('/api/upload/complete', async (c) => {
const user = c.get('user');
const { key } = await c.req.json<{ key: string }>();
// R2にファイルが存在するか確認
const object = await c.env.R2.head(key);
if (!object) {
return c.json({ error: 'File not found in storage' }, 404);
}
// DBに記録
const db = drizzle(c.env.DB);
await db.insert(filesTable).values({
id: ulid(),
userId: user.id,
key,
size: object.size,
contentType: object.httpMetadata?.contentType ?? 'application/octet-stream',
createdAt: new Date(),
});
return c.json({ success: true, key });
});
export { uploadRouter };
R2のコスト計算(実績値)
私が運営しているアプリの場合、月間1,000アップロード(平均2MB)で計算すると:
- ストレージ: 2GB × $0.015/GB/月 = $0.03
- クラスA操作(書き込み): 1,000リクエスト × $4.50/100万 = $0.0045
- エグレス: $0(Cloudflare CDN経由は無料)
VPCやS3と比べると驚くほど安価です。ただし**クラスBオペレーション(読み取り)**は$0.36/100万リクエストなので、高頻度アクセスのファイルはR2から直接配信するよりもCloudflare CDNキャッシュを活用するほうが合理的です。
Workers AI: エッジAI推論の「使いどころ」と「使わないべき場面」
Workers AIは、CloudflareのエッジでAIモデルを実行できるサービスです。プライバシーデータを外部APIに送りたくない場面や、低レイテンシが重要な軽量推論に適しています。
// src/handlers/ai-classify.ts
// Workers AI でテキストカテゴリ分類を実行
async function classifyUserInput(env: Env, text: string): Promise<{
category: string;
confidence: number;
}> {
// @cf/huggingface/distilbert-sst-2-int8 は感情分析に特化した軽量モデル
const result = await env.AI.run('@cf/huggingface/distilbert-sst-2-int8', {
text,
}) as { label: string; score: number }[];
// 期待する出力例:
// [{ label: "POSITIVE", score: 0.9987 }, { label: "NEGATIVE", score: 0.0013 }]
if (!result || result.length === 0) {
throw new Error('AI inference returned empty result');
}
const topResult = result.sort((a, b) => b.score - a.score)[0];
return {
category: topResult.label,
confidence: topResult.score,
};
}
// テキスト要約(短いコンテンツのみ推奨)
async function summarizeText(env: Env, text: string): Promise<string> {
// Workers AIのモデルは入力トークン数に制限があるため、長文は切り詰める
const truncatedText = text.slice(0, 1000); // 1000文字程度に制限
const result = await env.AI.run('@cf/facebook/bart-large-cnn', {
input_text: truncatedText,
max_length: 150, // 要約の最大長
}) as { summary: string };
return result.summary;
}
Workers AIを「使わないべき」場面
実際に使ってみてわかったのは、Workers AIはすべてのユースケースに適しているわけではないということです。
- 複雑な推論・長文生成: Gemini 2.5 Flash や Claude などの外部APIのほうが品質・コストともに優秀
- 画像生成: 商用品質の生成には Midjourney / DALL-E 等を使う
- リアルタイム音声認識: Workers AIのレイテンシは低いが、Whisper APIのほうが精度が高い
Workers AIの本当の強みは「データをCloudflareの外に出さずに軽量推論できる」点にあります。プライバシーが重要なエンタープライズ向け機能に特に有効です。
Stripe サブスクリプション連携: Webhook + D1 状態管理
SaaSの課金はStripeが事実上の標準です。WorkersでStripe Webhookを受け取り、D1に課金状態を保存する実装を解説します。
// src/handlers/stripe-webhook.ts
import Stripe from 'stripe';
export async function handleStripeWebhook(
request: Request,
env: Env,
): Promise<Response> {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return new Response('Missing signature', { status: 400 });
}
let event: Stripe.Event;
try {
// Workers環境では SubtleCrypto を使った非同期検証が必要
const stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2025-01-27.acacia' });
event = await stripe.webhooks.constructEventAsync(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
undefined,
Stripe.createSubtleCryptoProvider(),
);
} catch (err) {
console.error('Webhook verification failed:', err);
return new Response('Invalid signature', { status: 400 });
}
const db = drizzle(env.DB);
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.CheckoutSession;
const userId = session.metadata?.userId;
if (!userId) {
console.error('Missing userId in checkout session metadata');
break;
}
// D1にサブスクリプション状態を保存
await db.insert(subscriptionsTable)
.values({
id: ulid(),
userId,
stripeSubscriptionId: session.subscription as string,
status: 'active',
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 仮の値
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: subscriptionsTable.userId,
set: {
status: 'active',
stripeSubscriptionId: session.subscription as string,
updatedAt: new Date(),
},
});
// KVにもキャッシュ(ダッシュボードの表示速度向上のため)
await env.KV.put(
`subscription:${userId}`,
JSON.stringify({ status: 'active' }),
{ expirationTtl: 3600 }, // 1時間キャッシュ
);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
const userId = subscription.metadata?.userId;
if (!userId) break;
await db.update(subscriptionsTable)
.set({ status: 'canceled', updatedAt: new Date() })
.where(eq(subscriptionsTable.userId, userId));
// KVキャッシュを削除
await env.KV.delete(`subscription:${userId}`);
break;
}
}
return new Response(JSON.stringify({ received: true }), {
headers: { 'Content-Type': 'application/json' },
});
}
本番で直面した5つの落とし穴と解決策
実際に本番環境でCloudflare エッジSaaSを運用して、想定外の問題に直面したことをまとめます。これを知っておくだけで数時間のデバッグ時間が節約できます。
落とし穴1: D1のread replicaによる結果整合性
D1は複数のリードレプリカにデータを複製しますが、書き込み直後に読み取ると古いデータが返ることがあります。ユーザー登録直後にダッシュボードにリダイレクトしたら「ユーザーが見つかりません」というエラーが出た原因がこれでした。
解決策: 書き込み後の読み取りはKVキャッシュに新しいデータを手動で反映させるか、短時間(100〜500ms)のウェイトを挟む。または、書き込み直後の読み取りは不要な設計にリファクタリングします。
落とし穴2: Workers の128MB メモリ制限
大きなJSONレスポンスをメモリ上で操作するとメモリ制限に達することがあります。特に、D1から数千件のレコードを取得してJSON化する処理で発生しました。
解決策: ページネーションを必ず実装する(LIMITは最大100件程度)。TransformStreamを使ったストリーミングレスポンスを検討します。
落とし穴3: R2の事前署名URLの期限切れ処理
フロントエンドで事前署名URLを取得後、ユーザーがファイル選択に時間をかけると期限切れ(デフォルト5分)になることがあります。
解決策: フロントエンド側でアップロード開始直前に事前署名URLを取得します。または有効期限を15分程度に延長します。
落とし穴4: wrangler.toml の設定ミス
[build] セクションをトップレベルフィールドの前に配置するとデプロイが失敗します。WorekrのNameも rorklab ではなく rorklabnet のようにサイトごとに正確に設定する必要があります。
解決策: wrangler.toml のファイル先頭に name, main, account_id, compatibility_date, compatibility_flags を配置し、[build] セクションはその後に記述します。
落とし穴5: WorkersとPages間のCORS設定
PagesアプリからWorkersのAPIを呼ぶ場合、開発環境(localhost)と本番環境でオリジンが異なるため、Access-Control-Allow-Origin の設定が漏れやすいです。
解決策: Workers側のCORSミドルウェアで origin を環境変数で管理します。開発時は ["http://localhost:3000"]、本番時は ["https://yourapp.pages.dev", "https://yourapp.com"] を設定します。
Antigravity でエッジSaaS開発を加速するワークフロー
Antigravityを使ったCloudflare開発で効果的だったパターンを紹介します。
agents.md へのCloudflare固有制約の記録
# プロジェクトコンテキスト: Cloudflare Edge SaaS
## 重要な制約
- Workers CPU制限: 10ms(Paid: 50ms)。重い計算はD1/KVに委譲する
- D1読み取り: 結果整合性あり。書き込み直後の読み取りに注意
- Workers メモリ: 128MB。大きなデータはページネーション必須(LIMIT 100)
- R2アップロード: Workers経由禁止。必ず事前署名URLを使う
- Stripe Webhook: constructEventAsync + SubtleCryptoProvider を使う
## 使用スタック
- Runtime: Cloudflare Workers + Hono
- ORM: Drizzle ORM(D1バインディング)
- フロントエンド: Next.js on Cloudflare Pages
- 課金: Stripe(Webhook経由でD1に状態保存)
このコンテキストをAgentに与えておくだけで、WorkersのCPU制限を無視したコードや、R2に直接Workers経由でアップロードするコードを生成しなくなります。
デプロイフローの自動化
# Antigravityに依頼するデプロイコマンド生成
# "staging環境にデプロイして、ヘルスチェックが通ったら本番にデプロイするスクリプトを作って"
# 生成されたスクリプト例:
#!/bin/bash
set -e
echo "🚀 Deploying to staging..."
wrangler deploy --env staging
# ヘルスチェック
HEALTH=$(curl -s https://staging.yourapp.workers.dev/api/health | jq -r '.status')
if [ "$HEALTH" != "ok" ]; then
echo "❌ Health check failed on staging"
exit 1
fi
echo "✅ Staging healthy. Deploying to production..."
wrangler deploy --env production
echo "🎉 Production deployment complete"
このようなスクリプトもAntrigravityに「Workers固有の制約を考慮して」と伝えれば、適切なものを生成してくれます。
ローカル開発環境の整備: wrangler dev の活用
本番同等環境をローカルで再現することで、「ローカルでは動いたのに本番で動かない」という問題を最小化できます。
# ローカルD1データベースの初期化
wrangler d1 execute my-db --local --file=./migrations/0001_initial.sql
# バインディングをすべてローカルエミュレーション
wrangler dev --local
# ローカルR2エミュレーション(ファイルはローカルに保存される)
# wrangler.toml に以下を追加:
# [[r2_buckets]]
# binding = "R2"
# bucket_name = "my-bucket"
# preview_bucket_name = "my-bucket-preview"
wrangler dev --local を使うと、D1・KV・R2・Workers AIすべてがローカルでエミュレートされます。本番デプロイ前にほぼすべての動作確認が可能です。
記事のサンプルで終わらせないために — 複数サイトを Workers で運用して見えた境界
ここまでは「一つの SaaS をどう組むか」という視点で書いてきました。ただ、私が日々向き合っているのは、同じ Cloudflare スタックの上で、個人開発として複数の本番サイトを同時に走らせ続ける状況です。記事生成を自動化したブログを四つ、いずれも Next.js を Workers 上で動かしています。一つを動かすときには見えなかった壁が、数を増やすとはっきり姿を現しました。
この節では、サンプルアプリでは決して踏まない領域——本番を回し続けてはじめて知る制約を、三つに絞って共有いたします。
Worker バンドルの 62 MiB 制限と、コンテンツを外に出す設計
最初に手痛い思いをしたのが、デプロイ時の「バンドルが大きすぎる」というエラーでした。記事のメタデータと本文 HTML を一つの JSON にまとめてバンドルに同梱していたところ、記事が数百本を超えたあたりで Worker のバンドルが 62 MiB の上限に近づいていったのです。
Workers はエッジへ配るために実行ファイルそのものを各ロケーションへ複製します。だからこそ、本文のような「読むときだけ必要な重いデータ」を実行バンドルに抱えさせる設計は、規模が増えるほど自分の首を絞めます。
解決の方向は、データの「層」を分けることでした。
articles.json にはタイトル・スラッグ・カテゴリといったメタデータだけを残す
- 本文 HTML は
public/content/articles/{locale}/{category}/{slug}.html として一本ずつ個別ファイルに出す
- 実行時は ASSETS バインディング経由で必要な一本だけを取りに行く
// content.ts — 本文は ASSETS から取得する(バンドルに含めない)
export async function getArticleContent(
locale: string, category: string, slug: string
): Promise<string | null> {
const { env } = getCloudflareContext();
const url = `https://assets.local/content/articles/${locale}/${category}/${slug}.html`;
const res = await env.ASSETS.fetch(url);
if (!res.ok) return null;
return res.text();
}
ここで一つ落とし穴があります。「自分のホスト名へ fetch() すれば取れるのでは」と考えたくなりますが、Workers の内部では自ホストへの fetch は期待どおりに解決しません。必ず ASSETS バインディングの fetch を使う、という点だけは譲れない勘所です。
メタデータと本文を分けてからは、記事を何本増やしてもバンドルの重さは一定に保たれるようになりました。「動くもの」と「運用し続けられるもの」の違いは、この一線にあると感じています。
エッジキャッシュと「会員にだけ見せたい本文」の衝突
エッジキャッシュは速度の要ですが、課金読者にだけ全文を届けたいプロダクトでは、そのまま使うと事故になります。あるユーザーがキャッシュした全文ページが、別の非会員にもそのまま配られてしまうからです。
私は Workers の前段に薄いキャッシュワーカーを置き、判定を一つだけ挟みました。premium_token もしくは記事購入を示す Cookie を持つリクエストは、キャッシュを素通りさせて常にオリジンへ渡す、という分岐です。
// cache-worker.js — 会員・購入者はキャッシュをバイパス
const cookie = request.headers.get("Cookie") || "";
const isMember = cookie.includes("premium_token") || cookie.includes("article_purchases");
if (isMember) {
return fetch(request); // キャッシュに触れず常に最新を返す
}
あわせて、デプロイのたびに古いキャッシュをまとめて失効させたいので、DEPLOY_VERSION という定数を一つ持たせています。値を変えるだけで全エッジのキャッシュが一斉に無効化されるため、記事の差し替え後に古い本文が残り続ける、という事態を防げます。
「速さ」と「出し分け」は、放っておくと必ずぶつかります。その境界線をどこに引くかを最初に決めておくと、後からの手戻りがほとんどなくなりました。
一瞬のエラーページがキャッシュに焼き付く問題
最も気づきにくかったのが、これでした。Next.js は SSR のストリーミング中に例外が起きても、HTTP ステータスは 200 のままエラー用の UI を流すことがあります。デプロイの切り替わる一瞬に、本文が空のページや、エラー画面が「正常な 200」として生成されてしまう瞬間があったのです。
素朴なキャッシュワーカーは「200 の HTML なら良いもの」とみなして、その壊れたページをエッジに数時間貼り付けてしまいます。リロードすれば直る断続的な「読み込みエラー」の正体は、ここにありました。
対策は、キャッシュに入れる前に中身を疑うことです。
// 壊れたHTMLはキャッシュに入れない
const body = await response.clone().text();
const looksBroken =
body.includes("data-error-boundary") || // エラー境界が描画された
!body.includes("</html>") || // HTMLが途中で切れている
body.includes('class="article-content"></'); // 本文が空
if (looksBroken) {
return response; // そのまま返すが、キャッシュには保存しない
}
エラー境界には目印になる属性を付け、本文が空であることや </html> の欠落も合わせて検知します。さらに、エッジのアセット取得が一瞬失敗したときのために、読み取りを一度だけ再試行する保険も入れました。
この三点を入れてから、断続的な読み込みエラーの報告は静かに止みました。エッジは速いぶん、間違ったものも速く・広く配ってしまう——その怖さを、私自身、運用ではじめて身体で理解した出来事でした。
全体を振り返って: 今日から始める最初の一歩
Cloudflareエッジスタックは、個人開発者がSaaSを作る上で非常に理にかなった選択肢だと思っています。インフラコストが低く、グローバルな低レイテンシを無料枠で実現できる点は、スタートアップやインディーデベロッパーに特に向いています。
ただし、D1の結果整合性・WorkersのCPU制限・R2の事前署名URL設計など、Cloudflare固有の制約を理解した上で設計する点が肝心です。これらを知らないまま実装を進めると、本番で想定外の問題に直面します。
Antigravityを使う場合は、agents.md にCloudflare固有の制約を最初に記録しておくことを強くお勧めします。これだけで、生成されるコードの品質が大きく変わります。
次の一歩として: まずは wrangler generate で新しいWorkersプロジェクトを作成し、D1 + Honoの最小構成からスタートしてみてください。Antigravityに「Cloudflare Workers + Hono + D1の最小構成のAPIサーバーを作って」と依頼すれば、この記事で紹介したパターンを踏まえた実装が得られます。
Cloudflareのエッジスタックは進化が速く、D1のGA以降も新機能が次々と追加されています。公式のCloudflare Developers Blogを定期的にチェックしておくと、有用な機能追加のタイミングを見逃しません。
また、Cloudflareスタック全体を使った実装の参考書として、CloudflareのドキュメントとWorkers AI公式ガイドは無料で充実した内容ですが、「どのサービスを組み合わせてSaaSを作るか」という設計判断の観点では、『SaaS構築の教科書(著: 複数著者)』のようなアーキテクチャ寄りの書籍を手元に置いておくと、設計の迷いが減るかもしれません。