IT

【Stripe】Next.js 15とPrismaで実装するサブスクリプション機能の開発

Next.js 15(App Router)とPrismaで、Stripeのサブスクリプションを実装した記録です。登録と決済の分け方、Webhookでの状態の同期、未払い時のアクセス制御、Stripe CLIでのローカル確認を紹介します。

この記事の結論:Next.js 15(App Router)とPrismaでStripeのサブスクリプションを作るときは、ユーザー登録と決済を分けてStripe Checkoutの支払い画面に送ります。決済状態の更新は、StripeのWebhook(checkout.session.completed など)を正として扱います。決済状態はPrismaのモデルにまとめて持ち、未払いのあいだは layout.tsx(サーバーコンポーネント)で管理画面を表示しないようにします。ローカルでは、Stripe CLIの stripe listen --forward-to でWebhookを受け取って確かめます。

この記事の内容のご相談は株式会社bundlyzeへbundlyzeのシステム開発について見てみる

1. はじめに

この記事は、Next.js 15(App Router)・Prisma・Stripeで、月額課金(サブスクリプション)を「登録→決済→利用開始」の流れで組み込んだときの記録です。

現在開発中の飲食店向けSaaSプラットフォームにおいて、Stripeを使用したサブスクリプション機能を実装しました。本記事では、Next.js 15 (App Router) と Prisma を組み合わせ、「ユーザー登録 → 決済 → アカウント有効化」という一連のフローをセキュアに構築する方法を解説します。


2. 実装要件とデータベース設計

Stripeのサブスクリプションを組み込むときは、先に「どのプランを売るか」「未払いのあいだ何を止めるか」を決め、Stripeの顧客IDとサブスクリプションIDを自分のデータベースに持たせます。

今回のSaaSモデルでは、以下の4点を必須要件として定義しました。

システム要件

  1. プランの多段階化: ライト(980円)とスタンダード(2,980円)の提供。
  2. 即時利用制限: 決済完了まで機能へのアクセスを遮断。
  3. Webhookによる自動更新: 決済成功・解約時のステータス同期。
  4. セルフプラン変更: 管理画面からのアップグレード対応。

Prismaによるスキーマ設計

ユーザー(飲食店)モデルにStripe関連のフィールドを統合し、決済状態をDBで一元管理します。

schema.prismaprisma
// 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とStripeでサブスクリプションを実装するときの要点は、「登録と決済を分ける」「決済の結果はWebhookで受け取る」「未払いのあいだは画面を出さない」の3つです。

3.1 「仮登録」と「決済」を分離するUX

ユーザー登録と支払い登録を分けることで、離脱を防ぎつつ確実に課金へ誘導します。

  • 新規登録後: 自動的に /admin/subscription へリダイレクト。
  • Stripe Checkout: サーバーサイドで支払いリンクを生成し、Stripeの堅牢な決済画面へ遷移。

Checkoutのセッションは、たとえば次のようにサーバー側で作ります(例。priceId はStripeで作ったプランの価格ID)。client_reference_id に自分のデータベースのIDを入れておくと、Webhookを受け取ったときにどの店舗の支払いかを特定できます。

ts
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によるステータス同期(最重要)

クライアント側の「完了画面」は、通信遮断などで100%の実行が保証されません。そのため、StripeからのWebhookを「唯一の真実」として扱います。Stripeの公式ドキュメントでも、サブスクリプションを売る場合はWebhookでの自動処理が必須とされています(Stripe:Fulfill orders、2026年10月2日確認)。

api/webhooks/stripe/route.ts の実装例:

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
        }
    });
}

補足(2026年10月2日追記):上のコードは要点だけを抜き出したものです。実際に動かすには、次の2点も必要です。

  1. 署名の検証:Webhookの本文は加工前の文字列のまま stripe.webhooks.constructEvent(本文, Stripe-Signatureヘッダー, エンドポイントのシークレット) に渡して、Stripeから届いたものか確かめます(Stripe:Webhook の署名を検証する、2026年10月2日確認)。App Routerでは await req.text() で本文を受け取ります。
  2. 解約・支払い失敗の同期:解約は customer.subscription.deleted、プランの変更や状態の変化は customer.subscription.updated、支払いの失敗は invoice.payment_failed のイベントで届きます(Stripe:イベントの種類、2026年10月2日確認)。

※2026年10月3日追記:上のコードは、公開時は restaurantId をどこから取るかが抜けていたので、Checkoutのセッションを作るときに入れた client_reference_id から取る1行を足しました。

