Next.js×Cloudflare Workers×D1でSaaS開発する方法【技術スタック解説】


はじめに

個人開発で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 WorkersAPIサーバーコールドスタートなし、グローバル配信、無料枠が広い
HonoWebフレームワーク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 (
    &lt;h1&gt;{t(locale, 'check.title')}&lt;/h1&gt;
  )
}

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は任意の値で通ります。