【Stripe】Next.js 15とPrismaで実装するサブスクリプション機能の開発
Next.js 15
このcheckout.session.completed など)をlayout.tsx(サーバーコンポーネント)でstripe listen --forward-to で
この
1. はじめに
この
現在開発中の
2. 実装要件とデータベース設計
Stripeの
今回の
システム要件
- プランの
多段階化 : ライト(980円)と スタンダード (2,980円)の 提供。 - 即時利用制限: 決済完了まで
機能への アクセスを 遮断。 - Webhookに
よる : 決済成功・解約時の自動更新 ステータス同期。 - セルフプラン変更: 管理画面からの
アップグレード対応。
Prismaによるスキーマ設計
ユーザー
// schema.prisma
model Restaurant {
id String @id @default(cuid())
// ...基本情報
// セットアップ状況(決済完了でtrue)
isSetupCompleted Boolean @default(false)
// Stripe連携用フィールド
stripeCustomerId String? @unique
subscriptionId String? @unique
subscriptionStatus String? @default("inactive")
subscriptionPlanId String?
}3. 実装のハイライト
Next.jsと
3.1 「仮登録」と「決済」を分離するUX
ユーザー登録と
- 新規登録後:
自動的に /admin/subscriptionへリダイレクト。 - Stripe Checkout: サーバーサイドで
支払いリンクを 生成し、 Stripeの 堅牢な 決済画面へ 遷移。
CheckoutのpriceId はclient_reference_id に
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: restaurant.id,
success_url: `${baseUrl}/admin?checkout=success`,
cancel_url: `${baseUrl}/admin/subscription`,
});
// session.url に遷移させる3.2 Webhookによるステータス同期(最重要)
クライアント側の
api/webhooks/stripe/route.ts の
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const restaurantId = session.client_reference_id!; // Checkout作成時に入れた店舗ID
await prisma.restaurant.update({
where: { id: restaurantId },
data: {
stripeCustomerId: session.customer as string,
subscriptionId: session.subscription as string,
subscriptionStatus: "active",
isSetupCompleted: true
}
});
}補足
- 署名の検証:Webhookの
本文は 加工前の 文字列の まま stripe.webhooks.constructEvent(本文, Stripe-Signatureヘッダー, エンドポイントのシークレット)に渡して、 Stripeから 届いた ものか 確かめます (Stripe:Webhook の 署名を 、検証する 2026年10月2日確認)。 App Routerでは await req.text()で本文を 受け取ります。 - 解約・支払い
失敗の :解約は同期 customer.subscription.deleted、プランの 変更や 状態の 変化は customer.subscription.updated、支払いの 失敗は invoice.payment_failedのイベントで 届きます (Stripe:イベントの 種類 、2026年10月2日確認)。
※2026年10月3日追記:上のrestaurantId をclient_reference_id から
あわせて、invoice.paid でinvoice.paid をactive であるcanceled や unpaid に
次の
// app/api/webhooks/stripe/route.ts(例)
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const restaurantId = session.client_reference_id!;
// 上のコードと同じく、決済状態を更新する
}
if (event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
await prisma.restaurant.update({
where: { subscriptionId: subscription.id },
data: { subscriptionStatus: "canceled" },
});
}
return new Response(null, { status: 200 });
}3.3 未払いユーザーの徹底的なアクセス制御
未払いの
Next.jsのlayout.tsx(サーバーコンポーネント)を
補足
ログインや
4. 開発・デバッグ:Stripe CLIの活用
ローカル環境でstripe listen --forward-to で
ローカル環境では
# ローカルへのWebhook転送コマンド
stripe listen --forward-to localhost:3000/api/webhooks/stripeこのwhsec_...)を
株式会社bundlyzeでは、
5. まとめ
Next.js 15と
Next.js 15 と
- 確実な
収益化 : 決済なしでの利用を システムレベルで 排除。 - 運用の
自動化 : 入金確認作業のゼロ化。 - スケール性: 容易な
プラン追加や 変更が 可能な 柔軟な 設計。
SaaS開発に
出典
下の
- Stripe:注文の
フルフィルメント (Checkout) - Stripe:サブスクリプションで
Webhook を (使用する invoice.paid・customer.subscription.deleted・invoice.payment_failed、状態の 扱い) - Stripe:Webhook の
署名確認エラーを (加工前の解決する 本文で constructEvent()を呼ぶ) - Stripe:Webhook(
stripe listen --forward-toでのローカル確認) - Stripe:イベントの
種類 - Next.js:Authentication(レイアウトでの
確認の 注意、 Server Actions・Route Handlersでの 確認)
よくある質問
決済が完了したかどうかは、どこで判定すればいい?
Stripeからのcheckout.session.completed を
ローカル環境でStripeのWebhookを試すには?
Stripe CLIのstripe listen --forward-to localhost:3000/api/webhooks/stripe で、whsec_...)を
未払いのユーザーに管理画面を見せないようにするには?
このlayout.tsx(サーバーコンポーネント)で
毎月の継続課金が成功したかは、どのイベントで分かる?
invoice.paid です。invoice.paid をactive であるcustomer.subscription.deleted、invoice.payment_failed で
Next.jsのApp RouterでWebhookの署名検証が失敗するのはなぜ?
本文をawait req.text() でstripe.webhooks.constructEvent() にstripe listen がwhsec_... の