All writing

Set up Stripe subscriptions in a Next.js SaaS app

Published

A practical Stripe subscription design that uses Checkout, webhooks, the customer portal, and a small local cache.

If I add subscriptions to a software as a service (SaaS) app, I start with Stripe's hosted payment flow. I do not start with a custom payment form.

The first version needs four parts:

This design keeps payment collection in Stripe. The app remains responsible for authentication, product access, and recovery when local data differs from Stripe.

Define the integration boundaries

Create one server route for each operation.

The Checkout route creates a Checkout Session. It receives the authenticated workspace, selected plan, success URL, and cancel URL. The server maps the plan to a Stripe Price ID. Do not accept an arbitrary Price ID from the browser.

The portal route creates a customer portal session. It needs the authenticated workspace and the workspace's Stripe Customer ID.

The webhook route processes subscription events. At minimum, handle the events that your access model needs, such as:

Verify the webhook signature before you parse or apply an event. Make each handler idempotent because Stripe can deliver the same event more than once.

Store a small local cache

I usually store these fields:

The local record supports access checks and billing screens. It does not replace Stripe as the source of truth for the payment lifecycle.

Reconcile before you display billing state

Most subscription bugs come from state drift. A customer changes a plan in the portal, a webhook arrives late, or the app reads stale data after a redirect. The billing page can then say "renews" while Stripe says "cancels."

Use this read path when the page needs current billing state:

  1. If the app has a Subscription ID, retrieve that subscription from Stripe.
  2. If the app has only a Customer ID, list the customer's subscriptions and select the applicable record.
  3. Update the local record with the Stripe response.
  4. Render the reconciled state.

If the state is still unclear, show the billing period end date. Do not claim that a subscription will renew or cancel until the provider state supports that claim.

Verify the complete lifecycle

Before release, test that:

Billing text affects trust. A wrong renewal message can be worse than a visible error because the user may make a payment decision from it.

Design principle

Let Stripe manage payment collection and the subscription lifecycle. Let the app manage product access and a local cache of the provider state.

The integration is complete when each boundary has one clear job. Checkout collects payment. Webhooks synchronize lifecycle events. The portal handles customer changes. The database stores the minimum state that the product needs.

Sources