
A practical, copy-paste guide to adding Stripe subscription billing to a Next.js 15 App Router app - checkout, webhooks, and gating access the right way.
Adding subscription billing is the moment a side project starts feeling like a real SaaS - and Stripe makes it surprisingly approachable. But most tutorials stop at "redirect to Checkout" and skip the parts that actually matter in production: handling webhooks reliably, letting customers manage their own plans, dealing with upgrades and cancellations, and recovering failed payments.
In this guide I'll walk you through a complete Stripe subscription setup in a Next.js 15 App Router app - not just taking the first payment, but running billing like a real product. This is the same flow I use in production SaaS apps.
What you'll build
A full subscription system: a user subscribes through Stripe Checkout, your app reliably knows they're paying, they can upgrade, downgrade, or cancel through a self-service portal, and your app handles failed payments gracefully - all kept in sync with your database.
Prerequisites
A Next.js 15 project using the App Router
A free Stripe account
A database (this guide assumes Prisma + PostgreSQL, but any works)
Step 1: Create your product and price in Stripe
In the Stripe Dashboard, go to Product catalog and create a product (e.g. "Pro Plan"). Add a recurring price - say $19/month. Copy the Price ID (it starts with price_...). If you offer multiple tiers (Basic, Pro, Enterprise), create a price for each - you'll need them for upgrades later.
Step 2: Install Stripe and set environment variables
Install the Stripe SDK:
npm install stripeThen add your keys to .env.local:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000Never expose the secret key on the client. Only the secret key touches your server code.
Step 3: Create a Checkout Session (server route)
Create a route handler at app/api/checkout/route.ts. This creates a Stripe Checkout Session and returns its URL.
import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { userId, priceId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: ${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true,
cancel_url: ${process.env.NEXT_PUBLIC_APP_URL}/pricing,
// Attach your own user id so the webhook can match the payment to a user
metadata: { userId },
});
return NextResponse.json({ url: session.url });
}The metadata field is the key detail most tutorials miss - it's how you link a Stripe payment back to a user in your own database.
Step 4: The subscribe button (client)
On your pricing page, call that route and redirect the user to Stripe:
"use client";
export function SubscribeButton({ userId, priceId }: { userId: string; priceId: string }) {
async function handleClick() {
const res = await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ userId, priceId }),
});
const { url } = await res.json();
window.location.href = url;
}
return <button onClick={handleClick}>Subscribe to Pro</button>;
}Step 5: Handle webhooks (the part that actually matters)
A user might pay and immediately close the tab - so you cannot rely on the success_url to update your database. The reliable source of truth is Stripe's webhook. Create app/api/webhooks/stripe/route.ts:
import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body is required for verification
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 NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
// Save the Stripe customer + subscription id and mark the user active
// await db.user.update({ where: { id: userId }, data: {
// stripeCustomerId: session.customer, isPro: true } });
break;
}
case "customer.subscription.updated": {
// Plan changed, renewed, or set to cancel — sync the new status/plan
break;
}
case "customer.subscription.deleted": {
// Subscription ended — revoke access in your database
break;
}
case "invoice.payment_failed": {
// A renewal payment failed — flag the account, trigger a reminder email
break;
}
}
return NextResponse.json({ received: true });
}Two things developers get wrong here:
(1) you must read the raw request body with req.text() - parsing it as JSON breaks signature verification, and
(2) you must verify the signature, otherwise anyone could fake a "user paid" event. Also notice we now store the Stripe customer id - you'll need it for the customer portal next.
Step 6: Gate premium features
Now that your database knows who's paying, gating access is trivial. On a protected page or route, check the flag:
if (!user.isPro) {
redirect("/pricing");
}That's the core loop: checkout → webhook updates your DB → your app checks the DB.
Step 7: Let customers manage their own plan (Customer Portal)
You do not want to build cancellation, plan changes, and card updates yourself — Stripe gives you a hosted Customer Portal for free. Create a route that opens it for the logged-in user:
// app/api/portal/route.ts
export async function POST(req: Request) { const { stripeCustomerId } = await req.json(); const session = await stripe.billingPortal.sessions.create({ customer: stripeCustomerId, return_url: ${process.env.NEXT_PUBLIC_APP_URL}/dashboard, }); return NextResponse.json({ url: session.url });}Add a "Manage subscription" button that hits this route and redirects the user. Inside the portal they can update their card, download invoices, change plans, and cancel - and every change fires a webhook you're already listening to. This one step removes a huge amount of code you'd otherwise have to build and maintain.
Step 8: Upgrades, downgrades, and proration
When a user moves from Basic to Pro (or back), you usually don't want to charge them a fresh full price - you want to charge the difference. Stripe handles this with proration automatically when you update the subscription's item to a new price:
await stripe.subscriptions.update(subscriptionId, { items: [{ id: subscriptionItemId, price: newPriceId }], proration_behavior: "create_prorations",});Stripe calculates the credit for unused time on the old plan and applies it to the new one. If you'd rather not prorate (e.g. downgrades take effect next cycle), set proration_behavior to "none". Most of this can also be handled entirely through the Customer Portal without writing this code yourself - but it's good to know what's happening underneath.
Step 9: Handling cancellations the right way
There are two ways to cancel, and the difference matters for your users:
// Cancel at the end of the paid period (recommended - they keep access until it expires)
await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true });
// Cancel immediately (access ends now, usually with a prorated refund)
await stripe.subscriptions.cancel(subscriptionId);
Cancelling at period end is almost always the right default - the customer already paid for the month, so let them use it. When the period actually ends, Stripe fires customer.subscription.deleted, and that's where you revoke access. Never revoke access the moment they click cancel.
Step 10: Recover failed payments (don't lose paying customers)
Cards expire and payments fail - it's normal, and it quietly costs SaaS businesses real revenue. When a renewal fails, Stripe fires invoice.payment_failed. Use it to flag the account and prompt the user to update their card (Stripe's built-in "Smart Retries" and dunning emails handle most of the chasing for you if you enable them). Only downgrade or lock the account after Stripe has exhausted its retries and the subscription actually moves to canceled or unpaid - cutting people off on the first failure loses customers who would have paid.
Testing everything locally with the Stripe CLI
You can't wait for real payments to test all this. Use the Stripe CLI to forward events to your local app:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
It prints a webhook signing secret - paste that into STRIPE_WEBHOOK_SECRET. Then trigger events on demand to test each handler:
stripe trigger checkout.session.completedstripe trigger invoice.payment_failedstripe trigger customer.subscription.deletedThis lets you verify your entire billing flow - success, failure, cancellation - before a single real customer touches it.
Common mistakes to avoid
Relying on success_url instead of webhooks to grant access. Always use webhooks as the source of truth.
Forgetting metadata, so you can't tell which user paid.
Parsing the webhook body as JSON, which breaks signature verification.
Building your own cancellation and card-update UI instead of using the Customer Portal.
Revoking access the instant someone clicks cancel, or on the first failed payment.
Shipping test keys to production. Swap to live keys and a live webhook secret on deploy.
Frequently asked questions
Do I need a database to use Stripe subscriptions?
Yes, practically speaking. Stripe tracks the payment, but your app needs to store which of your users is subscribed so you can gate features. The webhook keeps the two in sync.
Why use webhooks instead of the success redirect?
Because a user can pay and never reach the redirect (closed tab, lost connection). Webhooks are server-to-server and reliable, so they're the correct source of truth.
How do I let users cancel or change their subscription?
Use Stripe's hosted Customer Portal. With one API call you give users a secure page to update their card, change plans, and cancel - and every action fires a webhook your app already handles.
How does Stripe handle upgrades and proration?
When you move a subscription to a new price, Stripe automatically prorates - crediting unused time on the old plan against the new one. You can also disable proration if you want changes to apply next cycle.
What happens when a subscription payment fails?
Stripe fires an invoice.payment_failed event and can automatically retry with dunning emails. You should only restrict the account after Stripe's retries are exhausted, not on the first failure.
How do I test Stripe subscriptions without real payments?
Use the Stripe CLI: stripe listen forwards webhooks to your local app, and stripe trigger fires test events so you can verify every part of the flow before going live.
Adding billing is one of those features that looks small but quietly decides whether your SaaS feels trustworthy - and whether you actually keep the revenue you earn. If you'd rather have subscription billing set up correctly the first time - webhooks, portal, proration, failed-payment recovery and all - I help founders build production-ready SaaS on Next.js. Tell me what you're building (https://osamahabib.com/contact) and I'll help you ship it.
Osama Habib
Multan, Pakistan
Full Stack Developer specialising in Next.js, Node.js, and the MERN stack. I write about modern web development, system design, and practical engineering.

