← Blog

Stripe Checkout and the customer portal in the Next.js App Router: two routes, one writer

Wiring Stripe Checkout and the customer portal in the Next.js App Router: reuse one customer, set return URLs, and let the webhook be the only writer.

The easy way to wire subscription billing is to write plan changes wherever they happen: the checkout success page flips the user to Pro, an upgrade button patches the row, and the webhook writes it again. Each path works on its own. Together they drift, and the failure is expensive in a specific way: a user on Pro in your database and on Scale in Stripe is charged $99 and metered at the $20 cap. Wiring Stripe Checkout and the customer portal in the Next.js App Router is two short route handlers. The part that costs money when you get it wrong is deciding who is allowed to write plan state. In the kit the answer is: only the webhook.

The checkout route: reuse one customer, stamp the user id

The checkout handler does four things, in order: refuse if billing is not configured (503), refuse anonymous callers (401), validate the requested plan with Zod (400), then create a Checkout session for a subscription. The part worth copying is the customer handling:

const origin = new URL(req.url).origin;

// Reuse the user's Stripe customer if we've created one before, so their
// billing history and payment methods stay on a single customer.
let customerId = await getStripeCustomerId(session.user.id);
if (!customerId) {
  const customer = await stripe().customers.create({
    email: session.user.email,
    metadata: { userId: session.user.id },
  });
  customerId = customer.id;
}

const checkout = await stripe().checkout.sessions.create({
  mode: "subscription",
  customer: customerId,
  line_items: [{ price: plan.stripePriceId, quantity: 1 }],
  client_reference_id: session.user.id,
  subscription_data: { metadata: { userId: session.user.id } },
  allow_promotion_codes: true,
  success_url: `${origin}/dashboard?upgraded=1`,
  cancel_url: `${origin}/pricing`,
});

return Response.json({ url: checkout.url });

The route returns the session URL as JSON and the client does window.location.href = data.url. That keeps the handler a plain POST you can call with fetch, and lets the button handle a 401 by sending the user to sign in with a redirect back to /pricing.

Notice what the route does not do: it never touches the subscriptions table. The user id goes onto the subscription itself via subscription_data.metadata, and the webhook reads it from there when Stripe reports the subscription — see the webhook verification post for that half. The price id is the only plan information sent, and it comes from env (STRIPE_PRICE_PRO, STRIPE_PRICE_SCALE), so the same code runs against test and live accounts.

The portal route: let Stripe own plan changes

The portal handler is even shorter. It looks up the stored customer id; a free user who has never checked out has none, so it returns 400 with a message instead of creating an empty customer. Otherwise it opens a billing portal session pointed back at the dashboard:

const portal = await stripe().billingPortal.sessions.create({
  customer: customerId,
  return_url: `${origin}/dashboard`,
});

This is the route that carries the design decision. Upgrades between paid plans, downgrades, cancellation, card updates and invoices all happen inside Stripe's hosted page. Each of those fires customer.subscription.updated or deleted, the webhook maps the new price id back to a plan with planByPriceId, and the row that resolves quota and model access changes. There is no "change plan" endpoint in the app, so there is no second code path that can disagree with Stripe. Prorations, failed-payment states and cancel-at-period-end are Stripe's problem, and the webhook just records the result.

The success URL lands on the dashboard, but the dashboard reads the plan from the database, not from the query string. That is deliberate: ?upgraded=1 is not proof of payment, and anyone can type it.

Things that bite

  • The portal needs a saved configuration first. Until you save the customer portal settings in the Stripe dashboard (separately for test mode and live mode), creating a portal session fails with an error telling you to do so. It is a one-time click, but it breaks the first demo.
  • Allow plan switching in the portal, or the Pro to Scale path does not exist. The portal only offers the products you list in its settings. If Scale is not listed there, a Pro user cannot upgrade, because the app has no upgrade route of its own.
  • Do not send a paying user back through Checkout. The pricing page's upgrade button calls the checkout route, and that route does not check for an existing subscription. A Pro user who clicks "Scale" gets a second, separate subscription and two charges. Show paid users a "Manage billing" button (the portal) instead of upgrade buttons, or add an active-subscription check that returns 409.
  • An unknown price id becomes the free plan. The webhook stores plan?.id ?? "free". Create a new price in Stripe without updating the env var, and a customer who pays for it is metered as free. When you change prices, change env first.
  • Abandoned checkouts leave duplicate customers. The customer id is only stored once the webhook writes a subscription row, so a user who opens Checkout, closes it, and tries again gets a fresh Stripe customer each time. Harmless for billing, messy in the dashboard. If it bothers you, persist the customer id on the user record at creation.
  • The success page can beat the webhook. Stripe redirects the browser immediately; the webhook can arrive a second later. A dashboard rendered in that window still says Free. Either tolerate it or poll briefly when upgraded=1 is present, reading the database each time.
  • origin comes from the request URL. That is correct on Vercel. Behind a proxy that rewrites the host, build return URLs from a configured site URL instead, or Stripe will send users back to an internal hostname.

Shipwright — the Next.js and Claude starter kit this blog documents — ships both routes, the pricing and dashboard buttons that call them, and the webhook that is the single writer of plan state, so quota and model gating follow whatever Stripe says. The plan limits it enforces are visible in the live demo.

Shipwright is a Next.js 16 + Claude starter kit that ships these patterns already done.

Try the live demo →