はじめに
個人開発でSaaSを作るとき、「技術スタックをどう選ぶか」が最初の大きな悩みです。スケーラビリティ、コスト、開発速度——すべてを満たす構成はなかなか見つかりません。
この記事では、実際にDirebase(ディレベース)を開発した際に採用した Next.js × Cloudflare Workers × D1 の技術スタックを、学習者向けに解説します。なぜこの組み合わせを選んだのか、どう実装するのか、どこでハマったのかを具体的にまとめています。
技術スタック全体像
アーキテクチャ図
| レイヤー | ホスティング | 使用技術 | 役割 |
|---|---|---|---|
| フロントエンド | Vercel | Next.js(App Router)+TypeScript/Tailwind CSS+shadcn/ui | 画面表示・SEO |
| API | Cloudflare Workers | Hono+TypeScript | ビジネスロジック(fetchでフロントから呼び出す) |
| データベース | Cloudflare | D1(SQLite) | 永続化。Workersからバインディングで直接アクセス |
| 外部API | — | PageSpeed Insights API | 計測データの取得 |
| 決済 | — | Stripe | サブスクリプション課金・Webhook |
フロントエンドはVercel、APIはCloudflare Workersという分離構成です。それぞれが得意な領域を担当することで、コストと開発速度の両方を最適化できます。サーバー・DB・APIの役割分担そのものを整理したい場合はバックエンドとは?サーバー・DB・APIの全体像もあわせてどうぞ。
技術選定の理由
| 技術 | 役割 | 選んだ理由 |
|---|---|---|
| Next.js (App Router) | フロントエンド | SSG/SSRの柔軟な切り替え、SEOメタタグ生成が簡単 |
| Cloudflare Workers | APIサーバー | コールドスタートなし、グローバル配信、無料枠が広い |
| Hono | Webフレームワーク | Workers向けに設計された軽量フレームワーク、型安全なルーティング |
| Cloudflare D1 | データベース | SQLiteベースで学習コスト低、Workersとのバインディングがシンプル |
| Stripe | 決済 | 実装の信頼性が高く、Webhook処理が堅牢 |
| Vercel | フロントホスティング | Next.jsと親和性が最高、デプロイが自動化 |
コールドスタートとは、サーバーがリクエストを受け取る前に起動時間が発生する問題です。Cloudflare WorkersはJavaScriptをV8エンジン上で直接実行するため、この起動時間がほぼゼロです。AWS LambdaやCloud Functionsでは数百ms〜数秒かかることがある問題を回避できます。
モノレポ構成
モノレポとは、フロントエンドとAPIなど複数のアプリケーションを1つのGitリポジトリで管理する構成です。チーム開発ではTurborepoやNxがよく使われますが、個人開発ではシンプルな手動構成で十分です。
my-saas/
├── apps/
│ └── web/ # Next.js フロントエンド
│ ├── app/
│ │ └── [locale]/ # 多言語ルーティング(後述)
│ ├── components/ # UIコンポーネント
│ └── lib/ # ユーティリティ・翻訳・型定義
├── packages/
│ ├── api/ # Cloudflare Workers API
│ │ ├── src/
│ │ │ ├── routes/ # エンドポイント定義
│ │ │ ├── lib/ # ビジネスロジック
│ │ │ └── middleware/ # 認証・バリデーション
│ │ └── wrangler.toml # Cloudflare設定ファイル
│ └── database/
│ ├── schema.sql # D1スキーマ定義
│ └── migrations/ # マイグレーションファイル
├── package.json
└── tsconfig.json
フロントとAPIを apps/ と packages/ で分けることで、責務が明確になります。個人開発であればこのくらいのシンプルな粒度が、保守しやすくちょうどよいバランスです。
Cloudflare D1の実践知見
D1はCloudflareが提供するSQLiteベースのデータベースです。Workers上から直接アクセスでき、接続管理が不要な点が大きな特徴です。
基本的な使い方
まず wrangler.toml にバインディングを設定します。バインディングとは、WorkersのコードからD1などのリソースに変数として直接アクセスする仕組みのことです。
# wrangler.toml
[[d1_databases]]
binding = "DB" # コード内でこの名前でアクセス
database_name = "my-saas-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"Honoを使ったエンドポイントでは、以下のようにD1にアクセスします。
// packages/api/src/routes/users.ts
import { Hono } from 'hono'
type Bindings = {
DB: D1Database
}
const app = new Hono<{ Bindings: Bindings }>()
// ユーザー取得
app.get('/users/:id', async (c) => {
const id = c.req.param('id')
const user = await c.env.DB
.prepare('SELECT * FROM users WHERE id = ?')
.bind(id)
.first()
if (!user) {
return c.json({ error: 'User not found' }, 404)
}
return c.json(user)
})マイグレーション管理
スキーマ変更は migrations/ フォルダにSQLファイルで管理し、Wranglerコマンドで適用します。
-- database/migrations/0001_create_users.sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'free',
stripe_customer_id TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);# ローカル環境への適用
wrangler d1 migrations apply my-saas-db --local
# 本番環境への適用
wrangler d1 migrations apply my-saas-db無料枠の実数(2026年8月時点)
D1の無料枠は「クエリ数」ではなく読み書きした行数(rows)で計測されます。ここを取り違えると見積もりが大きくずれます。Cloudflare公式の料金ページに記載されている Workers Free の上限は次のとおりです。
| 項目 | Workers Free の上限 |
|---|---|
| Rows read(読み取り行数) | 500万行/日 |
| Rows written(書き込み行数) | 10万行/日 |
| ストレージ | 合計5GB |
テーブル設計そのものの考え方はチームツールのDB設計パターンで扱っています。上限はUTC 00:00にリセットされ、超過するとD1のAPIがエラーを返します。注意点は「1クエリ=1カウント」ではないことで、インデックスの効かない SELECT はテーブル全体をスキャンして大量の rows read を消費します。無料枠で運用するなら、WHERE句に使う列へのインデックス作成が実質必須です。
良かった点・ハマった点
| 内容 | |
|---|---|
| ✅ 良かった点 | SQLiteの知識がそのまま使える。接続管理が不要で、バインディングで即アクセス可能 |
| ✅ 良かった点 | 無料枠が大きく、個人開発の初期フェーズはほぼコストゼロ(後述の実数を参照) |
| ⚠️ ハマった点 | SQLiteは型が緩い(例:INTEGER列に文字列を入れても通る)。アプリ側のバリデーションが必須 |
| ⚠️ ハマった点 | 複雑なJOINはレスポンスが遅くなる。クエリをシンプルに保つか、非正規化を検討する |
| ⚠️ ハマった点 | ローカルD1と本番D1はデータが別。本番データをローカルで試したい場合は手動エクスポートが必要 |
Stripe決済の実装ポイント
SaaSにサブスクリプション決済を組み込む際、Stripeが事実上の標準です。Free / Entry / Basic / Proの4プランを想定した実装例を示します。
Checkout Sessionの作成
Checkout SessionはStripeのホスト型決済画面です。自前でカード入力フォームを実装する必要がなく、PCI DSSへの準拠もStripe側が担保してくれます。
// packages/api/src/routes/stripe.ts
import Stripe from 'stripe'
app.post('/create-checkout', async (c) => {
const stripe = new Stripe(c.env.STRIPE_SECRET_KEY)
const { planId, userId } = await c.req.json()
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: planId, // StripeダッシュボードのPrice ID
quantity: 1,
},
],
success_url: `${c.env.FRONTEND_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${c.env.FRONTEND_URL}/pricing`,
metadata: { userId }, // Webhookで使用する
})
return c.json({ url: session.url })
})Webhook処理と冪等性
冪等性(べきとうせい)とは、同じ操作を何回実行しても結果が変わらない性質のことです。Stripeのウェブフック(Webhook)はネットワーク障害時に同じイベントを複数回送信することがあります。そのため「1回目だけ処理し、2回目以降はスキップする」設計が必要です。
// packages/api/src/routes/webhook.ts
app.post('/webhook', async (c) => {
const stripe = new Stripe(c.env.STRIPE_SECRET_KEY)
const signature = c.req.header('stripe-signature') ?? ''
const body = await c.req.text()
// 署名検証:Stripeからの正規リクエストかを確認
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
c.env.STRIPE_WEBHOOK_SECRET
)
} catch {
return c.json({ error: 'Invalid signature' }, 400)
}
// 冪等性チェック:同じイベントを2回処理しない
const processed = await c.env.DB
.prepare('SELECT id FROM webhook_events WHERE stripe_event_id = ?')
.bind(event.id)
.first()
if (processed) {
return c.json({ status: 'already_processed' })
}
// イベントを処理済みとして記録(先に記録してから処理)
await c.env.DB
.prepare('INSERT INTO webhook_events (stripe_event_id, event_type) VALUES (?, ?)')
.bind(event.id, event.type)
.run()
// イベントタイプに応じてプランを更新
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.CheckoutSession
const userId = session.metadata?.userId
await c.env.DB
.prepare('UPDATE users SET plan = ?, stripe_customer_id = ? WHERE id = ?')
.bind('entry', session.customer, userId)
.run()
}
return c.json({ status: 'ok' })
})
ポイントは「先にDBへイベントIDを記録してから処理する」順番です。処理してから記録すると、記録前にリトライが来た場合に二重処理が起きます。
ここまでが決済を通すための実装です。運用に入ると、プラン変更・支払い失敗・解約という契約の状態変化をどう受け取るかが問題になります。拾うべきイベントの選び方、支払いが止まった利用者の機能をいつ止めるか、日割りの挙動といった実装後の判断はStripeサブスク課金の設計と運用にまとめています。
多言語対応の設計パターン
日英対応のSaaSを作る場合、Next.jsのApp Routerでは [locale] ディレクトリを使ったルーティングが一般的です。
ルーティング構成
app/
└── [locale]/ # "ja" または "en" が入る動的セグメント
├── layout.tsx
├── page.tsx # /ja/ または /en/
└── dashboard/
└── page.tsx # /ja/dashboard または /en/dashboard
翻訳ファイルと取得関数
// lib/i18n/translations.ts
const translations = {
ja: {
'check.title': 'SEOチェック結果',
'check.score': 'スコア',
'check.passed': '合格',
'check.failed': '不合格',
},
en: {
'check.title': 'SEO Check Result',
'check.score': 'Score',
'check.passed': 'Passed',
'check.failed': 'Failed',
},
} as const
type Locale = keyof typeof translations
type TranslationKey = keyof typeof translations['ja']
export function t(locale: Locale, key: TranslationKey): string {
return translations[locale][key]
}
// app/[locale]/page.tsx
export default function Page({
params: { locale }
}: {
params: { locale: 'ja' | 'en' }
}) {
return (
<h1>{t(locale, 'check.title')}</h1>
)
}
APIレスポンスの多言語対応
UIだけでなく、APIのレスポンスメッセージも言語に応じて切り替えることで、英語圏ユーザーへの対応が完結します。フロントからリクエスト時に Accept-Language ヘッダーまたはクエリパラメータで言語を渡し、Workers側で出し分けます。
// packages/api/src/lib/messages.ts
export const checkMessages = {
ja: {
missingTitle: 'titleタグが設定されていません',
missingDescription: 'meta descriptionが設定されていません',
},
en: {
missingTitle: 'title tag is missing',
missingDescription: 'meta description is missing',
},
}【検証】本番に出す前に確認する4項目
ローカルで動いても本番で落ちる箇所が、この構成には決まったパターンで存在します。デプロイ直後に必ず4つを確認してください。
確認手順と合否ライン
- 本番のバインディングが解決しているか:デプロイ後に
/users/:idのような D1 を触るエンドポイントを叩く。合否=ローカルと同じレスポンスが返ること。wrangler.tomlのdatabase_idがローカル用のままだと本番で 500 になる - Webhookの冪等性が効いているか:Stripeダッシュボードから同じイベントを2回再送する。合否=プラン付与やレコード追加が1回しか起きないこと。2回起きるなら冪等キーの実装が効いていない
- CORSが本番ドメインで通るか:ブラウザの開発者ツールを開いた状態で本番フロントから API を呼ぶ。合否=コンソールにCORSエラーが出ないこと。localhost だけ許可した設定が残っていると本番で全滅する
- D1の消費量が想定内か:Cloudflareダッシュボードで rows read / rows written を見る。合否=1日の実測値が無料枠(読み取り500万行・書き込み10万行)に収まっていること
2番目が最も見落とされます。Stripeは配信に失敗したWebhookを自動でリトライするため、冪等性がないと本番で必ず二重処理が起きます。「たまたま起きなかった」状態のままリリースしないでください。
不合格だった場合の切り分け
| 症状 | 疑うべき原因 | 次の一手 |
|---|---|---|
| 本番だけ500エラー | バインディング未設定、または本番用のIDが違う | wrangler.toml の database_id と、Cloudflareダッシュボードの本番DBのIDを突き合わせる |
| Webhookで二重処理 | 冪等キーが未実装、または保存前に処理している | イベントIDを先に記録し、既存なら早期リターンする順序に直す |
| CORSエラー | 許可オリジンにlocalhostしかない | 本番ドメインを許可リストへ追加する |
| rows readが急増 | インデックスが効かず全件スキャンしている | WHERE句で使う列にインデックスを作成する |
まとめ
Next.js × Cloudflare Workers × D1 の技術スタックは、個人開発のSaaSに非常に向いています。理由を整理すると次の3点です。
- コストが低い:CloudflareとVercelはどちらも無料枠が広く、初期フェーズはほぼ無料で運用できる
- 学習コストが低い:D1はSQLite、HonoはExpressライクな書き方で、新しい概念を覚えすぎる必要がない
- スケールしやすい:Cloudflare Workersは世界200以上のエッジ拠点に自動で分散されるため、ユーザーが増えても対応しやすい
技術スタック選定のコツは「差別化ポイント以外はシンプルに保つ」ことです。決済はStripe、認証はClerk、メールはResendなど、専門のSaaSを組み合わせることで、自分が集中すべきコア機能の開発に時間を使えます。
具体的な実装の進め方は、本サイトのSaaSで実装した FAQチャットウィジェットを自作する方法【Next.js×Cloudflare D1】 でハンズオン形式で解説しています。Cloudflare Workers + D1 の実用例として参考にしてください。
サイトのSEO品質が気になる方は、ぜひ Direbase も試してみてください。この記事で紹介した技術スタックで構築した4カテゴリ45項目以上を診断する無料ツールです。
よくある質問
Q. Cloudflare WorkersとVercel Edge Functionsは何が違いますか?
どちらもエッジで動くサーバーレス環境ですが、Workersはスタンドアロンのバックエンドとして使えるのに対し、Vercel Edge FunctionsはNext.jsプロジェクト内のAPIルートとして動作します。Cloudflare WorkersはD1・KV・R2などのCloudflareエコシステムと組み合わせやすく、フロントとAPIを別サービスに分けたい個人SaaS開発には特に向いています。
Q. D1はPostgreSQLの代わりに使えますか?
シンプルなCRUD操作やJSONの保存はD1で十分対応できます。ただし、複雑なJOINや全文検索、トランザクションの高度な利用が必要な場合はPlanetScaleやNeon(PostgreSQL互換)が適しています。個人開発の初期フェーズはD1から始め、必要に応じて移行するアプローチが現実的です。
Q. Stripeのテストはどうすればいいですか?
Stripeにはテストモードが用意されており、ダッシュボードのAPIキーを「テスト用」に切り替えると実際の課金が発生しません。Webhookのテストには stripe listen --forward-to localhost:8787/webhook コマンドを使い、ローカル環境でWebhookイベントを受信できます。カード番号 4242 4242 4242 4242 はStripeのテスト用カードで、有効期限・CVCは任意の値で通ります。
Q. D1の無料枠はどのくらい使えますか?
Cloudflare公式の料金ページによると、Workers Freeプランでは読み取り500万行/日、書き込み10万行/日、ストレージ合計5GBです。重要なのはクエリ数ではなく読み書きした行数で計測される点で、インデックスの効かないSELECTはテーブル全体をスキャンして大量の行数を消費します。無料枠で運用するならWHERE句に使う列へのインデックス作成が実質必須です。上限はUTC 00:00にリセットされます。
Q. なぜフロントとAPIをVercelとCloudflareに分けたのですか?
それぞれの得意領域が違うためです。Next.jsはVercelとの親和性が高くデプロイが自動化できる一方、APIは実行回数が多くコールドスタートの影響を受けやすいため、起動時間がほぼゼロのCloudflare Workersが向いています。D1がWorkersからバインディングで直接触れる点も分離の理由です。1つのプラットフォームに寄せるより構成は複雑になりますが、コストと応答速度の両方で有利になります。
