1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeを使ったStripe月額課金の実装|Next.js SaaS構築ガイド

1
Posted at

この記事でわかること

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セッションの詳細を取得
  • セッションから subscription IDを抽出
  • 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 }
    );
  }
}

動作フロー

  1. クライアントから有効なJWTトークンを受け取る
  2. トークンを検証して customerId を抽出
  3. stripe.billingPortal.sessions.create() でポータルセッションを生成
  4. ポータルURLをク
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?