Skip to content
Stripe Subscriptions in Next.js: The Setup That Survives Production
Milos KnezevicMilos Knezevic20 Oct 2026 · 11 min read

Stripe Subscriptions in Next.js: The Setup That Survives Production

Checkout is the demo. Production is webhooks, idempotent sync, dunning, and the test-to-live checklist — the Stripe setup that keeps working after launch.

Stripe owns the truth. Your database keeps a copy.

The flow every tutorial shows looks complete: the user clicks subscribe, you redirect them to Stripe Checkout, they pay, Stripe sends them back to your success page, and you grant access. It works in the demo. It fails in production, because the redirect is not a receipt. A customer can pay and close the tab before your success page ever loads. Some European payment methods confirm hours after checkout. And a success page is just a URL — anyone can visit it without paying a cent. The success page is UX. It is not truth.

The model that survives contact with real customers is this: Stripe is the system of record for everything payment-shaped. Who has a subscription, what state it is in, when it renews, whether the last invoice was paid — Stripe decides all of it. Your database decides nothing. It keeps a mirror of that state so your pages can check access with one local query instead of an API round-trip on every request. Webhooks are the sync mechanism: when payment state changes, Stripe calls you, and you update the mirror. Everything else in this article is an implementation detail of that one sentence.

Get this wrong and the failure is not a crash you can debug — it is silent. A customer pays through a checkout link you posted somewhere, no webhook records it, and three days later they email asking where their access is. Or the inverse: you granted access on the redirect, their SEPA payment failed after the fact, and they keep the product for free. Both happen quietly, both cost you money or trust, and neither shows up in your error tracker.

The mirror needs less than you would expect. One table:

prisma/schema.prisma
model Subscription {
  id                   String   @id @default(cuid())
  userId               String   @unique
  stripeCustomerId     String
  stripeSubscriptionId String   @unique
  status               String
  priceId              String
  currentPeriodEnd     DateTime
  createdAt            DateTime @default(now())
  updatedAt            DateTime @updatedAt
}

The two Stripe IDs are the join points between your world and Stripe's — every webhook payload speaks in those IDs, never in yours. The status and currentPeriodEnd fields are what your access checks will read later, and the unique constraint on stripeSubscriptionId is not decoration: it is what makes the upsert pattern in the webhook handler safe.

Products and prices live in two parallel universes

In Stripe's vocabulary, a product is the thing you sell and a price is how you bill for it — amount, currency, billing interval. A typical SaaS has one product, Pro, with two prices: monthly and yearly. Create them in the dashboard. You can create them through the API, and for a catalogue that changes often you should, but a subscription business with two tiers does not need a provisioning script. It needs you to click four buttons once and write the resulting price IDs into environment variables.

The detail that bites people is that test mode and live mode are entirely separate universes. Separate API keys, separate customers, separate products — and crucially, separate IDs for prices you created identically in both. The classic launch bug is a test-mode price ID hardcoded somewhere, shipped to production, and a checkout that fails with No such price for your first real customer. The discipline is boring and absolute: price IDs come from environment variables, set per environment, never from a string literal in code. If you want extra safety, give each price a lookup key — a stable name like pro_monthly that your code can resolve at runtime — so the same code works in both modes without any ID juggling.

Stay in test mode until the go-live checklist at the end of this article. The test card number 4242 4242 4242 4242 accepts everything; Stripe also publishes cards that decline, require 3D Secure, or fail after a delay, and you will want those for the failure paths — which is where most of your real code lives.

Create the Checkout Session in a route handler

Checkout Sessions are created on the server, because everything that matters about them — the secret key, the price, the identity of the buyer — must not be decided by the client. The browser's only job is to receive a URL and redirect to it.

app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { stripe } from "@/lib/stripe";
import { getOrCreateStripeCustomer } from "@/lib/billing";

export async function POST() {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
  const customerId = await getOrCreateStripeCustomer(session.user.id);
  const checkout = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    line_items: [{ price: process.env.STRIPE_PRICE_PRO_MONTHLY!, quantity: 1 }],
    metadata: { userId: session.user.id },
    subscription_data: { metadata: { userId: session.user.id } },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  });
  return NextResponse.json({ url: checkout.url });
}

