Integrating Stripe with Next.js 15/16 is cleaner than ever: Server Actions handle checkout creation without a separate API route, Route Handlers process webhooks with signature verification, and the App Router makes it straightforward to protect pages behind a subscription check. This guide covers the full subscription lifecycle.
>Setup
bash
npm install stripe @stripe/stripe-js
npm install --save-dev stripe-cli # optional local testingTypeScript
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-03-31.basil',
typescript: true,
});
// Client-side only
export const getStripe = () =>
import('@stripe/stripe-js').then(({ loadStripe }) =>
loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!),
);>Create a Checkout Session — Server Action
TypeScript
// app/actions/checkout.ts
'use server';
import { redirect } from 'next/navigation';
import { stripe } from '@/lib/stripe';
import { getServerSession } from '@/lib/auth';
export async function createCheckoutSession(priceId: string) {
const session = await getServerSession();
if (!session) throw new Error('Unauthenticated');
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: session.user.email,
client_reference_id: session.user.id,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=1`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
subscription_data: {
metadata: { userId: session.user.id },
},
});
redirect(checkoutSession.url!);
}
// Client component — call as a form action
'use client';
import { createCheckoutSession } from '@/app/actions/checkout';
export function UpgradeButton({ priceId }: { priceId: string }) {
return (
<form action={createCheckoutSession.bind(null, priceId)}>
<button type="submit">Upgrade to Pro</button>
</form>
);
}>Webhook Route Handler
TypeScript
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
export async function POST(req: NextRequest) {
const body = await req.text();
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;
await db.subscription.create({
data: {
userId: session.client_reference_id!,
stripeCustomerId: session.customer as string,
stripeSubId: session.subscription as string,
status: 'active',
plan: 'pro',
},
});
break;
}
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
await db.subscription.update({
where: { stripeSubId: sub.id },
data: { status: sub.status, currentPeriodEnd: new Date(sub.current_period_end * 1000) },
});
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await db.subscription.update({
where: { stripeSubId: sub.id },
data: { status: 'canceled' },
});
break;
}
}
return NextResponse.json({ received: true });
}NOTEDisable body parsing for the webhook route — Next.js sends the raw body by default in Route Handlers (no extra config needed). Always verify the signature before processing.
>Customer Portal — manage subscription
TypeScript
// app/actions/portal.ts
'use server';
import { redirect } from 'next/navigation';
import { stripe } from '@/lib/stripe';
import { getServerSession } from '@/lib/auth';
import { db } from '@/lib/db';
export async function openCustomerPortal() {
const session = await getServerSession();
const sub = await db.subscription.findUnique({
where: { userId: session!.user.id },
});
const portalSession = await stripe.billingPortal.sessions.create({
customer: sub!.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
});
redirect(portalSession.url);
}>Protect pages with subscription check
TypeScript
// middleware.ts → proxy.ts (Next.js 16)
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from '@/lib/auth';
import { db } from '@/lib/db';
export default async function proxy(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.next();
}
const session = await getServerSession();
if (!session) return NextResponse.redirect(new URL('/login', req.url));
const sub = await db.subscription.findFirst({
where: { userId: session.user.id, status: 'active' },
});
if (!sub) return NextResponse.redirect(new URL('/pricing', req.url));
return NextResponse.next();
}
export const config = { matcher: ['/dashboard/:path*'] };