Build a Stripe Subscription SaaS: Complete 2026 Guide
·APIScout Team
stripesubscriptionssaaspaymentsnext-js2026
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>
);
}
Explore all payment and billing APIs at APIScout.