Back to Journal
SaaS Engineering

How to Build Subscription Billing Systems Using Nextjs

Step-by-step tutorial for building Subscription Billing Systems with Nextjs, from project setup through deployment.

Muneer Puthiya Purayil 20 min read

Subscription billing is the revenue engine behind every SaaS product, but getting it right with Next.js requires more than just dropping in a Stripe checkout. You need webhook idempotency, plan management, usage metering, and graceful upgrade/downgrade flows — all while keeping your Next.js app responsive and your billing state consistent.

This tutorial walks through building a production subscription billing system using Next.js 14 App Router, Stripe, and Prisma. We'll cover everything from initial Stripe setup to handling edge cases like failed payments and mid-cycle plan changes.

Project Setup and Dependencies

Start with a Next.js project and install the billing dependencies:

bash
1npx create-next-app@latest saas-billing --app --typescript --tailwind
2cd saas-billing
3npm install stripe @stripe/stripe-js @prisma/client
4npm install -D prisma
5npx prisma init
6 

Configure your environment variables:

env
1STRIPE_SECRET_KEY=sk_test_...
2NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
3STRIPE_WEBHOOK_SECRET=whsec_...
4DATABASE_URL="postgresql://user:pass@localhost:5432/billing"
5 

Database Schema for Billing

Design your Prisma schema to mirror Stripe's subscription model:

prisma
1model User {
2 id String @id @default(cuid())
3 email String @unique
4 name String?
5 stripeCustomerId String? @unique @map("stripe_customer_id")
6 subscription Subscription?
7 invoices Invoice[]
8 createdAt DateTime @default(now()) @map("created_at")
9 updatedAt DateTime @updatedAt @map("updated_at")
10 
11 @@map("users")
12}
13 
14model Plan {
15 id String @id @default(cuid())
16 name String
17 slug String @unique
18 stripePriceId String @unique @map("stripe_price_id")
19 price Int // cents
20 interval String // 'month' | 'year'
21 features Json
22 limits Json // { apiCalls: 10000, storage: 5 }
23 sortOrder Int @default(0) @map("sort_order")
24 active Boolean @default(true)
25 subscriptions Subscription[]
26 createdAt DateTime @default(now()) @map("created_at")
27 
28 @@map("plans")
29}
30 
31model Subscription {
32 id String @id @default(cuid())
33 userId String @unique @map("user_id")
34 user User @relation(fields: [userId], references: [id])
35 planId String @map("plan_id")
36 plan Plan @relation(fields: [planId], references: [id])
37 stripeSubscriptionId String @unique @map("stripe_subscription_id")
38 status String // 'active' | 'past_due' | 'canceled' | 'trialing'
39 currentPeriodStart DateTime @map("current_period_start")
40 currentPeriodEnd DateTime @map("current_period_end")
41 cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
42 createdAt DateTime @default(now()) @map("created_at")
43 updatedAt DateTime @updatedAt @map("updated_at")
44 
45 @@map("subscriptions")
46}
47 
48model Invoice {
49 id String @id @default(cuid())
50 userId String @map("user_id")
51 user User @relation(fields: [userId], references: [id])
52 stripeInvoiceId String @unique @map("stripe_invoice_id")
53 amount Int // cents
54 currency String @default("usd")
55 status String // 'paid' | 'open' | 'void' | 'uncollectible'
56 invoiceUrl String? @map("invoice_url")
57 paidAt DateTime? @map("paid_at")
58 createdAt DateTime @default(now()) @map("created_at")
59 
60 @@map("invoices")
61}
62 
63model WebhookEvent {
64 id String @id @default(cuid())
65 stripeEventId String @unique @map("stripe_event_id")
66 type String
67 processed Boolean @default(false)
68 createdAt DateTime @default(now()) @map("created_at")
69 
70 @@map("webhook_events")
71}
72 

The WebhookEvent table is critical — it provides idempotency for webhook processing. Without it, retried webhooks can double-process state changes.

Stripe Client Configuration

Create a shared Stripe instance with proper configuration:

