はじめに
個人開発でSaaSを作るとき、「技術スタックをどう選ぶか」が最初の大きな悩みです。スケーラビリティ、コスト、開発速度——すべてを満たす構成はなかなか見つかりません。
この記事では、実際にCodeQuest SEO_CHECKを開発した際に採用した Next.js × Cloudflare Workers × D1 の技術スタックを、学習者向けに解説します。なぜこの組み合わせを選んだのか、どう実装するのか、どこでハマったのかを具体的にまとめています。
技術スタック全体像
アーキテクチャ図
┌─────────────────────────────────────────────┐
│ フロントエンド(Vercel) │
│ Next.js (App Router) + TypeScript │
│ Tailwind CSS + shadcn/ui │
└──────────────────┬──────────────────────────┘
│ fetch(HTTP通信)
┌──────────────────▼──────────────────────────┐
│ API(Cloudflare Workers) │
│ Hono + TypeScript │
├──────────────┬──────────────┬───────────────┤
│ Cloudflare │ PageSpeed │ Stripe │
│ D1 (SQLite) │ Insights API │ (決済) │
└──────────────┴──────────────┴───────────────┘
フロントエンドはVercel、APIはCloudflare Workersという分離構成です。それぞれが得意な領域を担当することで、コストと開発速度の両方を最適化できます。
技術選定の理由
| 技術 | 役割 | 選んだ理由 |
|---|---|---|
| Next.js (App Router) | フロントエンド | SSG/SSRの柔軟な切り替え、SEOメタタグ生成が簡単 |
| Cloudflare Workers | APIサーバー | コールドスタートなし、グローバル配信、無料枠が広い |
| Hono | Webフレームワーク | Workers向けに設計された軽量フレームワーク、型安全なルーティング |
| Cloudflare D1 | データベース | SQLiteベースで学習コスト低、WorkersとのバインディングがシンプルD |
| 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"
<p>Honoを使ったエンドポイントでは、以下のようにD1にアクセスします。</p>
// 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良かった点・ハマった点
| 内容 | |
|---|---|
| ✅ 良かった点 | SQLiteの知識がそのまま使える。接続管理が不要で、バインディングで即アクセス可能 |
| ✅ 良かった点 | 無料枠が大きく(100万クエリ/日)、個人開発の初期フェーズはほぼコストゼロ |
| ⚠️ ハマった点 | SQLiteは型が緩い(例:INTEGER列に文字列を入れても通る)。アプリ側のバリデーションが必須 |
| ⚠️ ハマった点 | 複雑なJOINはレスポンスが遅くなる。クエリをシンプルに保つか、非正規化を検討する |
| ⚠️ ハマった点 | ローカルD1と本番D1はデータが別。本番データをローカルで試したい場合は手動エクスポートが必要 |
Stripe決済の実装ポイント
SaaSにサブスクリプション決済を組み込む際、Stripeが事実上の標準です。Free / Entry / Basic / Proの4プランを想定した実装例を示します。
Checkout Sessionの作成
Checkout SessionはStripeのホスト型決済画面です。自前でカード入力フォームを実装する必要がなく、PCI DSSへの準拠もStripe側が担保してくれます。
<pre class="wp-block-code"><code>// 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を記録してから処理する」順番です。処理してから記録すると、記録前にリトライが来た場合に二重処理が起きます。
多言語対応の設計パターン
日英対応の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',
},
}まとめ
Next.js × Cloudflare Workers × D1 の技術スタックは、個人開発のSaaSに非常に向いています。理由を整理すると次の3点です。
- コストが低い:CloudflareとVercelはどちらも無料枠が広く、初期フェーズはほぼ無料で運用できる
- 学習コストが低い:D1はSQLite、HonoはExpressライクな書き方で、新しい概念を覚えすぎる必要がない
- スケールしやすい:Cloudflare Workersは世界200以上のエッジ拠点に自動で分散されるため、ユーザーが増えても対応しやすい
技術スタック選定のコツは「差別化ポイント以外はシンプルに保つ」ことです。決済はStripe、認証はClerk、メールはResendなど、専門のSaaSを組み合わせることで、自分が集中すべきコア機能の開発に時間を使えます。
サイトのSEO品質が気になる方は、ぜひ CodeQuest SEO_CHECK も試してみてください。この記事で紹介した技術スタックで構築した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は任意の値で通ります。