Stripe Subscriptions in Next.js: The Complete Setup

Updated July 2026 Β· App Router Β· ~7 min read

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.

1. Start the subscription β€” a Checkout Session

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.

⚠️ Don't trust the 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.

2. Sync state β€” the webhook (the part that's actually load-bearing)

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.

ε»£ε‘Š Ad

3. Cancels & upgrades β€” the Billing Portal (don't build this)

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.

The three gotchas that break this in production

  1. Parsed body in the webhook β†’ signature verification fails. Use await req.text() and pass the raw string to constructEvent.
  2. Granting access on the redirect instead of the webhook β†’ users get access without paying, or lose it on a flaky redirect. Webhook is truth.
  3. No idempotency β†’ duplicate events double-apply. Key writes by stripe_customer_id/subscription id and make re-processing a no-op.
πŸ›  Skip the wiring β€” SaaS Starter β†’
All three parts done and hardened: subscription Checkout, a signature-verified webhook that syncs per-org plan status, and the Billing Portal β€” on Next.js 16 + Prisma + Auth.js v5. Free Lite on GitHub to read the code; full version $99.

Recap

  1. Checkout Session with mode: 'subscription' + a recurring price; store stripe_customer_id.
  2. Webhook on raw body β†’ verify signature β†’ sync completed/updated/deleted to your DB, idempotently.
  3. Billing Portal for all self-serve changes; they come back as the same webhook events.