HomeBlogStripe Subscriptions in the Next.js App Router: A Production Checklist

· 8 min read

Stripe Subscriptions in the Next.js App Router: A Production Checklist

The parts of Stripe subscription billing that tutorials skip — webhooks as the source of truth, idempotency, the customer portal, and the failure modes that bite you in production.

Wiring up Stripe Checkout in a Next.js app takes an afternoon. Making subscription billing correct — so your database and Stripe never disagree about who's paying — is the part that actually matters, and it's the part most tutorials skip. This is the checklist I use when shipping billing for a production SaaS on the App Router.

The one rule that prevents most bugs

Webhooks are the source of truth, not the redirect. It's tempting to mark a user as subscribed on the Checkout success page. Don't. The user can close the tab before redirecting, payment can settle asynchronously, and subscriptions renew, fail, and cancel long after any page load. Treat the success page as cosmetic and let webhook events drive every change to your database.

The moving parts

  1. A Checkout Session created from a server action or route handler to start the subscription.
  2. A webhook endpoint that verifies signatures and updates your database on subscription events.
  3. A customer portal session so users manage or cancel their own plans — you should never rebuild billing management yourself.
  4. A gate in your app that reads subscription status from your own database, not from Stripe on every request.

Creating the Checkout Session

In the App Router, create the session in a route handler and always attach a stable reference to your own user so the webhook can map the event back to a row:

// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const { userId, priceId } = await req.json();

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    // Map the event back to your user later:
    client_reference_id: userId,
    metadata: { userId },
    success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing/success`,
    cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing`,
  });

  return NextResponse.json({ url: session.url });
}

The webhook: where correctness lives

Two things people get wrong here. First, you must verify the signature against the raw request body — in the App Router, read await req.text(), never the parsed JSON. Second, handle the small set of events that actually change entitlement:

// app/api/webhooks/stripe/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 — required for signature check
  const sig = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response("Bad signature", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed":
    case "customer.subscription.updated":
    case "customer.subscription.deleted":
      // Upsert subscription status into YOUR database here.
      break;
  }

  return new Response("ok", { status: 200 });
}

Make webhook handling idempotent

Stripe will deliver the same event more than once — that's by design, not a bug. If your handler isn't idempotent, retries can double-apply changes. Store the Stripe event.id you've already processed, or write updates as upserts keyed by the subscription ID so re-processing is harmless.

Failure modes to plan for

  • Failed renewals. A card declines on month three. Listen for invoice.payment_failed and downgrade access after Stripe's retry window, not immediately.
  • Race conditions. The webhook can arrive before your success redirect. Your UI must tolerate 'payment processing' for a second or two rather than assuming instant state.
  • Local testing. Use the Stripe CLI (stripe listen --forward-to) so you test against real event payloads, not mocks.
  • Plan changes and proration. Upgrades/downgrades emit customer.subscription.updated — make sure your gate reads the current price, not the one at signup.

The gate

Finally, check entitlement from your own database in a server component or middleware — never call Stripe on every page load. Your webhook keeps that row fresh; your app just reads it. That's the whole pattern: Stripe is the system of record for money, your database is the system of record for access, and webhooks keep them in sync.

This is the same billing backbone behind the SaaS products I ship — see work or start a project if you want billing done right the first time.

Related case studies

Building something like this?

I help startups ship AI agents, custom CRMs, React Native apps, and Next.js SaaS to production.

← All posts