Look at the two metadata lines, because they are the part tutorials skip and the part you will be grateful for in six months. Webhook payloads identify everything by Stripe IDs. When a subscription renews or cancels next year, the event will tell you which customer and subscription are involved — and nothing about which row in your users table that is. Writing your userId into metadata on both the session and the subscription gives every future webhook a return address. The getOrCreateStripeCustomer helper matters for the same reason: create the Stripe customer once, store its ID on the user, and reuse it. Skip that and every purchase mints a duplicate customer, and your billing history fragments across all of them.

One more production reality: this endpoint is public the moment you deploy it, and each call creates an object in your Stripe account. Bots find payment endpoints quickly. Give it the same protection as any other state-changing route — the rate limiting guide covers the pattern for Next.js route handlers exactly like this one.

The webhook handler is where the business actually lives

Everything up to this point, Stripe could have demoed for you. The webhook endpoint is the part that is genuinely yours: it is where payment events become database state, and it is the single most consequential file in the integration.

app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import {
  syncSubscription,
  revokeSubscription,
  handleFailedPayment,
} from "@/lib/billing";

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;

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

  switch (event.type) {
    case "checkout.session.completed":
    case "customer.subscription.updated":
      await syncSubscription(event);
      break;
    case "customer.subscription.deleted":
      await revokeSubscription(event);
      break;
    case "invoice.payment_failed":
      await handleFailedPayment(event);
      break;
  }
  return new Response(null, { status: 200 });
}

Stripe emits dozens of event types, and most of them do not matter for subscriptions. Four do:

  • checkout.session.completed — a new subscription was paid for. Read your userId back out of the metadata, fetch the subscription, write your mirror row, grant access.
  • customer.subscription.updated — the workhorse. Plan switches, renewals, a cancellation scheduled for period end, a status sliding to past_due: all of it arrives as this one event. Sync the whole record every time it fires.
  • customer.subscription.deleted — the subscription is gone for good. Mark the row canceled and revoke access.
  • invoice.payment_failed — a renewal charge failed, usually an expired card. Do not revoke access here; the dunning section below explains what to do instead.

Notice that syncSubscription handles two event types with one function. That is deliberate, and it is the most useful implementation pattern in this article: do not patch individual fields based on what an event claims changed. Fetch the subscription fresh from the Stripe API and upsert the entire mirror row, keyed on the Stripe subscription ID. Events can arrive out of order — an update delivered before the creation it follows — and sync-everything-from-the-source is immune to ordering in a way that field-patching never is.

warning

Webhooks retry. Stripe re-sends any event that does not get a 2xx response, for up to three days, and occasionally delivers the same event twice even when nothing failed. Your handler must produce the same end state when run twice with the same event — upsert, never create. An upsert keyed on the Stripe subscription ID gives you idempotency almost for free; an insert that throws a unique-constraint error on the second delivery does not, and worse, the error makes Stripe retry it again.

The constructEvent call at the top is signature verification: proof the request came from Stripe and not from anyone who found your endpoint URL. Get the details wrong — parse the body before verifying, skip the timestamp check — and your billing endpoint takes orders from strangers. The full story, including why the raw bytes matter and what attackers actually send to unverified endpoints, is in the webhook verification deep dive.

info

Signature verification needs the raw request body, byte for byte. In an App Router route handler, await req.text() gives you exactly that — which is why the handler reads text, never req.json(). Anything that parses the body before you do, a framework body parser or a middleware that touches the request, re-serializes it slightly differently and every signature check fails. If verification fails and you are certain the secret is right, a parsed body is almost always the culprit.

Locally, Stripe cannot reach localhost, so the Stripe CLI plays courier:

terminal
# terminal 1 — forward Stripe events to your dev server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# terminal 2 — fire test events at it
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

tip

stripe listen prints its own signing secret, and it is different from the one on your dashboard webhook endpoint. Put the CLI's secret in .env.local while developing, or every local event will fail verification and you will spend an hour blaming your own code.

The customer portal is one line that replaces a month of UI work

