Next.js + PostgreSQL + Stripe でサブスクリプション決済を実装する
はじめに
これまでに以下の記事でStripeの概要と単発決済について検証してきました。
今回は、Stripeを使ったサブスクリプション決済の実装について解説します。単発決済とは違い、継続課金の管理やWebhookでの状態管理が重要になってきます。実際にコードを書いて動作確認までやってみたので、その過程で気づいたポイントも含めて紹介していきます。
流れはほとんど単発決済とは変わりません!サクサクみていきましょう〜!
今回のリポジトリは下記になります。
サブスクリプション決済のフロー
サブスクリプション決済は以下の5つのステップで動作します:
- プラン選択 - ユーザーが月額・年額プランから選択
-
チェックアウト作成 - Stripe Checkoutセッションを作成(
mode: 'subscription') - 決済処理 - ユーザーがクレジットカード情報を入力して決済
- Webhook受信 - 決済完了やサブスクリプション作成のイベントを受信
- ステータス更新 - データベースのサブスクリプション状態を更新
特に重要なのが4番目のWebhookです。サブスクリプションでは初回決済だけでなく、継続課金の成功・失敗、プラン変更、キャンセルなど様々なイベントが発生するため、Webhookによる状態管理が必須となります。
データベース設計
サブスクリプション用のモデル
サブスクリプション決済では、主に2つのモデルを使います:
SubscriptionPlan(料金プラン管理)
-
price: 料金 -
interval: 課金間隔(MONTH または YEAR) -
stripeProductId/stripePriceId: Stripeとの紐付け用ID -
active: プランの有効/無効状態
Subscription(個別のサブスクリプション契約)
-
status: サブスクリプションの状態(PENDING, ACTIVE, CANCELEDなど) -
stripeCustomerId/stripeSubscriptionId: Stripeとの紐付け用ID -
currentPeriodStart/currentPeriodEnd: 現在の課金期間 -
cancelAtPeriodEnd: 期間終了時にキャンセルするかどうか
なお、既存のProductとPaymentモデルは単発決済用なので、今回のサブスクリプション実装では使用しません。
Prismaスキーマ差分
// 今回のサブスクリプション実装で使うのは SubscriptionPlan と Subscription モデル
// Product, Order, Payment モデルは単発決済用
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model SubscriptionPlan {
id String @id @default(cuid())
name String
description String?
price Int // Price in cents
currency String @default("jpy")
interval BillingInterval @default(MONTH)
stripeProductId String? @unique
stripePriceId String? @unique
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscriptions Subscription[]
@@map("subscription_plans")
}
model Subscription {
id String @id @default(cuid())
planId String
plan SubscriptionPlan @relation(fields: [planId], references: [id])
customerEmail String
stripeCustomerId String?
stripeSubscriptionId String? @unique
status SubscriptionStatus @default(PENDING)
currentPeriodStart DateTime?
currentPeriodEnd DateTime?
cancelAtPeriodEnd Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("subscriptions")
}
enum BillingInterval {
MONTH
YEAR
}
enum SubscriptionStatus {
PENDING
ACTIVE
PAST_DUE
CANCELED
UNPAID
INCOMPLETE
INCOMPLETE_EXPIRED
TRIALING
}
実装の流れ
1. データベースのセットアップ
まずはサンプルプランをシードデータで作成します:
// prisma/seed.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// Create subscription plans
await prisma.subscriptionPlan.createMany({
data: [
{
name: 'Basic Monthly',
description: 'Perfect for getting started',
price: 100000, // ¥1,000 in cents
currency: 'jpy',
interval: 'MONTH',
},
{
name: 'Pro Monthly',
description: 'Best for professionals',
price: 300000, // ¥3,000 in cents
currency: 'jpy',
interval: 'MONTH',
},
{
name: 'Basic Yearly',
description: 'Perfect for getting started (save 20%)',
price: 960000, // ¥9,600 in cents
currency: 'jpy',
interval: 'YEAR',
},
],
})
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
2. サブスクリプションプラン一覧画面
実際の画面はこんな感じになります:

