Stripe Subscriptions in Next.js 16: Complete SaaS Billing Guide (2026)
Most developers can wire up a one-time payment in an afternoon. Subscriptions? That's where things get confusing fast.
What happens when a user's card expires? What if they cancel mid-cycle? How do you know their subscription is still active before letting them into your dashboard? These aren't edge cases — they're everyday realities of any SaaS product.
The good news is Stripe handles almost all of this complexity for you. Your job is knowing how to plug it in correctly. In this guide, you'll build a complete monthly subscription billing flow in Next.js 16 using Stripe Checkout Sessions — the fastest and most reliable way to add subscriptions to your SaaS.
By the end, you'll have:
- A pricing page that redirects users to Stripe Checkout
- Webhook handling that activates subscriptions in your database
- A customer portal where users can manage or cancel their plan
- A solid understanding of how subscription lifecycle events work
Why Subscriptions Are Different From One-Time Payments
A one-time payment is simple: user pays → you deliver → done.
A subscription is an ongoing relationship. Stripe needs to charge the user every month, handle failed payments, retry logic, and tell your app what's happening at every step. Your backend needs to listen to those updates via webhooks and keep your database in sync.
If you skip webhooks and just trust the frontend redirect, you're asking for trouble. The user's payment could fail silently, or they could cancel their plan and still access your app for months.
What We're Building
A minimal SaaS billing setup with two plans:
| Plan | Price |
|---|---|
| Starter | $29/month |
| Pro | $79/month |
The flow works like this:
User clicks "Subscribe"
↓
Server Action creates Checkout Session
↓
User redirected to Stripe hosted checkout
↓
Payment completed → user redirected back
↓
Stripe fires webhook → your app activates subscription
↓
User can manage billing via Customer Portal
Prerequisites
Make sure you have:
- Node.js 20+ installed
- A Stripe account (free to create)
- Basic familiarity with Next.js App Router
We'll be using:
- Next.js 16 with App Router
- TypeScript strict mode
- Tailwind CSS for any UI
- React Compiler (no manual
useMemo/useCallbackneeded)
Step 1: Create a Next.js Project
npx create-next-app@latest stripe-subscriptions
Choose:
- TypeScript ✅
- Tailwind CSS ✅
- App Router ✅
- React Compiler ✅
src/directory ✅
Move into the project:
cd stripe-subscriptions
Step 2: Install Stripe
npm install stripe
This is the official Stripe Node.js SDK. We'll use it on the server only — it should never touch the browser.
Step 3: Set Up Stripe Server Client
Create a reusable Stripe instance so you don't initialize it in every file.
// src/lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-05-27.dahlia",
});
⚠️ Common Mistake: Never import this file into a Client Component. The
STRIPE_SECRET_KEYmust stay server-side only.
Step 4: Create Products and Prices in Stripe
Before writing code, set up your products in the Stripe dashboard.
- Go to dashboard.stripe.com
- Enable Test Mode (toggle in the top right)
- Copy Publishable & Secret keys
- Navigate to Product Catalog → Create Product
- Create two products:
Starter Plan
- Name:
Starter - Price:
$29.00 USD - Billing:
Recurring → Monthly
Pro Plan
- Name:
Pro - Price:
$79.00 USD - Billing:
Recurring → Monthly
After saving, click into each product and copy the Price ID (starts with price_). You'll need these next.
Step 5: Configure Environment Variables
Create .env.local in your project root:
# .env.local
STRIPE_SECRET_KEY=sk_test_xxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxx
NEXT_PUBLIC_APP_URL=http://localhost:3000
STRIPE_WEBHOOK_SECRET=whsec_xxxx
# Price IDs
STRIPE_STARTER_PRICE_ID=price_xxxx
STRIPE_PRO_PRICE_ID=price_xxxx
⚠️ Important: Add
.env.localto.gitignoreimmediately. Never commit secret keys to version control.
Variables starting with NEXT_PUBLIC_ are safe to expose to the browser. Everything else is server-only.
Step 6: Define Plan Constants
Keep your plan data in one place so you never hardcode price IDs across multiple files.
// src/lib/plans.ts
export const PLANS = {
starter: {
id: "starter",
name: "Starter",
price: 29,
priceId: process.env.STRIPE_STARTER_PRICE_ID!,
features: [
"Up to 5 projects",
"10GB storage",
"Email support",
],
},
pro: {
id: "pro",
name: "Pro",
price: 79,
priceId: process.env.STRIPE_PRO_PRICE_ID!,
features: [
"Unlimited projects",
"100GB storage",
"Priority support",
"Advanced analytics",
],
},
} as const;
export type PlanId = keyof typeof PLANS;
💡 Tip: Using
as constgives you full TypeScript autocompletion on plan IDs throughout your app — no magic strings.
Step 7: Create the Checkout Server Action
A Server Action is an async function that runs on the server. We'll use one to create the Stripe Checkout Session — this keeps your secret key safe and your code clean.
// src/actions/create-checkout.ts
"use server";
import { redirect } from "next/navigation";
import { stripe } from "@/lib/stripe";
import { PLANS, PlanId } from "@/lib/plans";
export async function createCheckout(
prev: unknown,
formData: FormData
) {
let url;
try {
const planId = formData.get("planId") as PlanId;
if (!planId || !(planId in PLANS)) {
return { error: "Invalid plan selected" };
}
const plan = PLANS[planId];
const session = await stripe.checkout.sessions.create({
mode: "subscription", // ← this is the key difference from one-time payments
line_items: [
{
price: plan.priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: {
planId,
},
// Collect billing address for invoices
billing_address_collection: "auto",
// Allows users to apply promo codes at checkout
allow_promotion_codes: true,
});
if (!session.url) {
return { error: "Failed to create checkout session" };
}
url = session.url;
} catch (err) {
return {
error: err instanceof Error ? err.message : "Something went wrong",
};
}
if (url) {
redirect(url);
}
}
What's happening here:
mode: "subscription"tells Stripe this is a recurring payment, not a one-time chargeline_itemsspecifies which price the user is subscribing tosuccess_url/cancel_urlcontrol where Stripe sends the user after checkoutmetadata.planIdlets you identify which plan was purchased inside your webhook later- In
subscriptionmode, Stripe automatically creates a Customer object — no extra config needed
Step 8: Build the Pricing Page
// src/app/pricing/page.tsx
"use client";
import { useActionState } from "react";
import { createCheckout } from "@/actions/create-checkout";
import { PLANS } from "@/lib/plans";
export default function PricingPage() {
const [state, action, isPending] = useActionState(createCheckout, {
error: "",
});
return (
<main className="min-h-screen py-20 px-4">
<div className="max-w-4xl mx-auto text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight">
Simple, transparent pricing
</h1>
<p className="mt-4 text-muted-foreground text-lg">
Start free. Upgrade when you're ready.
</p>
</div>
<div className="grid md:grid-cols-2 gap-6 max-w-3xl mx-auto">
{Object.values(PLANS).map((plan) => (
<div
key={plan.id}
className="rounded-2xl border p-8 flex flex-col gap-6"
>
<div>
<h2 className="text-xl font-semibold">{plan.name}</h2>
<div className="mt-2">
<span className="text-4xl font-bold">${plan.price}</span>
<span className="text-muted-foreground"> / month</span>
</div>
</div>
<ul className="space-y-2 flex-1">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<span className="text-green-500">✓</span>
{feature}
</li>
))}
</ul>
<form action={action}>
<input type="hidden" name="planId" value={plan.id} />
<button
type="submit"
disabled={isPending}
className="w-full rounded-xl bg-primary text-primary-foreground py-3 font-medium disabled:opacity-50 transition-opacity"
>
{`Subscribe to ${plan.name}`}
</button>
</form>
</div>
))}
</div>
{state?.error && (
<p className="text-red-500 text-center mt-6">{state.error}</p>
)}
</main>
);
}
The hidden planId input is how the Server Action knows which plan the user clicked. The name attribute on form inputs is what formData.get("planId") reads.
Step 9: Handle Subscription Events with Webhooks
This is the most important part. When a user pays (or cancels, or their card fails), Stripe sends a POST request to your webhook endpoint. Your app listens, updates the database, and keeps subscription state accurate.
⚠️ Never trust the frontend redirect alone. A user could close the tab after payment, the redirect could fail, or someone could fake the success URL. Webhooks are your source of truth.
Create the Webhook Route
// src/app/api/webhook/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import Stripe from "stripe";
import { stripe } from "@/lib/stripe";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
try {
const payload = await req.text();
const sig = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
// Verify the webhook actually came from Stripe
try {
event = stripe.webhooks.constructEvent(payload, sig, endpointSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ error: `Webhook signature failed: ${message}` },
{ status: 400 }
);
}
switch (event.type) {
// Fired when a new subscription is created and first payment succeeds
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== "subscription") break;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
const planId = session.metadata?.planId;
console.log("New subscription:", {
customerId,
subscriptionId,
planId,
});
// TODO: Save to your database
// await db.user.update({ stripeCustomerId: customerId, subscriptionId, plan: planId, status: "active" })
break;
}
// Fired on every successful recurring charge
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = invoice.subscription as string;
console.log("Invoice paid — subscription renewed:", subscriptionId);
// TODO: Mark subscription as active / extend access period
// await db.subscription.update({ id: subscriptionId, status: "active" })
break;
}
// Fired when a payment fails (expired card, insufficient funds, etc.)
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = invoice.subscription as string;
console.log("Payment failed for subscription:", subscriptionId);
// TODO: Notify user and mark subscription as past_due
// await db.subscription.update({ id: subscriptionId, status: "past_due" })
// await sendPaymentFailedEmail(invoice.customer_email)
break;
}
// Fired when user cancels or subscription expires
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
console.log("Subscription cancelled:", subscription.id);
// TODO: Revoke access
// await db.subscription.update({ id: subscription.id, status: "cancelled" })
break;
}
// Fired when plan changes (upgrade/downgrade)
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
console.log("Subscription updated:", subscription.id, subscription.status);
// TODO: Sync the new plan and status to your database
break;
}
default:
// Ignore unhandled event types
break;
}
// Always return 200 so Stripe knows the event was received
return NextResponse.json({ received: true }, { status: 200 });
} catch (err) {
const error = err instanceof Error ? err.message : "Unexpected error";
console.error("Webhook error:", error);
return NextResponse.json({ error }, { status: 500 });
}
}
Why req.text() instead of req.json()?
Stripe's signature verification requires the raw request body as a string. If you parse it with .json() first, the signature check will fail. Always use .text() in webhook handlers.
Why return 200 even for events you don't handle?
Stripe retries events that don't get a 2xx response. If you return a 4xx for an unknown event type, Stripe will keep retrying it forever. Return 200 for everything that isn't a genuine error.
Step 10: Test Webhooks Locally with Stripe CLI
Stripe can't reach localhost, so you need the Stripe CLI to forward events to your dev server.
Install the Stripe CLI: docs.stripe.com/stripe-cli
Then run:
stripe listen --forward-to localhost:3000/api/webhook
This prints a webhook signing secret — copy it into your .env.local as STRIPE_WEBHOOK_SECRET.
In a separate terminal, start your dev server:
npm run dev
Now when you complete a test checkout, you'll see the events streaming in your CLI terminal.
Step 11: Add a Customer Portal
Once users are subscribed, they'll need a way to update their card, change plans, or cancel. Stripe's Customer Portal handles all of this for you.
Enable the Portal in Stripe Dashboard
- Go to Settings → Billing → Customer Portal
- Enable it and configure:
- Allow plan changes ✅
- Allow cancellations ✅
- Allowed plans: select your Starter and Pro prices
- Save settings
Create the Portal Server Action
// src/actions/create-portal.ts
"use server";
import { redirect } from "next/navigation";
import { stripe } from "@/lib/stripe";
export async function createPortalSession(customerId: string) {
if (!customerId) {
throw new Error("Customer ID is required");
}
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/`,
});
redirect(session.url);
}
Add a "Manage Billing" Button to Your Home
// src/app/page.tsx
import { createPortalSession } from "@/actions/create-portal";
// In a real app, fetch the stripeCustomerId from your database
const DEMO_CUSTOMER_ID = "cus_xxxx"; // replace with real DB lookup
export default function Page() {
const action = createPortalSession.bind(null, DEMO_CUSTOMER_ID)
return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<form
action={action}
>
<button
type="submit"
className="w-fit rounded-xl border p-2 text-sm font-medium hover:bg-muted transition-colors"
>
Manage Billing
</button>
</form>
</main>
);
}
When the user clicks "Manage Billing", they're taken to Stripe's hosted portal. They can update payment methods, switch plans, or cancel — and Stripe fires webhooks for every action so your database stays in sync automatically.
Step 12: Deploy and Configure Production Webhooks
Before going live, update your environment variables in Vercel (or your hosting provider):
- Add all your
.env.localvariables to your Vercel project settings - Switch to live Stripe keys (
sk_live_,pk_live_) - Create a new webhook in the Stripe Dashboard:
- Endpoint URL:
https://yourdomain.com/api/webhook - Events to listen for:
checkout.session.completedinvoice.paidinvoice.payment_failedcustomer.subscription.deletedcustomer.subscription.updated
- Endpoint URL:
- Copy the signing secret and add it as
STRIPE_WEBHOOK_SECRETin Vercel
Preventing Duplicate Processing
Stripe may send the same webhook event more than once if your server is slow or returns an error. Before processing any event, check if you've already handled it:
// Inside your webhook handler, before processing:
const eventId = event.id;
// Check if already processed
const existing = await db.webhookEvent.findUnique({ where: { stripeEventId: eventId } });
if (existing) {
return NextResponse.json({ received: true }, { status: 200 });
}
// Store the event ID first, then process
await db.webhookEvent.create({ data: { stripeEventId: eventId } });
// ... rest of processing
This is called idempotency — handling the same event twice has the same result as handling it once.
Frequently Asked Questions
How do I know if a user's subscription is active?
Store the subscription status in your database when the webhook fires. On every protected page load, query your database for the user's subscription status. Don't call the Stripe API on every request — that's slow and wastes API calls. Keep your DB as the source of truth, synced by webhooks.
What's the difference between checkout.session.completed and invoice.paid?
checkout.session.completed fires once when a user completes the initial checkout. invoice.paid fires every time a subscription renews (monthly/yearly). You need to listen to both — one to activate the account, one to keep it active.
Can I offer a free trial?
Yes. Add subscription_data: { trial_period_days: 14 } to your stripe.checkout.sessions.create() call. Stripe won't charge the user until the trial ends, and it'll fire the appropriate webhook events when it does.
💡 Tip: Set
payment_method_collection: "always"alongside a trial so Stripe collects card details upfront even though no charge happens immediately.
How do I handle plan upgrades and downgrades?
The Customer Portal handles this automatically. When a user switches plans through the portal, Stripe fires a customer.subscription.updated event with the new price ID. Listen for that event in your webhook and update the user's plan in your database.
Can I use this with an auth system like Clerk or NextAuth?
Absolutely. After authentication, store the stripeCustomerId alongside the user record in your database. When creating a checkout session for a logged-in user, pass customer: existingStripeCustomerId instead of customer_creation: "always" — this links the subscription to their existing Stripe customer.
Conclusion
You now have a production-ready subscription billing flow in Next.js 16 — pricing page, Stripe Checkout, webhook handling for the full subscription lifecycle, and a customer portal for self-service billing management.
The pattern here scales cleanly: add more plans to PLANS, add more webhook event handlers as your app grows, and you'll never have to touch the core flow.
From here, the natural next steps are connecting a real database to persist subscription status, and gating your app's features based on the user's active plan.
📦 Source Code: View on GitHub
Useful Resources
- Stripe Subscriptions Docs
- Stripe Checkout Docs
- Stripe Customer Portal Docs
- Stripe Webhook Events Reference
- Next.js Server Actions Docs