Customers need to cancel, switch plans, update an expired card, and download invoices. You can build all of that yourself — forms, confirmation flows, proration maths, the are-you-sure modal — or you can configure Stripe's hosted customer portal once in the dashboard and add a single redirect:

app/api/portal/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { stripe } from "@/lib/stripe";
import { getStripeCustomerId } from "@/lib/billing";

export async function POST() {
  const session = await auth();
  if (!session?.user?.id) return new Response(null, { status: 401 });

  const customerId = await getStripeCustomerId(session.user.id);
  if (!customerId) return new Response(null, { status: 400 });

  const portal = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  });
  return NextResponse.json({ url: portal.url });
}

Every change a customer makes in the portal — a cancellation, a plan switch, a new card — flows back to you as the same customer.subscription.updated webhook you already handle. The mirror stays correct and you wrote nothing new. That is the sync model paying rent. There is also a regulatory angle: EU consumer rules increasingly expect cancelling to be as easy as subscribing was, and a self-service portal clears that bar without a single support email.

Failed payments are normal. Dunning is the plan for them.

A subscription business loses customers two ways. Voluntary churn is someone deciding to leave. Involuntary churn is someone whose card expired, hit its limit, or was reissued after fraud — they never decided anything, and a meaningful slice of your renewals will fail this way every single month. This is not an edge case you handle defensively; it is a recurring operational reality, and the industry name for handling it is dunning.

The reflex to fight is revoking access the moment invoice.payment_failed arrives. Most failed renewals are recoverable: Stripe's Smart Retries re-attempt the charge over the following days at times its models predict will succeed, and the dashboard's revenue recovery settings will email the customer a hosted page where they can update their card without logging in to anything. Cut access at the first failure and you punish a customer who did nothing wrong, usually hours before a retry would have quietly fixed it.

So the policy that works looks like this. On payment failure, Stripe moves the subscription to past_due — your customer.subscription.updated handler records that automatically, with no new code. Keep access alive while past_due and let retries and emails run; your handleFailedPayment function sends at most one gentle nudge. If every retry fails, Stripe cancels the subscription or marks it unpaid, depending on what you chose in the dashboard — choose cancel, so the terminal state arrives as the customer.subscription.deleted event you already handle and access ends through the normal path. The whole dunning flow lands on code you have already written. What remains is one decision, not code: how long past_due keeps access. The default retry window of roughly three weeks is a sane answer for most products.

The go-live checklist

When the integration works end to end in test mode, switching to live is a sequence of small, easy-to-forget steps. None of them are hard. All of them are invisible until your first real customer finds the one you missed.

  • Recreate products and prices in live mode and put the live price IDs into production environment variables — test-mode IDs do not exist in live mode.
  • Register the live webhook endpoint with your production URL and set its live signing secret in production env vars. The CLI secret and the test endpoint's secret are both wrong here — this is the single most common day-one failure.
  • Deploy the webhook handler and confirm it returns 2xx in the Stripe dashboard's event log before sharing any checkout link.
  • Configure the customer portal in live mode — portal settings do not copy over from test mode.
  • Turn on Smart Retries and customer emails in the live-mode revenue recovery settings, and set the after-final-retry behaviour to cancel.
  • Set your statement descriptor so the charge on a customer's card statement says your product's name. Unrecognized charges become disputes, and disputes cost far more than the sale.
  • Run one real transaction with your own card on the cheapest plan, watch every webhook land in your database, then refund it.
  • Trigger invoice.payment_failed in test mode one last time and verify that the customer email and the database state both do what you expect.

Build it in test mode this week

Billing is one subsystem of a production application, and it leans on all the others: authentication to know who is buying, a database to mirror state, server rendering to gate access on the mirror. If you want the map of how those pieces fit together — and the order to learn them in — the full-stack overview is the place this article hangs off.

The next step is concrete. Build the whole flow in test mode this week: checkout click, CLI forwarding events, webhook writing the mirror, portal cancelling, a triggered payment failure landing as past_due. It is an evening or two of work once you have the model in your head — Stripe owns the truth, your database mirrors it, webhooks keep them in sync. At the end of it you will not have a demo. You will have the part that makes a project a business.