あわせて、毎月の継続課金の成功は invoice.paid で届きます。Stripeの公式ドキュメントでは、invoice.paid を受け取ったらサブスクリプションの状態が active であることを確かめてから利用期間を延ばし、状態が canceled や unpaid になったら利用を止める流れが紹介されています(Stripe:サブスクリプションで Webhook を使用する、2026年10月3日確認)。

次のコードは、この2点を Stripe の公式ドキュメントに沿って書いた例です(記事の実装そのものではありません)。

ts
// 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(サーバーコンポーネント)を活用し、未払い状態では管理メニューそのものをレンダリングさせない仕組みを導入しました。

補足(2026年10月3日追記):Next.jsの公式ドキュメントでは、レイアウトは画面を移動しても再描画されないため、レイアウトだけでの確認に頼らないよう注意しています。また、Server ActionsやRoute Handlersは外から直接呼び出せる入口と同じように扱い、それぞれの中でも権限を確かめるよう勧めています(Next.js:Authentication、2026年10月3日確認)。レイアウトでメニューを隠すのは見た目の制御と考え、決済状態の確認はデータを取り出す関数にもまとめておくと漏れがありません。

ログインや権限の考え方そのものは、業務システムで、ログイン・権限・操作ログを最初に設計しておく理由でもまとめています。


4. 開発・デバッグ:Stripe CLIの活用

ローカル環境でStripeのWebhookを試すには、Stripe CLIの stripe listen --forward-to でイベントを自分のパソコンに転送します。

ローカル環境ではそのままではWebhookを受け取れないため、Stripe CLIを使用してイベントを転送します。

bash
# ローカルへのWebhook転送コマンド
stripe listen --forward-to localhost:3000/api/webhooks/stripe

この開発手法により、署名検証(whsec_...)を含めた本番同等のテストが可能になります。


株式会社bundlyzeでは、予約管理システムを企画から運用まで開発し、LINE公式アカウントと連携する仕組みも自社で開発しています。決済のように外部のサービスとつなぐ部分を含めた開発の進め方は、業務システム開発のページで紹介しています。

5. まとめ

Next.js 15とPrismaでStripeのサブスクリプションを作るなら、登録と決済を分け、決済状態はWebhookで受け取ってデータベースに持たせるのが確実です。

Next.js 15 と Prisma、そして Stripe を組み合わせることで、以下の成果が得られました。

  • 確実な収益化: 決済なしでの利用をシステムレベルで排除。
  • 運用の自動化: 入金確認作業のゼロ化。
  • スケール性: 容易なプラン追加や変更が可能な柔軟な設計。

SaaS開発において決済は信頼性が命です。この構成は、今後の拡張においても強力な基盤となると確信しています。

出典

下の6つは、どれも 2026-10-03 に内容を確認しました。

よくある質問

決済が完了したかどうかは、どこで判定すればいい?

StripeからのWebhookで判定します。クライアント側の完了画面は、通信が切れるなどで必ず実行されるとは限らないため、checkout.session.completed を受け取ったときにデータベースの決済状態を更新します。

ローカル環境でStripeのWebhookを試すには?

Stripe CLIの stripe listen --forward-to localhost:3000/api/webhooks/stripe で、イベントをローカルに転送します。署名検証(whsec_...)を含めて、本番と同じ流れで確かめられます。

未払いのユーザーに管理画面を見せないようにするには?

この記事の実装では、Next.jsの layout.tsx(サーバーコンポーネント)で決済状態を確かめ、未払いのあいだは管理メニューそのものを表示しないようにしました。ただしNext.jsの公式ドキュメントでは、レイアウトは画面を移動しても再描画されないため、確認はデータを取り出す処理の近く(Server Actions・Route Handlers・データ取得の関数)でも行うよう勧めています。

毎月の継続課金が成功したかは、どのイベントで分かる?

invoice.paid です。Stripeの公式ドキュメントでは、invoice.paid を受け取ってサブスクリプションの状態が active であることを確かめてから、利用できる期間を延ばす流れが紹介されています。解約は customer.subscription.deleted、支払いの失敗は invoice.payment_failed で届きます。

Next.jsのApp RouterでWebhookの署名検証が失敗するのはなぜ?

本文を加工してから検証していることが多い原因です。Route Handlerでは await req.text() で受け取った加工前の文字列を、そのまま stripe.webhooks.constructEvent() に渡します。Stripe CLIで転送している場合は、stripe listen が表示した whsec_... のシークレットを使っているかも確かめてください。

この記事の書き方と確認の方針