前編のロードマップで書いた通り、個人開発SaaSの収益化は「絞り込み」と「コスト構造」がほぼ全てです。本記事はその続編として、Claude API・Stripe・Cloudflare Workersを組み合わせた月額課金SaaSの完全な実装ガイドをお届けします。
私自身、4つのAI技術ブログ(Claude Lab・Gemini Lab・Antigravity Lab・Rork Lab)でこの構成を実運用しており、解約処理・Webhook失敗・エッジキャッシュとの戦いまで含めて、個人開発で詰まりがちな箇所をかなり踏み抜いてきました。本記事はその実戦経験を体系化したものです。
コードはNext.js 16 (App Router) + Cloudflare Workers + Stripe v15 を前提にしています。一部はOpenNextが必要ですが、ロジックは他フレームワークでも応用可能です。
前提構成と全体アーキテクチャ
最終的に作るのは、次のような構成です。
- ユーザー認証: NextAuth.js または Clerk(ここではNextAuthを使用)
- 決済: Stripe Checkout(サブスクリプション)+ Stripe Customer Portal
- アクセス制御: Stripe Webhook → Cloudflare KV にアクティブ化フラグ書き込み
- API実行層: Cloudflare Workers + Anthropic SDK
- ペイウォール: ミドルウェア層でKVを参照し、未契約ユーザーをブロック
KVをアクセス制御の真実の源(source of truth)とするのが個人開発で最も軽量です。Stripe APIに毎リクエスト問い合わせる構成は、レートリミットの観点でもレイテンシの観点でも筋が悪いです。Webhookで状態変化があった瞬間にKVを更新し、API実行時はKVだけ見る、というルールに統一します。
ステップ1: Stripe側のプロダクトと価格を設計する
Stripe Dashboardで「プロダクト」を作成し、月額プランの価格を登録します。私は通常、次のように構成します。
- Pro月額(日本円): 商品名「Pro」、価格 ¥580/月、recurring=monthly、price_id を
STRIPE_PRICE_PRO_JPYとして保管 - Pro月額(USD): 商品名「Pro」、価格 $5/月、price_id を
STRIPE_PRICE_PRO_USDとして保管 - Premium買い切り(JPY/USD): ¥2,480 / $15、price_id を
STRIPE_PRICE_PREMIUM_*として保管
通貨ごとにプロダクトを分けるのは、Stripe Checkoutに表示される商品名・説明文をロケール別に出し分けるためです(参照: STUMBLING_POINTS #79)。ロケール混在の1商品で済ませようとすると、英語ユーザーに日本語の説明文が出てしまうという事故が起きます。
価格IDは Cloudflare Workers の環境変数として wrangler.toml に登録します。秘密鍵(STRIPE_SECRET_KEY STRIPE_WEBHOOK_SECRET)は wrangler secret put で別管理にしてください。
ステップ2: Checkoutセッション生成APIを実装する
Next.js App Routerでは app/api/checkout/route.ts にPOSTハンドラを置きます。下のコードはサブスクリプション + 単発購入(記事買い切り)を1つのエンドポイントで扱う例です。
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { getPriceIds, getProductData } from '@/config/pricing';
export const runtime = 'edge';
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const { plan, locale, articleSlug } = await req.json();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-31',
});
const priceIds = getPriceIds(locale);
const product = getProductData(plan, locale);
const params: Stripe.Checkout.SessionCreateParams = {
mode: plan === 'pro' ? 'subscription' : 'payment',
success_url: `${process.env.SITE_URL}/${locale}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/${locale}/membership`,
customer_email: session.user.email,
line_items: [{
price_data: {
currency: locale === 'ja' ? 'jpy' : 'usd',
recurring: plan === 'pro' ? { interval: 'month' } : undefined,
unit_amount: product.amount,
product_data: {
name: product.name,
description: product.description,
images: [product.imageUrl],
},
},
quantity: 1,
}],
metadata: {
plan_type: plan,
user_email: session.user.email,
article_slug: articleSlug ?? '',
},
};
const checkoutSession = await stripe.checkout.sessions.create(params);
return NextResponse.json({ url: checkoutSession.url });
}ここで重要なのは metadata.plan_type を必ず付けることです。Webhookで受け取ったときに、サブスク契約と単発購入を区別する手がかりになります。プレミアム永久購入が「チップ」と取り違えられるバグは個人開発で最も多い事故の1つです(STUMBLING_POINTS #14.5)。
ステップ3: Stripe Webhookを処理する
Webhookは個人開発SaaSの心臓部です。決済完了 → KV書き込み の流れをここで完結させます。
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export const runtime = 'edge';
export async function POST(req: NextRequest) {
const sig = req.headers.get('stripe-signature');
const body = await req.text();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-31',
});
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig!,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (e) {
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
const { env } = getCloudflareContext();
const kv = env.MEMBERSHIP_KV;
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object as Stripe.Checkout.Session;
const planType = s.metadata?.plan_type;
const email = s.customer_email ?? s.metadata?.user_email;
if (!email) break;
if (planType === 'pro') {
// 月額契約: 契約期限まで有効
const subscriptionId = s.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await kv.put(
`site:claudelab:member:${email}`,
JSON.stringify({
plan: 'pro',
current_period_end: subscription.current_period_end,
subscription_id: subscriptionId,
}),
{ expirationTtl: subscription.current_period_end - Math.floor(Date.now() / 1000) + 86400 * 3 }
);
} else if (planType === 'premium') {
// 永久プラン: 10年TTLで実質永続
await kv.put(
`site:claudelab:member:${email}`,
JSON.stringify({ plan: 'premium' }),
{ expirationTtl: 86400 * 365 * 10 }
);
} else if (planType === 'article') {
// 記事単体購入: 既存スラッグリストに追加
const slug = s.metadata?.article_slug;
if (slug) {
const key = `site:claudelab:article:${email}:${slug}`;
await kv.put(key, '1', { expirationTtl: 86400 * 365 * 10 });
}
}
// tip は何もしない(記録のみで権限は付与しない)
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(sub.customer as string);
if ('email' in customer && customer.email) {
await kv.delete(`site:claudelab:member:${customer.email}`);
}
break;
}
}
return NextResponse.json({ received: true });
}特に押さえるべきは expirationTtl の設計です。月額プランは「次回更新日 + 3日のバッファ」をTTLにすることで、Webhookの遅延や失敗時にも自動的に失効する保険になります。永続プランは10年と長く取りますが、これは「実質永久」を表現するための値です。
ステップ4: ペイウォール側でKVを参照する
ユーザーがプレミアム機能を呼び出したとき、KVを引いてアクセスを判定します。Next.jsのRoute HandlerやServer Componentで使う共通関数として lib/premium.ts に切り出すのが整理しやすいです。
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export async function canViewPremium(): Promise<boolean> {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return false;
try {
const { env } = getCloudflareContext();
const raw = await env.MEMBERSHIP_KV.get(`site:claudelab:member:${session.user.email}`);
if (!raw) return false;
const data = JSON.parse(raw);
return data.plan === 'pro' || data.plan === 'premium';
} catch (e) {
// KVアクセス不可時はdeny by default(重要)
return false;
}
}
export async function getArticleAccess(slug: string): Promise<boolean> {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return false;
try {
const { env } = getCloudflareContext();
const raw = await env.MEMBERSHIP_KV.get(
`site:claudelab:article:${session.user.email}:${slug}`
);
return raw === '1';
} catch (e) {
return false; // deny by default
}
}return false がフォールバック挙動なのは絶対に変えないでください。return true にした瞬間、KVアクセスエラーが出るたびに全プレミアム記事が無料公開になります。アクセス制御は deny by default が鉄則です(STUMBLING_POINTS #89)。
ステップ5: Claude API実行層の利用上限を実装する
前編で触れたハイブリッドプラン(月額固定 + 超過従量)を実装するには、ユーザーごとの月間利用回数を別途カウントする必要があります。これもKVを使います。
// lib/usage.ts
import { getCloudflareContext } from '@opennextjs/cloudflare';
const PLAN_LIMITS: Record<string, number> = {
free: 5,
pro: 100,
premium: 9999,
};
export async function checkAndIncrementUsage(
email: string,
plan: 'free' | 'pro' | 'premium'
): Promise<{ allowed: boolean; remaining: number; overage: number }> {
const { env } = getCloudflareContext();
const month = new Date().toISOString().slice(0, 7); // YYYY-MM
const key = `site:claudelab:usage:${email}:${month}`;
const raw = await env.MEMBERSHIP_KV.get(key);
const current = raw ? parseInt(raw, 10) : 0;
const limit = PLAN_LIMITS[plan];
// 月末に自動失効するTTL
const now = new Date();
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((monthEnd.getTime() - now.getTime()) / 1000) + 86400;
await env.MEMBERSHIP_KV.put(key, String(current + 1), { expirationTtl: ttl });
if (current >= limit) {
return { allowed: plan === 'pro', remaining: 0, overage: current + 1 - limit };
}
return { allowed: true, remaining: limit - current - 1, overage: 0 };
}ハイブリッド設計では、Pro契約者が月間上限を超えても allowed: true を返し、超過分を別途記録します。月末に集計してStripe Usage Records APIで報告するのが理想ですが、個人開発の範囲では「Pro契約者は超過分も実行できる代わりに、翌月にメールで超過レポートを送る」という運用でも十分回ります。
ステップ6: Anthropic SDKを呼び出す
利用上限チェックを通過したら、いよいよClaude APIを呼びます。Cloudflare Workersでは @anthropic-ai/sdk をesmで読み込みます。
import Anthropic from '@anthropic-ai/sdk';
export const runtime = 'edge';
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return new Response('unauthorized', { status: 401 });
}
const isMember = await canViewPremium();
const plan = isMember ? 'pro' : 'free';
const usage = await checkAndIncrementUsage(session.user.email, plan);
if (!usage.allowed) {
return Response.json(
{ error: 'usage_limit_exceeded', remaining: 0 },
{ status: 429 }
);
}
const { prompt } = await req.json();
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
const message = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 2000,
messages: [{ role: 'user', content: prompt }],
});
return Response.json({
text: message.content[0].type === 'text' ? message.content[0].text : '',
remaining: usage.remaining,
overage: usage.overage,
});
}max_tokens は必ず明示してください。個人開発の最も痛い事故は「無制限のmax_tokensでループ呼び出しが発生し、1日で月の予算を使い切る」というパターンです。Anthropic Console側でも月間予算アラートを必ず設定しておきます。
ステップ7: 解約フローとエッジケース
本番運用で必ず詰まるのが解約周りです。Stripe Customer Portal を使えば解約画面はStripe側で持ってくれますが、解約後の挙動はあなたが設計する必要があります。
「契約解除直後の駆け込み利用」問題 — ユーザーが解約ボタンを押した瞬間、現在の課金期間の終わりまでは引き続きアクセスできるべきです。Webhookでは customer.subscription.deleted ではなく customer.subscription.updated(cancel_at_period_end が true になる)を聴くと、解約予約状態を正しく扱えます。完全に失効するのは customer.subscription.deleted イベント受信時です。
「失効後の自動ダウングレード」 — KVのTTL設計(次回更新日 + 3日)に頼ると、Webhook失敗時もある程度は自動で消えます。ただし完璧ではないので、月初に「先月失効したユーザーをStripeから取得してKVを再同期」するバッチをCron Triggers で動かしておくと安全です。
「重複Webhookによる二重課金」 — Stripeは同一イベントを複数回送ることがあります。event.id をKVに保存しておき、すでに処理済みならスキップする冪等性チェックを必ず入れてください。
const eventKey = `site:claudelab:webhook:${event.id}`;
const seen = await kv.get(eventKey);
if (seen) {
return NextResponse.json({ received: true, duplicate: true });
}
await kv.put(eventKey, '1', { expirationTtl: 86400 * 7 });
// ... 通常の処理ステップ8: Cloudflare CDNキャッシュとの戦い
これは個人開発SaaSでもっとも見落とされる罠です。Cloudflare WorkersでHTMLをキャッシュすると、ログイン状態や課金状態に関係なく同じHTMLが配信されてしまいます。
対策は2層です。1つ目は cache-worker.js で premium_token article_purchases などのCookieを持つリクエストはキャッシュをバイパスすること。2つ目はクライアント側でfetch('/api/premium-status') を発行して、最新の状態を取得してUIを差し替えること。
// cache-worker.js
addEventListener('fetch', event => {
const req = event.request;
const cookie = req.headers.get('cookie') || '';
// 認証Cookie保持者はバイパス
if (cookie.includes('premium_token') || cookie.includes('article_purchases')) {
event.respondWith(fetch(req));
return;
}
// それ以外はエッジキャッシュを使う
event.respondWith(handleCachedRequest(event));
});これを忘れると「課金してもメンバーシップCTAが消えない」というクレームを大量に受けることになります(STUMBLING_POINTS #73)。
全体を振り返って — 本番投入前のチェックリスト
実装が終わったら、本番デプロイ前に必ず次の項目を確認してください。
- Stripe Webhookエンドポイントが本番URLを指している
STRIPE_WEBHOOK_SECRETが本番モードのものに切り替わっている- KVの key prefix がサイトごとに
site:{name}:で分離されている(マルチサイト共有時の汚染防止) - 月間予算アラートがAnthropic Consoleで設定されている
- Stripe Test Modeでサブスク作成 → 解約 → 再購入の3パスを通している
- 解約後にプレミアム機能がきちんとブロックされることを確認している
cache-worker.jsでpremium_tokenarticle_purchasesCookieバイパスが効いている
ここまでくれば、Claude APIを使った月額課金SaaSとして十分に商用運用できる構成です。あとは前編で書いた通り、リテンション設計を継続的に磨き続ける勝負になります。
実装で詰まったら、私が運営するClaude Lab・Gemini Lab・Antigravity Lab・Rork Labのリポジトリ群が同じ構成で動いている実例として参考になるはずです。具体的な質問があれば、各サイトのサポートフォームから連絡をいただければ可能な範囲でお答えします。