月額・年額プランを分けて表示し、メールアドレス入力フィールドを配置しています。
'use client';
import { useEffect, useState } from 'react';
interface SubscriptionPlan {
id: string;
name: string;
description: string | null;
price: number;
currency: string;
interval: 'MONTH' | 'YEAR';
}
export default function Subscriptions() {
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [email, setEmail] = useState('');
const [checkoutLoading, setCheckoutLoading] = useState<string | null>(null);
const handleCheckout = async (planId: string) => {
if (!email || !email.includes('@')) {
alert('Please enter a valid email address');
return;
}
setCheckoutLoading(planId);
try {
const response = await fetch('/api/checkout-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planId, customerEmail: email }),
});
const data = await response.json();
if (data.url) {
window.location.href = data.url;
}
} catch (error) {
console.error('Checkout error:', error);
} finally {
setCheckoutLoading(null);
}
};
// Group plans by interval
const monthlyPlans = plans.filter((plan) => plan.interval === 'MONTH');
const yearlyPlans = plans.filter((plan) => plan.interval === 'YEAR');
// 省略:UIレンダリング部分
}
コードのポイント
- プランを
intervalでグループ化して月額・年額別に表示 -
formatPriceとformatIntervalで料金と期間を適切に表示 - メールアドレスの簡易バリデーション
3. プラン取得API
// app/api/subscription-plans/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const plans = await prisma.subscriptionPlan.findMany({
where: { active: true },
orderBy: { price: 'asc' },
});
return NextResponse.json(plans);
} catch (error) {
console.error('Failed to fetch subscription plans:', error);
return NextResponse.json(
{ error: 'Failed to fetch subscription plans' },
{ status: 500 }
);
}
}
コードのポイント
-
active: trueで有効なプランのみ取得 -
price: 'asc'で価格の昇順でソート
4. チェックアウトセッション作成API
// app/api/checkout-subscription/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
export async function POST(request: NextRequest) {
try {
const { planId, customerEmail } = await request.json();
const plan = await prisma.subscriptionPlan.findUnique({
where: { id: planId },
});
if (!plan || !plan.active) {
return NextResponse.json(
{ error: 'Subscription plan not found or inactive' },
{ status: 404 }
);
}
// Create subscription record in database with PENDING status
const subscription = await prisma.subscription.create({
data: {
planId: plan.id,
customerEmail,
status: 'PENDING',
},
});
const stripeInterval = plan.interval === 'MONTH' ? 'month' : 'year';
// Create Stripe Checkout Session for subscription
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: plan.currency,
product_data: {
name: plan.name,
description: plan.description || undefined,
},
unit_amount: plan.price,
recurring: {
interval: stripeInterval,
},
},
quantity: 1,
},
],
mode: 'subscription',
success_url: `${request.headers.get('origin')}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${request.headers.get('origin')}/cancel`,
customer_email: customerEmail,
metadata: {
subscriptionId: subscription.id,
},
subscription_data: {
metadata: {
subscriptionId: subscription.id,
},
},
});
return NextResponse.json({ sessionId: session.id, url: session.url });
} catch (error) {
console.error('Subscription checkout error:', error);
return NextResponse.json(
{ error: 'Failed to create subscription checkout session' },
{ status: 500 }
);
}
}
コードのポイント(重要!)
-
mode: 'subscription': サブスクリプションモードを指定(単発決済との最大の違い) -
recurring設定:intervalでMONTHまたはYEARを指定して継続課金を有効化 -
metadata: データベースのsubscriptionIdを設定し、Webhookで紐付けできるようにする -
subscription_data.metadata: サブスクリプション自体にもメタデータを設定
5. Stripe Checkoutでの決済
プラン選択後、Stripeのチェックアウト画面に遷移します:

カード情報を入力する画面では、開発環境用のテストカード(4242 4242 4242 4242)を使用できます:

6. Webhookによる状態管理
サブスクリプション決済では、以下のイベントを処理する必要があります:
-
checkout.session.completed- 初回決済完了 -
customer.subscription.created- サブスクリプション作成 -
customer.subscription.updated- サブスクリプション更新(プラン変更等) -
customer.subscription.deleted- サブスクリプション削除 -
invoice.payment_succeeded- 継続課金成功 -
invoice.payment_failed- 継続課金失敗
開発環境でのWebhook設定(重要!)
開発環境では、以下のコマンドでWebhookエンドポイントを立ち上げます:
stripe listen --forward-to localhost:3000/api/webhook
コマンド実行時に表示されるsigning secretを .envに設定:
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
基本的なWebhookハンドラーの構造:
// app/api/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed.');
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
const subscriptionId = session.metadata?.subscriptionId;
if (subscriptionId) {
await prisma.subscription.update({
where: { id: subscriptionId },
data: {
status: 'ACTIVE',
stripeCustomerId: session.customer,
stripeSubscriptionId: session.subscription,
},
});
}
break;
// 他のイベント処理...
}
return NextResponse.json({ received: true });
}
7. Stripeダッシュボードでの確認
決済が完了すると、Stripeダッシュボードのサブスクリプション一覧で確認できます:

顧客のメールアドレス、プラン名、ステータス、次回請求日などが表示され、サブスクリプションが正常に作成されていることが確認できます。
データの流れを整理
実際のデータの流れをまとめると:
-
プランデータ作成:
SubscriptionPlanテーブルにシードデータでプランを作成 -
プラン選択: ユーザーがプラン選択 → APIで
Subscriptionレコード作成(status: PENDING) - 決済処理: Stripe Checkoutで決済実行
-
Webhook受信:
checkout.session.completedイベントでWebhook呼び出し -
状態更新: Webhookで
Subscriptionレコード更新(status: ACTIVE、stripeSubscriptionId等を保存) -
継続課金: 次回課金時は
invoice.payment_succeededイベントで通知
この流れで、データベースとStripe間の状態が適切に同期されます。
まとめ
今回はStripeを使ったサブスクリプション決済を実装しました。単発決済と比較して重要な違いは:
-
mode: 'subscription'とrecurring設定: 継続課金の設定 - Webhookの必須実装: 状態管理のためのイベント処理
- メタデータの活用: データベースとStripeの紐付け
特にWebhookの設定は忘れがちですが、サブスクリプションでは必須です。開発時は必ず stripe listenコマンドを実行し、signing secretを正しく設定してください。
次回は、Webhookハンドラーの詳細実装やサブスクリプションの管理機能(キャンセル、プラン変更など)について詳しく解説していく予定です。
株式会社StellarCreate(stellar-create.co.jp)のエンジニアブログです。 プロダクト指向のフルスタックエンジニアを目指す方募集中です! カジュアル面談で気軽に雑談しましょう!→ recruit.stellar-create.co.jp/
Discussion