This guide walks you through integrating Stripe billing — including subscription plans, webhooks, and a customer self-serve portal — into a Next.js 15 app. Following all steps end-to-end takes roughly 90 minutes. By the end, you will have a production-ready billing system that handles plan upgrades, cancellations, and failed payments automatically.
What You'll Build
- A Stripe Checkout flow that creates subscriptions from your Next.js frontend
- A webhook handler that keeps your database in sync with Stripe billing events
- A Stripe Customer Portal link so users can manage their own plan and payment method
- Route-level plan gating that blocks unpaid users from protected pages
- A working test environment using Stripe CLI and the Stripe test dashboard
Prerequisites
- Node.js 20+ and pnpm 9 installed
- A Next.js 15 project using the App Router (create one with
pnpm create next-app@latest) - A free Stripe account — create one at stripe.com
- Stripe CLI installed locally for webhook testing (
brew install stripe/stripe-cli/stripeon macOS) - A database with a
userstable — this guide uses Supabase but the pattern applies to any provider - Basic familiarity with React Server Components and Next.js Route Handlers
Step 1: Install the Stripe SDK and Set Up Environment Variables
Start by adding the official Stripe Node.js library to your project. As of 2026, the current stable version is stripe@16.
pnpm add stripe @stripe/stripe-js
Next, add these keys to your .env.local file. Never commit this file to version control.
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
Find your API keys in the Stripe Dashboard under Developers → API keys. You will fill in STRIPE_WEBHOOK_SECRET in Step 4.
Why initialise Stripe as a singleton?
Instantiating the Stripe client inside every request creates unnecessary overhead. Create a shared instance instead.
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-28',
typescript: true,
});
Expected result: Importing stripe from lib/stripe.ts throughout your app reuses a single connection. This also keeps your API version pinned so Stripe changelog updates don't silently break behaviour.
Step 2: Create Products and Prices in Stripe
Go to the Stripe Dashboard → Product catalogue → Add product. Create at least two plans — for example, Starter at $29/month and Pro at $79/month. Set the billing period to Monthly and the pricing model to Flat rate.
Copy the Price IDs (they look like price_1Abc...) into your app config.
// lib/plans.ts
export const PLANS = [
{
name: 'Starter',
priceId: 'price_starter_monthly',
monthlyPrice: 29,
},
{
name: 'Pro',
priceId: 'price_pro_monthly',
monthlyPrice: 79,
},
];
Common pitfall: Do not hardcode price amounts in your UI. Always derive them from Stripe's API or your config file. Prices change and your UI will drift out of sync.
Step 3: Build the Checkout Session Route Handler
Create a Route Handler that receives a Price ID and redirects the user to Stripe Checkout.
// app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getCurrentUser } from '@/lib/auth'; // your auth helper
export async function POST(req: NextRequest) {
const { priceId } = await req.json();
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
customer_email: user.email,
metadata: { userId: user.id },
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return NextResponse.json({ url: session.url });
}
On your pricing page, call this endpoint on button click and redirect to session.url.
async function handleSubscribe(priceId: string) {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
});
const { url } = await res.json();
window.location.href = url;
}
Pro tip: Pass allow_promotion_codes: true to the session to let Stripe handle your discount codes automatically — no custom coupon UI required.
Step 4: Set Up Webhooks to Sync Billing State
Stripe sends events to your server whenever billing state changes. This is the most critical part of any billing integration. Skipping webhooks is the number-one reason billing state goes stale.
How do you test webhooks locally?
Use the Stripe CLI to forward events to your local server.
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
The CLI prints a webhook signing secret starting with whsec_. Paste that into STRIPE_WEBHOOK_SECRET in your .env.local.
What events should you handle?
Handle these four events as a minimum for a subscription product.
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { updateUserSubscription } from '@/lib/db'; // your DB helper
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
const subscription = event.data.object as any;
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await updateUserSubscription({
stripeCustomerId: subscription.customer,
status: subscription.status,
priceId: subscription.items.data[0].price.id,
currentPeriodEnd: subscription.current_period_end,
});
break;
case 'customer.subscription.deleted':
await updateUserSubscription({
stripeCustomerId: subscription.customer,
status: 'cancelled',
priceId: null,
currentPeriodEnd: null,
});
break;
case 'invoice.payment_failed':
// Notify the user via email here
break;
}
return NextResponse.json({ received: true });
}
Critical pitfall: Always use req.text() — not req.json() — before passing the body to constructEvent. Parsing JSON first corrupts the raw body and signature verification will fail every time.
Step 5: Add the Customer Portal Route
Stripe's Customer Portal handles plan changes, payment method updates, and cancellations with zero custom UI. Enabling it takes two minutes.
In the Stripe Dashboard go to Settings → Billing → Customer portal and enable it. Then add this Route Handler.
// app/api/stripe/portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getCurrentUser } from '@/lib/auth';
import { getUserStripeCustomerId } from '@/lib/db';
export async function POST(req: NextRequest) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });
const customerId = await getUserStripeCustomerId(user.id);
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return NextResponse.json({ url: portalSession.url });
}
Add a Manage Billing button in your account settings that calls this endpoint and redirects to portalSession.url. Users can now self-serve without contacting your support team — a major time saver for SMB SaaS products across Australia, Singapore, Canada, and the US.
Step 6: Gate Routes by Subscription Status
Protecting premium pages is where the database sync from Step 4 pays off. Read the user's subscription status from your database and block access if they are not on an active plan.




