Build a Stripe Subscription SaaS in 2026
TL;DR
A complete Stripe subscription integration requires about 6 components: create products/prices (once), checkout session (new subscriptions), customer portal (manage subscriptions), webhooks (sync payment state), middleware guard (protect premium features), and billing page (show status + portal link). Each part is straightforward in isolation, but they must connect correctly or subscriptions go out of sync. This guide covers all six with production-ready Next.js code.
Key Takeaways
- Never store plan state yourself — derive it from Stripe via webhooks
- Checkout Session → creates subscriptions, returns
stripeCustomerIdin webhook - Customer Portal → lets users upgrade, downgrade, cancel without you building a billing UI
- Webhooks → the single source of truth for subscription state changes
- Trial periods: set in
subscription_data.trial_period_days, tracked viasubscription.status === 'trialing' - Dunning: Stripe retries automatically, you just send notification emails on
invoice.payment_failed
Step 1: Create Products and Prices (Stripe Dashboard or API)
// scripts/seed-stripe.ts — run once to set up products/prices:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
// Create product:
const product = await stripe.products.create({
name: 'Pro Plan',
description: 'Full access to all features',
});
// Create monthly price:
const monthlyPrice = await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00 in cents
currency: 'usd',
recurring: { interval: 'month' },
nickname: 'Pro Monthly',
});
// Create annual price (17% discount):
const annualPrice = await stripe.prices.create({
product: product.id,
unit_amount: 29000, // $290/year vs $348/year monthly
currency: 'usd',
recurring: { interval: 'year' },
nickname: 'Pro Annual',
});
console.log('Monthly Price ID:', monthlyPrice.id); // price_xxx — save to .env
console.log('Annual Price ID:', annualPrice.id);
# .env
STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxMonthly
STRIPE_PRO_ANNUAL_PRICE_ID=price_xxxAnnual
Step 2: Checkout Session (New Subscriptions)
// app/api/billing/checkout/route.ts
import Stripe from 'stripe';
import { auth } from '@/auth';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const session = await auth();
if (!session?.user) return new Response('Unauthorized', { status: 401 });
const { priceId, billingInterval } = await req.json();
// Validate the price ID:
const validPrices = [
process.env.STRIPE_PRO_MONTHLY_PRICE_ID,
process.env.STRIPE_PRO_ANNUAL_PRICE_ID,
];
if (!validPrices.includes(priceId)) {
return new Response('Invalid price', { status: 400 });
}
const user = await db.user.findUnique({ where: { id: session.user.id } });
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
// Pass user info to pre-fill checkout:
customer_email: user?.stripeCustomerId ? undefined : session.user.email,
customer: user?.stripeCustomerId ?? undefined,
// Metadata links the checkout to your user:
metadata: { userId: session.user.id },
subscription_data: {
metadata: { userId: session.user.id },
trial_period_days: 14, // Optional: 14-day free trial
},
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
// Allow users to apply promo codes:
allow_promotion_codes: true,
});
return Response.json({ url: checkoutSession.url });
}
// components/UpgradeButton.tsx:
'use client';
export function UpgradeButton({ priceId }: { priceId: string }) {
const [loading, setLoading] = useState(false);
const handleUpgrade = async () => {
setLoading(true);
const res = await fetch('/api/billing/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
});
const { url } = await res.json();
window.location.href = url; // Redirect to Stripe-hosted checkout
};
return (
<button onClick={handleUpgrade} disabled={loading}>
{loading ? 'Redirecting...' : 'Upgrade to Pro'}
</button>
);
}
Step 3: Customer Portal (Self-Service Billing Management)
// app/api/billing/portal/route.ts
import Stripe from 'stripe';
import { auth } from '@/auth';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const session = await auth();
if (!session?.user) return new Response('Unauthorized', { status: 401 });
const user = await db.user.findUnique({ where: { id: session.user.id } });
if (!user?.stripeCustomerId) {
return new Response('No subscription found', { status: 400 });
}
// Create a portal session:
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
});
// Redirect to the portal:
return Response.redirect(portalSession.url);
}
The Customer Portal handles: plan changes, cancellation, payment method updates, invoice history — all without you building any UI.
Step 4: Webhooks (Source of Truth)
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const sig = (await headers()).get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// Process async, return 200 immediately:
handleEvent(event).catch(console.error);
return new Response('OK', { status: 200 });
}
async function handleEvent(event: Stripe.Event) {
// Idempotency check:
const existing = await db.stripeEvent.findUnique({
where: { stripeEventId: event.id },
});
if (existing) return;
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== 'subscription') break;
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
// Store the customer ID from the checkout:
await db.user.update({
where: { id: session.metadata?.userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscription.id,
subscriptionStatus: subscription.status,
plan: getPlanFromPriceId(subscription.items.data[0].price.id),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
trialEndsAt: subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null,
},
});
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscription(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
plan: 'free',
subscriptionStatus: 'canceled',
stripeSubscriptionId: null,
currentPeriodEnd: null,
},
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const user = await db.user.findUnique({
where: { stripeCustomerId: invoice.customer as string },
});
if (user) {
await sendPaymentFailedEmail(user.email);
}
break;
}
}
await db.stripeEvent.create({
data: { stripeEventId: event.id, type: event.type, processedAt: new Date() },
});
}
async function syncSubscription(subscription: Stripe.Subscription) {
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
plan: getPlanFromPriceId(subscription.items.data[0].price.id),
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}
function getPlanFromPriceId(priceId: string): string {
const map: Record<string, string> = {
[process.env.STRIPE_PRO_MONTHLY_PRICE_ID!]: 'pro',
[process.env.STRIPE_PRO_ANNUAL_PRICE_ID!]: 'pro',
};
return map[priceId] ?? 'free';
}
Step 5: Protect Premium Features
// lib/subscription.ts — check subscription status:
import { auth } from '@/auth';
export async function requirePro() {
const session = await auth();
if (!session?.user) redirect('/login');
const user = await db.user.findUnique({ where: { id: session.user.id } });
const hasAccess =
user?.plan === 'pro' &&
(user?.subscriptionStatus === 'active' || user?.subscriptionStatus === 'trialing');
if (!hasAccess) redirect('/pricing?upgrade=required');
return user;
}
// app/dashboard/advanced/page.tsx — premium-only page:
import { requirePro } from '@/lib/subscription';
export default async function AdvancedPage() {
const user = await requirePro(); // Redirects to /pricing if not pro
return <div>Welcome, {user.name}! Premium feature here.</div>;
}
Step 6: Billing Page
// app/dashboard/billing/page.tsx
import { auth } from '@/auth';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export default async function BillingPage() {
const session = await auth();
const user = await db.user.findUnique({ where: { id: session!.user.id } });
// Fetch current invoice if subscribed:
let nextInvoice = null;
if (user?.stripeSubscriptionId) {
const upcoming = await stripe.invoices.retrieveUpcoming({
customer: user.stripeCustomerId!,
}).catch(() => null);
if (upcoming) {
nextInvoice = {
amount: upcoming.amount_due / 100,
date: new Date(upcoming.next_payment_attempt! * 1000),
};
}
}
return (
<div>
<h1>Billing</h1>
<p>Current plan: {user?.plan ?? 'Free'}</p>
<p>Status: {user?.subscriptionStatus ?? 'N/A'}</p>
{user?.trialEndsAt && new Date() < user.trialEndsAt && (
<p>Trial ends: {user.trialEndsAt.toLocaleDateString()}</p>
)}
{user?.currentPeriodEnd && (
<p>
{user.cancelAtPeriodEnd ? 'Access ends' : 'Next billing date'}:{' '}
{user.currentPeriodEnd.toLocaleDateString()}
</p>
)}
{nextInvoice && (
<p>Next invoice: ${nextInvoice.amount.toFixed(2)} on {nextInvoice.date.toLocaleDateString()}</p>
)}
{user?.stripeCustomerId ? (
<form action="/api/billing/portal" method="POST">
<button type="submit">Manage Subscription</button>
</form>
) : (
<a href="/pricing">Upgrade to Pro</a>
)}
</div>
);
}
Handling Subscription Edge Cases
The six-step flow covers the happy path. These edge cases appear in real SaaS deployments:
Mid-cycle plan upgrades: when a user upgrades from monthly $29 to monthly $99 mid-cycle, Stripe generates a proration credit for the unused portion of the old plan and charges for the new plan. The customer.subscription.updated webhook fires with proration_behavior: 'create_prorations' by default. Your syncSubscription() function handles this correctly — it just reads the new price ID. No special handling needed unless you want to show users a proration breakdown before they confirm.
Paused subscriptions: Stripe supports pausing subscriptions (Dashboard or API: pause_collection: { behavior: 'mark_uncollectible' }). During a pause, the subscription status stays active but invoices are marked uncollectible. Expose this via Customer Portal settings: features.subscription_pause.enabled: true. Handle the customer.subscription.updated event and check pause_collection in the subscription object.
Quantity changes: B2B SaaS often charges per seat. When a team adds a user, call stripe.subscriptions.update({ items: [{ id: subscriptionItemId, quantity: newCount }] }). Stripe prorates the change. Track subscriptionItemId in your database (it's subscription.items.data[0].id from the webhook).
Trial-to-paid conversion rate: track users who start a trial (trial_end set) vs convert to paid (subscription.status changes from trialing to active). This is your most important conversion metric. Stripe Sigma (SQL on your Stripe data) can query this directly.
Going Live: Pre-Launch Checklist
Before enabling live Stripe keys:
- Webhook endpoint registered in Stripe Dashboard with correct events (see Step 4)
-
STRIPE_WEBHOOK_SECRETset to the live webhook signing secret (different from test secret) - Price IDs in production environment point to live prices (not test prices)
- Tax settings configured: Dashboard → Tax → Add tax registration for your jurisdiction
- Customer Portal configured: Dashboard → Billing → Customer Portal → enable/disable features
- Test the complete flow: checkout → successful payment → webhook fires → user plan updated
- Test the failure flow: use card
4000000000000341(always declines after trial) to verify dunning emails - Stripe Radar rules reviewed — check defaults don't block legitimate customers
- Rate limiting on
/api/billing/checkoutto prevent abuse
Multi-Seat and Usage-Based Billing
Most SaaS apps start with flat-rate pricing, but seat-based and usage-based billing are standard in B2B products. Stripe supports both without architectural changes.
For seat-based billing, the subscription has a quantity field. When a team adds a user, update the subscription quantity:
// Add a seat when a new team member is invited:
async function addSeat(stripeSubscriptionId: string) {
const subscription = await stripe.subscriptions.retrieve(stripeSubscriptionId);
const currentQuantity = subscription.items.data[0].quantity ?? 1;
await stripe.subscriptions.update(stripeSubscriptionId, {
items: [{
id: subscription.items.data[0].id,
quantity: currentQuantity + 1,
}],
proration_behavior: 'create_prorations', // Charge immediately for added seat
});
}
Stripe prorates the charge: if a seat is added mid-cycle, Stripe calculates the cost for the remaining days and adds it to the next invoice. Store the subscriptionItemId (found at subscription.items.data[0].id) in your database — you need it for quantity updates.
For usage-based billing, create a metered price instead of a recurring flat price. Report usage via the usage records API at the end of each billing period (or in real time):
// Report API calls consumed this period:
async function reportUsage(subscriptionItemId: string, callCount: number) {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity: callCount,
timestamp: Math.floor(Date.now() / 1000),
action: 'set', // 'set' overwrites, 'increment' adds to existing
});
}
Stripe aggregates usage records and bills at the end of the billing period. The customer.subscription.updated webhook fires when the metered billing cycle closes and the invoice is generated.
Handling Subscription-Level Discounts and Coupons
Discount codes and promotional pricing are high-impact for conversion. Stripe's coupon and promotion code system integrates with the checkout flow you built in Step 2.
Coupons define the discount terms — percentage off, fixed amount off, or free trial extension. Promotion codes are customer-facing redemption codes that map to a coupon. The distinction matters: one coupon can have many promotion codes, each with its own redemption limit and expiry.
// Create a coupon and a promotion code:
const coupon = await stripe.coupons.create({
percent_off: 20,
duration: 'repeating',
duration_in_months: 3, // 20% off for first 3 months
name: '20% Off for 3 Months',
});
const promoCode = await stripe.promotionCodes.create({
coupon: coupon.id,
code: 'LAUNCH20',
max_redemptions: 100, // Limit total uses
expires_at: Math.floor(new Date('2026-06-01').getTime() / 1000),
});
With allow_promotion_codes: true on your checkout session (already in Step 2), users enter promotion codes at checkout — no additional code needed on your end. The discount is automatically applied to the subscription and reflected in the webhook events you receive.
Track redemption rates in Stripe Dashboard → Billing → Coupons. High-performing discount codes are worth analyzing: which acquisition channels generated the most code redemptions, and what's the conversion-to-paid rate for users who entered a promotion code versus those who didn't.
Revenue Recovery and Dunning
Failed payments are the primary driver of involuntary churn in SaaS. Stripe's Smart Retries automatically reschedules failed payment attempts based on machine learning signals about when the retry is most likely to succeed — this alone recovers a meaningful share of failed charges without any additional engineering.
Your dunning flow sits on top of Stripe's automatic retries and fills the gaps: communication that gives customers a chance to fix payment issues before access is cut off.
A production-grade dunning sequence:
Day 0 (invoice.payment_failed): send an email immediately. Subject: "Your payment didn't go through." Include the failed amount, the card last 4 digits, and a direct link to /dashboard/billing where they can update their payment method. Keep the email transactional and non-judgmental — most failed payments are expired cards, not intentional non-payment.
Day 3 (if still unpaid): send a reminder. Stripe's Smart Retries may have already retried and failed again by this point. Mention that access continues for now but will be affected if payment isn't resolved. Include the payment method update link prominently.
Day 7 (if still unpaid, before subscription cancels): final notice. Be specific about the date access will end. Offer a grace period if you're comfortable with it — some SaaS products give 14 days of access after the first failed payment to give customers time to resolve billing issues. Track whether this increases recovery rates for your user base.
Post-cancellation (customer.subscription.deleted from non-payment): a re-engagement email 2–3 days later with a link to resubscribe. Former subscribers have already paid once and are higher-conversion leads than cold prospects.
The invoice.payment_failed webhook gives you the invoice.attempt_count field — track this to know how many times Stripe has already retried. By attempt 2 or 3, the probability of automatic recovery drops significantly, and a human-written personal email from your support team often converts better than another automated message. This is worth implementing once your MRR justifies the manual time.
Stripe's dunning configuration is at Dashboard → Billing → Subscriptions → Smart Retries — adjust the retry schedule and the final cancellation timing to match your business's risk tolerance.
Testing Your Subscription Integration
End-to-end testing of a subscription flow requires testing card numbers, webhook forwarding, and state verification — not just unit tests.
Stripe provides a full set of test card numbers for simulating specific scenarios. The most important ones: 4242424242424242 (always succeeds), 4000000000000002 (always declines), 4000002500003155 (requires 3D Secure), and 4000000000000341 (always succeeds for checkout but subsequent charges fail — ideal for testing dunning). Use these with any future expiry date and any 3-digit CVV.
For local webhook testing, run stripe listen --forward-to localhost:3000/api/webhooks/stripe alongside your dev server. The CLI prints the test webhook signing secret — set this in your .env.local as STRIPE_WEBHOOK_SECRET. Then trigger events with stripe trigger checkout.session.completed or use the Stripe Dashboard's "Send test webhook" button on your registered endpoint.
The full integration test checklist:
[ ] New subscription: checkout → webhook fires → user.plan updated to 'pro'
[ ] Trial: checkout with trial → user.subscriptionStatus === 'trialing'
[ ] Trial conversion: trial ends → webhook fires → status changes to 'active'
[ ] Renewal: invoice paid → subscription status stays 'active', currentPeriodEnd advances
[ ] Failed payment: use card 4000000000000341 → invoice.payment_failed fires → dunning email sent
[ ] Cancellation: cancel via portal → cancel_at_period_end true → access continues until period end
[ ] Access check: requirePro() redirects unauthenticated and non-pro users correctly
Test the failure path explicitly — most subscription bugs live in the edge cases, not the happy path.
Methodology
Stripe API version referenced: 2024-12-18.acacia. Code examples use the stripe npm package 16.x and Next.js 15 App Router. Auth helper auth() references NextAuth.js v5 / Auth.js; substitute your own session provider as appropriate. Seat-based billing quantity updates are subject to Stripe's proration rules — the exact proration amount depends on the billing cycle day and the plan's proration behavior setting. Usage-based billing examples use the subscriptionItems.createUsageRecord API, which is available on metered prices only (not per-seat or flat-rate prices). Trial period behavior (14-day trial) reflects Stripe's default trial ending flow — set trial_period_days to 0 on the checkout session to disable trials for specific promotions.
Explore all payment and billing APIs at APIScout.
Related: Building a SaaS Backend, Best Subscription Management APIs 2026, Build a Payment System: Stripe + Plaid 2026