typescript
1// lib/stripe.ts
2import Stripe from 'stripe';
3 
4if (!process.env.STRIPE_SECRET_KEY) {
5 throw new Error('STRIPE_SECRET_KEY is not set');
6}
7 
8export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
9 apiVersion: '2024-06-20',
10 typescript: true,
11});
12 
13// Helper to get or create a Stripe customer
14export async function getOrCreateStripeCustomer(
15 userId: string,
16 email: string
17): Promise<string> {
18 const user = await prisma.user.findUnique({
19 where: { id: userId },
20 select: { stripeCustomerId: true },
21 });
22 
23 if (user?.stripeCustomerId) {
24 return user.stripeCustomerId;
25 }
26 
27 const customer = await stripe.customers.create({
28 email,
29 metadata: { userId },
30 });
31 
32 await prisma.user.update({
33 where: { id: userId },
34 data: { stripeCustomerId: customer.id },
35 });
36 
37 return customer.id;
38}
39 

Checkout Session API Route

Build the checkout endpoint that creates Stripe sessions:

typescript
1// app/api/billing/checkout/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { stripe, getOrCreateStripeCustomer } from '@/lib/stripe';
4import { prisma } from '@/lib/prisma';
5import { getServerSession } from '@/lib/auth';
6 
7export async function POST(request: NextRequest) {
8 const session = await getServerSession();
9 if (!session?.user) {
10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11 }
12 
13 const { priceId } = await request.json();
14 
15 // Validate the price exists in our system
16 const plan = await prisma.plan.findUnique({
17 where: { stripePriceId: priceId },
18 });
19 
20 if (!plan) {
21 return NextResponse.json({ error: 'Invalid plan' }, { status: 400 });
22 }
23 
24 // Check for existing active subscription
25 const existing = await prisma.subscription.findUnique({
26 where: { userId: session.user.id },
27 });
28 
29 if (existing && existing.status === 'active') {
30 return NextResponse.json(
31 { error: 'Already subscribed. Use plan change endpoint.' },
32 { status: 409 }
33 );
34 }
35 
36 const customerId = await getOrCreateStripeCustomer(
37 session.user.id,
38 session.user.email
39 );
40 
41 const checkoutSession = await stripe.checkout.sessions.create({
42 customer: customerId,
43 mode: 'subscription',
44 payment_method_types: ['card'],
45 line_items: [{ price: priceId, quantity: 1 }],
46 success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?session_id={CHECKOUT_SESSION_ID}`,
47 cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
48 subscription_data: {
49 metadata: {
50 userId: session.user.id,
51 planId: plan.id,
52 },
53 },
54 allow_promotion_codes: true,
55 });
56 
57 return NextResponse.json({ url: checkoutSession.url });
58}
59 

Webhook Handler with Idempotency

The webhook handler is the most critical piece. Every event must be processed exactly once:

typescript
1// app/api/webhooks/stripe/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { headers } from 'next/headers';
4import { stripe } from '@/lib/stripe';
5import { prisma } from '@/lib/prisma';
6import Stripe from 'stripe';
7 
8export async function POST(request: NextRequest) {
9 const body = await request.text();
10 const headersList = await headers();
11 const signature = headersList.get('stripe-signature');
12 
13 if (!signature) {
14 return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
15 }
16 
17 let event: Stripe.Event;
18 try {
19 event = stripe.webhooks.constructEvent(
20 body,
21 signature,
22 process.env.STRIPE_WEBHOOK_SECRET!
23 );
24 } catch (err) {
25 console.error('Webhook signature verification failed:', err);
26 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
27 }
28 
29 // Idempotency check
30 const existing = await prisma.webhookEvent.findUnique({
31 where: { stripeEventId: event.id },
32 });
33 
34 if (existing?.processed) {
35 return NextResponse.json({ received: true });
36 }
37 
38 // Record the event before processing
39 await prisma.webhookEvent.upsert({
40 where: { stripeEventId: event.id },
41 create: { stripeEventId: event.id, type: event.type },
42 update: {},
43 });
44 
45 try {
46 await handleStripeEvent(event);
47 
48 await prisma.webhookEvent.update({
49 where: { stripeEventId: event.id },
50 data: { processed: true },
51 });
52 } catch (error) {
53 console.error(`Failed to process ${event.type}:`, error);
54 // Don't mark as processed — Stripe will retry
55 return NextResponse.json(
56 { error: 'Processing failed' },
57 { status: 500 }
58 );
59 }
60 
61 return NextResponse.json({ received: true });
62}
63 
64async function handleStripeEvent(event: Stripe.Event) {
65 switch (event.type) {
66 case 'checkout.session.completed':
67 await handleCheckoutComplete(
68 event.data.object as Stripe.Checkout.Session
69 );
70 break;
71 
72 case 'customer.subscription.updated':
73 await handleSubscriptionUpdated(
74 event.data.object as Stripe.Subscription
75 );
76 break;
77 
78 case 'customer.subscription.deleted':
79 await handleSubscriptionDeleted(
80 event.data.object as Stripe.Subscription
81 );
82 break;
83 
84 case 'invoice.paid':
85 await handleInvoicePaid(event.data.object as Stripe.Invoice);
86 break;
87 
88 case 'invoice.payment_failed':
89 await handlePaymentFailed(event.data.object as Stripe.Invoice);
90 break;
91 }
92}
93 

Subscription Event Handlers

Each webhook event needs a dedicated handler with proper error handling:

typescript
1// lib/billing/handlers.ts
2import { prisma } from '@/lib/prisma';
3import { stripe } from '@/lib/stripe';
4import Stripe from 'stripe';
5 
6export async function handleCheckoutComplete(
7 session: Stripe.Checkout.Session
8) {
9 if (session.mode !== 'subscription') return;
10 
11 const subscription = await stripe.subscriptions.retrieve(
12 session.subscription as string
13 );
14 
15 const userId = subscription.metadata.userId;
16 const planId = subscription.metadata.planId;
17 
18 if (!userId || !planId) {
19 throw new Error('Missing metadata on subscription');
20 }
21 
22 await prisma.subscription.upsert({
23 where: { userId },
24 create: {
25 userId,
26 planId,
27 stripeSubscriptionId: subscription.id,
28 status: subscription.status,
29 currentPeriodStart: new Date(
30 subscription.current_period_start * 1000
31 ),
32 currentPeriodEnd: new Date(
33 subscription.current_period_end * 1000
34 ),
35 },
36 update: {
37 planId,
38 stripeSubscriptionId: subscription.id,
39 status: subscription.status,
40 currentPeriodStart: new Date(
41 subscription.current_period_start * 1000
42 ),
43 currentPeriodEnd: new Date(
44 subscription.current_period_end * 1000
45 ),
46 },
47 });
48}
49 
50export async function handleSubscriptionUpdated(
51 subscription: Stripe.Subscription
52) {
53 const dbSub = await prisma.subscription.findUnique({
54 where: { stripeSubscriptionId: subscription.id },
55 });
56 
57 if (!dbSub) {
58 console.warn(`No local subscription for ${subscription.id}`);
59 return;
60 }
61 
62 // Resolve the new plan from the Stripe price
63 const priceId = subscription.items.data[0]?.price.id;
64 const plan = priceId
65 ? await prisma.plan.findUnique({
66 where: { stripePriceId: priceId },
67 })
68 : null;
69 
70 await prisma.subscription.update({
71 where: { stripeSubscriptionId: subscription.id },
72 data: {
73 status: subscription.status,
74 planId: plan?.id ?? dbSub.planId,
75 currentPeriodStart: new Date(
76 subscription.current_period_start * 1000
77 ),
78 currentPeriodEnd: new Date(
79 subscription.current_period_end * 1000
80 ),
81 cancelAtPeriodEnd: subscription.cancel_at_period_end,
82 },
83 });
84}
85 
86export async function handleSubscriptionDeleted(
87 subscription: Stripe.Subscription
88) {
89 await prisma.subscription.update({
90 where: { stripeSubscriptionId: subscription.id },
91 data: { status: 'canceled' },
92 });
93}
94 
95export async function handleInvoicePaid(invoice: Stripe.Invoice) {
96 if (!invoice.customer) return;
97 
98 const user = await prisma.user.findUnique({
99 where: { stripeCustomerId: invoice.customer as string },
100 });
101 
102 if (!user) return;
103 
104 await prisma.invoice.upsert({
105 where: { stripeInvoiceId: invoice.id },
106 create: {
107 userId: user.id,
108 stripeInvoiceId: invoice.id,
109 amount: invoice.amount_paid,
110 currency: invoice.currency,
111 status: 'paid',
112 invoiceUrl: invoice.hosted_invoice_url,
113 paidAt: new Date(),
114 },
115 update: {
116 status: 'paid',
117 amount: invoice.amount_paid,
118 paidAt: new Date(),
119 },
120 });
121}
122 
123export async function handlePaymentFailed(invoice: Stripe.Invoice) {
124 const user = await prisma.user.findUnique({
125 where: { stripeCustomerId: invoice.customer as string },
126 });
127 
128 if (!user) return;
129 
130 await prisma.invoice.upsert({
131 where: { stripeInvoiceId: invoice.id },
132 create: {
133 userId: user.id,
134 stripeInvoiceId: invoice.id,
135 amount: invoice.amount_due,
136 currency: invoice.currency,
137 status: 'open',
138 invoiceUrl: invoice.hosted_invoice_url,
139 },
140 update: { status: 'open' },
141 });
142 
143 // Update subscription to past_due
144 if (invoice.subscription) {
145 await prisma.subscription.updateMany({
146 where: {
147 stripeSubscriptionId: invoice.subscription as string,
148 },
149 data: { status: 'past_due' },
150 });
151 }
152}
153 

Plan Change (Upgrade/Downgrade) API

Handling mid-cycle plan changes requires proration logic:

typescript
1// app/api/billing/change-plan/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { stripe } from '@/lib/stripe';
4import { prisma } from '@/lib/prisma';
5import { getServerSession } from '@/lib/auth';
6 
7export async function POST(request: NextRequest) {
8 const session = await getServerSession();
9 if (!session?.user) {
10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11 }
12 
13 const { newPriceId } = await request.json();
14 
15 const subscription = await prisma.subscription.findUnique({
16 where: { userId: session.user.id },
17 include: { plan: true },
18 });
19 
20 if (!subscription || subscription.status !== 'active') {
21 return NextResponse.json(
22 { error: 'No active subscription' },
23 { status: 400 }
24 );
25 }
26 
27 const newPlan = await prisma.plan.findUnique({
28 where: { stripePriceId: newPriceId },
29 });
30 
31 if (!newPlan) {
32 return NextResponse.json({ error: 'Invalid plan' }, { status: 400 });
33 }
34 
35 // Retrieve the Stripe subscription to get the item ID
36 const stripeSub = await stripe.subscriptions.retrieve(
37 subscription.stripeSubscriptionId
38 );
39 
40 const itemId = stripeSub.items.data[0].id;
41 
42 // Determine proration behavior based on upgrade vs downgrade
43 const isUpgrade = newPlan.price > subscription.plan.price;
44 
45 const updated = await stripe.subscriptions.update(
46 subscription.stripeSubscriptionId,
47 {
48 items: [{ id: itemId, price: newPriceId }],
49 proration_behavior: isUpgrade
50 ? 'create_prorations' // Charge difference immediately
51 : 'none', // Apply at next billing cycle
52 ...(isUpgrade
53 ? {}
54 : {
55 cancel_at_period_end: false,
56 billing_cycle_anchor: 'unchanged',
57 }),
58 }
59 );
60 
61 // Local update happens via webhook, but update optimistically
62 await prisma.subscription.update({
63 where: { id: subscription.id },
64 data: { planId: newPlan.id },
65 });
66 
67 return NextResponse.json({
68 subscription: {
69 planId: newPlan.id,
70 planName: newPlan.name,
71 status: updated.status,
72 },
73 });
74}
75 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Subscription Cancellation Flow

Implement a cancellation flow that retains customers until the period ends:

typescript
1// app/api/billing/cancel/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { stripe } from '@/lib/stripe';
4import { prisma } from '@/lib/prisma';
5import { getServerSession } from '@/lib/auth';
6 
7export async function POST(request: NextRequest) {
8 const session = await getServerSession();
9 if (!session?.user) {
10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11 }
12 
13 const { immediate } = await request.json();
14 
15 const subscription = await prisma.subscription.findUnique({
16 where: { userId: session.user.id },
17 });
18 
19 if (!subscription) {
20 return NextResponse.json(
21 { error: 'No subscription found' },
22 { status: 404 }
23 );
24 }
25 
26 if (immediate) {
27 // Immediate cancellation — issue prorated refund
28 await stripe.subscriptions.cancel(
29 subscription.stripeSubscriptionId,
30 { prorate: true }
31 );
32 } else {
33 // Cancel at period end — user retains access
34 await stripe.subscriptions.update(
35 subscription.stripeSubscriptionId,
36 { cancel_at_period_end: true }
37 );
38 }
39 
40 // Webhook will update the final state, but set optimistically
41 await prisma.subscription.update({
42 where: { id: subscription.id },
43 data: {
44 cancelAtPeriodEnd: !immediate,
45 status: immediate ? 'canceled' : subscription.status,
46 },
47 });
48 
49 return NextResponse.json({ canceled: true, immediate });
50}
51 
52// Reactivation — undo a pending cancellation
53export async function DELETE(request: NextRequest) {
54 const session = await getServerSession();
55 if (!session?.user) {
56 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
57 }
58 
59 const subscription = await prisma.subscription.findUnique({
60 where: { userId: session.user.id },
61 });
62 
63 if (!subscription?.cancelAtPeriodEnd) {
64 return NextResponse.json(
65 { error: 'No pending cancellation' },
66 { status: 400 }
67 );
68 }
69 
70 await stripe.subscriptions.update(
71 subscription.stripeSubscriptionId,
72 { cancel_at_period_end: false }
73 );
74 
75 await prisma.subscription.update({
76 where: { id: subscription.id },
77 data: { cancelAtPeriodEnd: false },
78 });
79 
80 return NextResponse.json({ reactivated: true });
81}
82 

Pricing Page Component

Build a pricing page that handles plan selection and checkout:

typescript
1// app/pricing/page.tsx
2import { prisma } from '@/lib/prisma';
3import { PricingCard } from './pricing-card';
4 
5export default async function PricingPage() {
6 const plans = await prisma.plan.findMany({
7 where: { active: true },
8 orderBy: { sortOrder: 'asc' },
9 });
10 
11 return (
12 <div className="mx-auto max-w-5xl px-4 py-16">
13 <h1 className="text-center text-4xl font-bold">
14 Simple, transparent pricing
15 </h1>
16 <p className="mt-4 text-center text-gray-400">
17 Start free, scale as you grow
18 </p>
19 
20 <div className="mt-12 grid gap-8 md:grid-cols-3">
21 {plans.map((plan) => (
22 <PricingCard key={plan.id} plan={plan} />
23 ))}
24 </div>
25 </div>
26 );
27}
28 
typescript
1// app/pricing/pricing-card.tsx
2'use client';
3 
4import { useState } from 'react';
5import { useRouter } from 'next/navigation';
6import type { Plan } from '@prisma/client';
7 
8export function PricingCard({ plan }: { plan: Plan }) {
9 const [loading, setLoading] = useState(false);
10 const router = useRouter();
11 const features = plan.features as string[];
12 
13 async function handleSubscribe() {
14 setLoading(true);
15 
16 const res = await fetch('/api/billing/checkout', {
17 method: 'POST',
18 headers: { 'Content-Type': 'application/json' },
19 body: JSON.stringify({ priceId: plan.stripePriceId }),
20 });
21 
22 const data = await res.json();
23 
24 if (data.url) {
25 window.location.href = data.url;
26 } else if (res.status === 409) {
27 router.push('/billing');
28 } else {
29 console.error('Checkout failed:', data.error);
30 setLoading(false);
31 }
32 }
33 
34 return (
35 <div className="rounded-2xl border border-gray-800 bg-gray-900 p-8">
36 <h3 className="text-lg font-semibold">{plan.name}</h3>
37 <div className="mt-4">
38 <span className="text-4xl font-bold">
39 ${(plan.price / 100).toFixed(0)}
40 </span>
41 <span className="text-gray-400">/{plan.interval}</span>
42 </div>
43 
44 <ul className="mt-6 space-y-3">
45 {features.map((feature) => (
46 <li key={feature} className="flex items-center gap-2">
47 <CheckIcon className="h-4 w-4 text-green-500" />
48 <span className="text-sm text-gray-300">{feature}</span>
49 </li>
50 ))}
51 </ul>
52 
53 <button
54 onClick={handleSubscribe}
55 disabled={loading}
56 className="mt-8 w-full rounded-lg bg-white px-4 py-2.5 text-sm font-medium text-black transition hover:bg-gray-200 disabled:opacity-50"
57 >
58 {loading ? 'Redirecting...' : 'Get started'}
59 </button>
60 </div>
61 );
62}
63 
64function CheckIcon({ className }: { className?: string }) {
65 return (
66 <svg className={className} viewBox="0 0 16 16" fill="currentColor">
67 <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
68 </svg>
69 );
70}
71 

Billing Dashboard

Create a dashboard where users manage their subscription:

typescript
1// app/billing/page.tsx
2import { redirect } from 'next/navigation';
3import { getServerSession } from '@/lib/auth';
4import { prisma } from '@/lib/prisma';
5import { stripe } from '@/lib/stripe';
6import { BillingActions } from './billing-actions';
7 
8export default async function BillingPage() {
9 const session = await getServerSession();
10 if (!session?.user) redirect('/login');
11 
12 const subscription = await prisma.subscription.findUnique({
13 where: { userId: session.user.id },
14 include: { plan: true },
15 });
16 
17 const invoices = await prisma.invoice.findMany({
18 where: { userId: session.user.id },
19 orderBy: { createdAt: 'desc' },
20 take: 10,
21 });
22 
23 // Create a portal session for payment method management
24 let portalUrl: string | null = null;
25 const user = await prisma.user.findUnique({
26 where: { id: session.user.id },
27 select: { stripeCustomerId: true },
28 });
29 
30 if (user?.stripeCustomerId) {
31 const portalSession =
32 await stripe.billingPortal.sessions.create({
33 customer: user.stripeCustomerId,
34 return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
35 });
36 portalUrl = portalSession.url;
37 }
38 
39 return (
40 <div className="mx-auto max-w-3xl px-4 py-16">
41 <h1 className="text-2xl font-bold">Billing</h1>
42 
43 {subscription ? (
44 <div className="mt-8 rounded-xl border border-gray-800 p-6">
45 <div className="flex items-center justify-between">
46 <div>
47 <p className="text-sm text-gray-400">Current plan</p>
48 <p className="text-xl font-semibold">
49 {subscription.plan.name}
50 </p>
51 </div>
52 <div className="text-right">
53 <p className="text-sm text-gray-400">Status</p>
54 <StatusBadge status={subscription.status} />
55 </div>
56 </div>
57 
58 <div className="mt-4 text-sm text-gray-400">
59 {subscription.cancelAtPeriodEnd ? (
60 <p>
61 Cancels on{' '}
62 {subscription.currentPeriodEnd.toLocaleDateString()}
63 </p>
64 ) : (
65 <p>
66 Renews on{' '}
67 {subscription.currentPeriodEnd.toLocaleDateString()}
68 </p>
69 )}
70 </div>
71 
72 <BillingActions
73 subscription={subscription}
74 portalUrl={portalUrl}
75 />
76 </div>
77 ) : (
78 <div className="mt-8 rounded-xl border border-gray-800 p-6 text-center">
79 <p className="text-gray-400">No active subscription</p>
80 <a
81 href="/pricing"
82 className="mt-4 inline-block rounded-lg bg-white px-4 py-2 text-sm font-medium text-black"
83 >
84 View plans
85 </a>
86 </div>
87 )}
88 
89 {invoices.length > 0 && (
90 <div className="mt-8">
91 <h2 className="text-lg font-semibold">Invoice history</h2>
92 <div className="mt-4 space-y-2">
93 {invoices.map((inv) => (
94 <div
95 key={inv.id}
96 className="flex items-center justify-between rounded-lg border border-gray-800 px-4 py-3"
97 >
98 <div>
99 <p className="text-sm">
100 ${(inv.amount / 100).toFixed(2)}{' '}
101 {inv.currency.toUpperCase()}
102 </p>
103 <p className="text-xs text-gray-500">
104 {inv.createdAt.toLocaleDateString()}
105 </p>
106 </div>
107 <div className="flex items-center gap-3">
108 <StatusBadge status={inv.status} />
109 {inv.invoiceUrl && (
110 <a
111 href={inv.invoiceUrl}
112 target="_blank"
113 rel="noopener"
114 className="text-xs text-blue-400 hover:underline"
115 >
116 View
117 </a>
118 )}
119 </div>
120 </div>
121 ))}
122 </div>
123 </div>
124 )}
125 </div>
126 );
127}
128 
129function StatusBadge({ status }: { status: string }) {
130 const colors: Record<string, string> = {
131 active: 'bg-green-500/10 text-green-400',
132 paid: 'bg-green-500/10 text-green-400',
133 trialing: 'bg-blue-500/10 text-blue-400',
134 past_due: 'bg-yellow-500/10 text-yellow-400',
135 canceled: 'bg-red-500/10 text-red-400',
136 open: 'bg-yellow-500/10 text-yellow-400',
137 };
138 
139 return (
140 <span
141 className={`rounded-full px-2 py-0.5 text-xs font-medium ${
142 colors[status] ?? 'bg-gray-500/10 text-gray-400'
143 }`}
144 >
145 {status}
146 </span>
147 );
148}
149 

Feature Gating Middleware

Enforce plan limits across your application:

typescript
1// lib/billing/limits.ts
2import { prisma } from '@/lib/prisma';
3 
4interface PlanLimits {
5 apiCalls: number;
6 storage: number; // GB
7 teamMembers: number;
8 projects: number;
9}
10 
11export async function checkLimit(
12 userId: string,
13 resource: keyof PlanLimits,
14 currentUsage: number
15): Promise<{ allowed: boolean; limit: number; usage: number }> {
16 const subscription = await prisma.subscription.findUnique({
17 where: { userId },
18 include: { plan: true },
19 });
20 
21 if (!subscription || subscription.status !== 'active') {
22 return { allowed: false, limit: 0, usage: currentUsage };
23 }
24 
25 const limits = subscription.plan.limits as PlanLimits;
26 const limit = limits[resource];
27 
28 return {
29 allowed: currentUsage < limit,
30 limit,
31 usage: currentUsage,
32 };
33}
34 
35// Middleware for API routes
36export function withPlanLimit(resource: keyof PlanLimits) {
37 return async function middleware(
38 userId: string,
39 currentUsage: number
40 ) {
41 const { allowed, limit, usage } = await checkLimit(
42 userId,
43 resource,
44 currentUsage
45 );
46 
47 if (!allowed) {
48 throw new PlanLimitError(resource, limit, usage);
49 }
50 };
51}
52 
53export class PlanLimitError extends Error {
54 constructor(
55 public resource: string,
56 public limit: number,
57 public usage: number
58 ) {
59 super(
60 `Plan limit reached for ${resource}: ${usage}/${limit}`
61 );
62 this.name = 'PlanLimitError';
63 }
64}
65 

Usage in an API route:

typescript
1// app/api/projects/route.ts
2import { withPlanLimit } from '@/lib/billing/limits';
3 
4export async function POST(request: NextRequest) {
5 const session = await getServerSession();
6 if (!session?.user) {
7 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
8 }
9 
10 const projectCount = await prisma.project.count({
11 where: { userId: session.user.id },
12 });
13 
14 try {
15 await withPlanLimit('projects')(session.user.id, projectCount);
16 } catch (error) {
17 if (error instanceof PlanLimitError) {
18 return NextResponse.json(
19 {
20 error: 'Plan limit reached',
21 limit: error.limit,
22 usage: error.usage,
23 upgradeUrl: '/pricing',
24 },
25 { status: 403 }
26 );
27 }
28 throw error;
29 }
30 
31 // Proceed with project creation...
32}
33 

Usage Metering for Pay-As-You-Go

For API call metering, report usage to Stripe:

typescript
1// lib/billing/metering.ts
2import { stripe } from '@/lib/stripe';
3import { prisma } from '@/lib/prisma';
4 
5export async function reportUsage(
6 userId: string,
7 quantity: number
8) {
9 const subscription = await prisma.subscription.findUnique({
10 where: { userId },
11 select: { stripeSubscriptionId: true },
12 });
13 
14 if (!subscription) return;
15 
16 const stripeSub = await stripe.subscriptions.retrieve(
17 subscription.stripeSubscriptionId
18 );
19 
20 // Find the metered price item
21 const meteredItem = stripeSub.items.data.find(
22 (item) => item.price.recurring?.usage_type === 'metered'
23 );
24 
25 if (!meteredItem) return;
26 
27 await stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
28 quantity,
29 timestamp: Math.floor(Date.now() / 1000),
30 action: 'increment',
31 });
32}
33 

Testing the Billing Flow

Use Stripe's test cards and CLI for local development:

bash
1# Install Stripe CLI and forward webhooks
2stripe listen --forward-to localhost:3000/api/webhooks/stripe
3 
4# Test card numbers:
5# Success: 4242 4242 4242 4242
6# Decline: 4000 0000 0000 0002
7# 3D Secure: 4000 0025 0000 3155
8# Insufficient funds: 4000 0000 0000 9995
9 

Write integration tests for the webhook handler:

typescript
1// __tests__/billing/webhooks.test.ts
2import { POST } from '@/app/api/webhooks/stripe/route';
3import { prisma } from '@/lib/prisma';
4import { stripe } from '@/lib/stripe';
5 
6describe('Stripe webhooks', () => {
7 it('creates subscription on checkout.session.completed', async () => {
8 const event = createMockEvent('checkout.session.completed', {
9 mode: 'subscription',
10 subscription: 'sub_test_123',
11 customer: 'cus_test_456',
12 });
13 
14 // Mock Stripe subscription retrieval
15 jest.spyOn(stripe.subscriptions, 'retrieve').mockResolvedValue({
16 id: 'sub_test_123',
17 status: 'active',
18 metadata: { userId: 'user_1', planId: 'plan_1' },
19 current_period_start: Math.floor(Date.now() / 1000),
20 current_period_end: Math.floor(Date.now() / 1000) + 2592000,
21 items: { data: [{ price: { id: 'price_test' } }] },
22 } as any);
23 
24 const request = createWebhookRequest(event);
25 const response = await POST(request);
26 
27 expect(response.status).toBe(200);
28 
29 const subscription = await prisma.subscription.findUnique({
30 where: { userId: 'user_1' },
31 });
32 expect(subscription?.status).toBe('active');
33 expect(subscription?.stripeSubscriptionId).toBe('sub_test_123');
34 });
35 
36 it('handles duplicate events idempotently', async () => {
37 const event = createMockEvent('invoice.paid', {
38 id: 'inv_test_789',
39 customer: 'cus_test_456',
40 amount_paid: 2900,
41 currency: 'usd',
42 });
43 
44 // Process twice
45 await POST(createWebhookRequest(event));
46 await POST(createWebhookRequest(event));
47 
48 // Should only have one invoice record
49 const invoices = await prisma.invoice.findMany({
50 where: { stripeInvoiceId: 'inv_test_789' },
51 });
52 expect(invoices).toHaveLength(1);
53 });
54});
55 

Production Checklist

Before going live, verify these critical items:

  1. Webhook endpoint registered in Stripe Dashboard with all required events
  2. Idempotency confirmed — replay webhooks and verify no duplicate state changes
  3. Error monitoring set up for failed webhook processing (Sentry, Datadog)
  4. Stripe CLI used to test all event types locally before deploy
  5. Customer portal configured in Stripe for self-service payment method updates
  6. Dunning emails enabled in Stripe settings for failed payment retry
  7. Plan migration path tested — upgrade, downgrade, cancel, reactivate
  8. Proration verified with real test subscriptions (not just mock data)
  9. Database indexes on stripe_customer_id, stripe_subscription_id, stripe_event_id
  10. Rate limiting on checkout endpoint to prevent abuse

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026