この記事でわかること
Next.js SaaSアプリケーションにStripeの月額課金機能を組み込む方法を、実装コード付きで説明します。
- Stripe Checkout Sessionを使った決済画面の構築
- Webhookで決済イベントを処理する方法
- JWTトークンによるサブスク状態の管理
- ユーザー向けカスタマーポータルの実装(解約・カード変更対応)
データベースにサブスク情報を保持しないステートレス設計を採用し、シンプルなSaaS課金システムを実現します。
システムアーキテクチャ
このアプローチの最大の特徴は、Stripeを課金情報の真実の源として扱うことです。自社DBでサブスク状態を管理せず、必要な都度Stripe APIから最新情報を取得します。
API仕様一覧
| エンドポイント | 機能 |
|---|---|
/api/checkout |
Stripe Checkout Session生成 |
/api/subscription/status |
サブスク状態確認&JWT発行 |
/api/portal |
カスタマーポータルへのリダイレクト |
/api/webhooks/stripe |
Stripe webhook受信 |
決済の流れ
ユーザー
│
├─① POST /api/checkout
│ └─→ Stripe Checkout画面へ遷移(Stripe側でホスト)
│ └─→ 決済完了
│ └─→ /checkout-success へリダイレクト
│
├─② GET /api/subscription/status?session_id=xxx
│ └─→ StripeAPIからSubscription情報を取得
│ └─→ JWTトークン生成
│ └─→ クライアントのlocalStorageに保存
│
├─③ POST /api/portal
│ └─→ Stripeカスタマーポータル画面を表示
│
└─④ POST /api/webhooks/stripe(Stripe側から自動呼び出し)
└─→ 署名を検証 → イベントを処理
セットアップ手順
必要なライブラリをインストール
npm install stripe jose
-
stripe— Stripe公式Node.js SDK -
jose— JWT操作用ライブラリ(軽量・Edge Runtime対応)
環境変数を設定
.env.local に以下の項目を記入します。
STRIPE_SECRET_KEY=sk_test_xxxx
STRIPE_PRICE_ID=price_xxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxx
JWT_SECRET=your-random-secret-at-least-32-chars
| 環境変数 | 入手元 |
|---|---|
STRIPE_SECRET_KEY |
Stripeダッシュボード → 開発者向け → APIキー |
STRIPE_PRICE_ID |
Stripeダッシュボード → 商品管理 → 価格ID |
STRIPE_WEBHOOK_SECRET |
Stripeダッシュボード → 開発者向け → Webhook → 署名シークレット |
JWT_SECRET |
openssl rand -base64 32 で生成 |
Checkoutセッションの実装
/api/checkout エンドポイントでStripe Checkout Sessionを作成し、決済ページのURLをクライアントに返します。
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = process.env.STRIPE_SECRET_KEY
? new Stripe(process.env.STRIPE_SECRET_KEY)
: null;
// シンプルなレート制限(IP単位で1時間あたり5回まで)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 60 * 60 * 1000 });
return true;
}
if (entry.count >= 5) return false;
entry.count++;
return true;
}
export async function POST(request: NextRequest) {
if (!stripe) {
return NextResponse.json(
{ error: "Stripe is not configured" },
{ status: 503 }
);
}
// レート制限をチェック
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429 }
);
}
try {
const body = await request.json();
const { userId } = body;
if (!userId) {
return NextResponse.json(
{ error: "userId is required" },
{ status: 400 }
);
}
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
locale: "ja",
line_items: [
{
price: process.env.STRIPE_PRICE_ID!,
quantity: 1,
},
],
success_url: `${request.nextUrl.origin}/checkout-success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${request.nextUrl.origin}/dashboard`,
metadata: {
userId,
},
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error("Checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout session" },
{ status: 500 }
);
}
}
実装上の注意点
-
mode: "subscription"で月額課金を有効化(一度限りの支払いは"payment") -
locale: "ja"を指定すると決済画面が日本語で表示される -
{CHECKOUT_SESSION_ID}はStripe側で自動置換されるプレースホルダー -
metadataフィールドにuserIdを記録しておくと、後で決済者を特定できる
サブスク状態の確認とJWT生成
決済成功後、クライアントが session_id を使ってサブスク状態をチェックします。有効なサブスクが存在すればJWTトークンを発行します。
// app/api/subscription/status/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import * as jose from "jose";
const stripe = process.env.STRIPE_SECRET_KEY
? new Stripe(process.env.STRIPE_SECRET_KEY)
: null;
const jwtSecret = new TextEncoder().encode(
process.env.JWT_SECRET || "default-secret"
);
export async function GET(request: NextRequest) {
if (!stripe) {
return NextResponse.json(
{ error: "Stripe is not configured" },
{ status: 503 }
);
}
try {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get("session_id");
if (!sessionId) {
return NextResponse.json(
{ error: "session_id is required" },
{ status: 400 }
);
}
// Checkout Sessionの詳細を取得
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (!session.subscription) {
return NextResponse.json(
{ error: "No subscription found" },
{ status: 404 }
);
}
// Subscriptionの詳細情報を取得
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
// ユーザーIDをmetadataから取得
const userId = session.metadata?.userId;
if (!userId) {
return NextResponse.json(
{ error: "User ID not found" },
{ status: 400 }
);
}
// JWTトークンを生成
const token = await jose.SignJWT({
userId,
subscriptionId: subscription.id,
customerId: subscription.customer,
status: subscription.status,
currentPeriodEnd: subscription.current_period_end,
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30d")
.sign(jwtSecret);
return NextResponse.json({ token, subscription });
} catch (error) {
console.error("Subscription status error:", error);
return NextResponse.json(
{ error: "Failed to fetch subscription status" },
{ status: 500 }
);
}
}
処理内容の説明
-
stripe.checkout.sessions.retrieve()でCheckoutセッションの詳細を取得 - セッションから
subscriptionIDを抽出 -
stripe.subscriptions.retrieve()でサブスク情報を詳しく取得 -
jose.SignJWTで署名付きのJWTトークンを生成(有効期限30日) - クライアント側で
localStorageに保存して今後のAPI呼び出しで利用
Webhookの実装
Stripeはサブスク状態の変化(解約・支払い失敗など)をWebhookで通知します。
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = process.env.STRIPE_SECRET_KEY
? new Stripe(process.env.STRIPE_SECRET_KEY)
: null;
export async function POST(request: NextRequest) {
if (!stripe) {
return NextResponse.json(
{ error: "Stripe is not configured" },
{ status: 503 }
);
}
try {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing signature" },
{ status: 400 }
);
}
// Webhook署名を検証
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
// イベントタイプに応じた処理
switch (event.type) {
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error("Webhook error:", error);
return NextResponse.json(
{ error: "Webhook processing failed" },
{ status: 500 }
);
}
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
console.log(`Subscription ${subscription.id} was deleted`);
// 必要に応じてDB更新やメール送信処理を実装
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
console.log(`Subscription ${subscription.id} was updated:`, subscription.status);
// サブスク状態の変化を記録する処理など
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
console.log(`Payment failed for invoice ${invoice.id}`);
// 支払い失敗時の処理(メール送信など)
}
Webhook署名検証について
-
stripe.webhooks.constructEvent()で署名の正当性を検証 - 不正なリクエストを事前に防止
- イベントタイプごとに異なるハンドラーを実行
カスタマーポータルの実装
ユーザーがStripe管理画面にアクセスして解約・カード変更ができるようにします。
// app/api/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import * as jose from "jose";
const stripe = process.env.STRIPE_SECRET_KEY
? new Stripe(process.env.STRIPE_SECRET_KEY)
: null;
const jwtSecret = new TextEncoder().encode(
process.env.JWT_SECRET || "default-secret"
);
export async function POST(request: NextRequest) {
if (!stripe) {
return NextResponse.json(
{ error: "Stripe is not configured" },
{ status: 503 }
);
}
try {
const body = await request.json();
const { token } = body;
if (!token) {
return NextResponse.json(
{ error: "Token is required" },
{ status: 400 }
);
}
// JWTトークンを検証
const verified = await jose.jwtVerify(token, jwtSecret);
const customerId = verified.payload.customerId as string;
if (!customerId) {
return NextResponse.json(
{ error: "Invalid token" },
{ status: 401 }
);
}
// カスタマーポータルセッションを作成
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${request.nextUrl.origin}/dashboard`,
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error("Portal error:", error);
return NextResponse.json(
{ error: "Failed to create portal session" },
{ status: 500 }
);
}
}
動作フロー
- クライアントから有効なJWTトークンを受け取る
- トークンを検証して
customerIdを抽出 -
stripe.billingPortal.sessions.create()でポータルセッションを生成 - ポータルURLをク