Subscription billing in a Next.js SaaS is three moving parts, and the order matters: (1) start a subscription with a Checkout Session, (2) let Stripe tell your database the truth via a webhook, and (3) hand cancels/upgrades to Stripe's Billing Portal so you never build a billing UI. Get these three right and you can ignore almost everything else. This is the practical wiring for the App Router β the deep webhook-security details are in a companion post.
The only real difference from a one-time payment is mode: 'subscription' and a recurring price. A Route Handler creates the session server-side and returns its URL:
// app/api/checkout/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const { userId, orgId } = await getSession() // your auth
const customerId = await ensureStripeCustomer(orgId) // create once, store on the org
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
success_url: `${process.env.APP_URL}/billing?ok=1`,
cancel_url: `${process.env.APP_URL}/billing`,
// stamp your own ids so the webhook can map back to a tenant:
subscription_data: { metadata: { orgId } },
})
return Response.json({ url: session.url })
}
Client redirects to session.url. Create the Stripe customer once and store stripe_customer_id on your org/user β you'll need it for the portal and to reconcile webhooks.
success_url redirect as "they paid." A user can hit it without completing payment, and network hiccups mean it may never fire. The webhook is the source of truth for subscription state β never grant access on the redirect alone.Stripe posts events; your job is to translate them into your DB. In an App Router route handler you must read the raw body β signature verification fails against parsed JSON:
// app/api/stripe/webhook/route.ts
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, not req.json()
const sig = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const sub = await stripe.subscriptions.retrieve(
(event.data.object as any).subscription ?? (event.data.object as any).id
)
await db.org.update({
where: { stripeCustomerId: sub.customer as string },
data: { plan: sub.status === 'active' ? 'pro' : 'free', subStatus: sub.status },
})
break
}
}
return Response.json({ received: true })
}
Handle the three subscription lifecycle events (completed, updated, deleted) and write the status to the row keyed by stripe_customer_id. Make it idempotent β Stripe can deliver the same event more than once β and always return 2xx quickly or Stripe retries. Full signature/replay hardening: Verify a Stripe webhook in Next.js.
Do not build cancel/upgrade/payment-method UI. Stripe hosts it. One route redirects the user in:
// app/api/portal/route.ts
export async function POST() {
const orgId = await getOrgId()
const { stripeCustomerId } = await db.org.findUniqueOrThrow({ where: { id: orgId } })
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.APP_URL}/billing`,
})
return Response.json({ url: session.url })
}
Cancels, plan switches, card updates, invoice history β all handled by Stripe, and all flow back to you as the same webhook events from step 2. That's the payoff of making the webhook the source of truth: the portal "just works" without extra code.
await req.text() and pass the raw string to constructEvent.stripe_customer_id/subscription id and make re-processing a no-op.mode: 'subscription' + a recurring price; store stripe_customer_id.completed/updated/deleted to your DB, idempotently.