Adios
BlogNext.js SaaS

Next.js SaaS

How to Build Stripe Subscriptions in Next.js: Checkout, Webhooks, and Billing State

Build Stripe subscription billing in Next.js with server-created Checkout Sessions, verified webhooks, local entitlements, and idempotent processing.

Adios teamUpdated July 17, 20268 min read

The redirect back from Checkout is a user experience event. Verified webhooks are what let the application reconcile asynchronous billing state reliably.

Own a local billing model

Store provider customer and subscription IDs on the owning account, then normalize the plan, status, current period, cancellation state, and entitlement your application needs. The provider object is not a substitute for a product-specific access model.

Decide how trialing, active, past_due, canceled, and incomplete states affect the application. Keep billing history and access decisions explainable. A pricing-page label should map to one configured price ID on the server, not an arbitrary price supplied by the browser.

Create Checkout Sessions on the server

Authenticate the account, validate the requested plan against an allowlist, create or reuse its Stripe Customer, and create a Checkout Session in subscription mode. Attach your stable account ID as metadata so later events can be reconciled without trusting an email address.

Return or redirect to the provider URL. Never expose the secret key to the browser. Use an idempotency key when a repeated application request must not create duplicate provider operations.

"use server";

export async function startCheckout(plan: PlanId) {
  const account = await requireBillingAdmin();
  const price = PRICE_IDS[plan];
  if (!price) return { error: "Unknown plan" };

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: account.stripeCustomerId,
    line_items: [{ price, quantity: 1 }],
    success_url: SITE_URL + "/settings/billing?checkout=complete",
    cancel_url: SITE_URL + "/pricing",
    metadata: { accountId: account.id },
  });

  redirect(session.url);
}

Treat the return page as pending

A customer can reach the success URL before your application processes every asynchronous event, and a copied URL is not proof of payment. Show a confirmation state, then read the local subscription record and refresh it after webhook processing.

Do not grant permanent access from a query parameter or a client-side provider response. Entitlements should follow verified server state. Give the customer a clear retry or support path when billing remains incomplete.

Verify the raw webhook request

A Next.js Route Handler can read the unmodified body with request.text and the signature from request.headers. Pass the raw body, Stripe-Signature value, and endpoint secret to the official library. Parsing JSON first changes the body used for verification and can make signatures fail.

Reject invalid signatures before doing work. Keep test and production endpoint secrets separate. Return a successful response quickly after recording accepted work; long emails or synchronization should move to a job.

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

  const event = stripe.webhooks.constructEvent(
    payload,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET,
  );

  await recordBillingEvent(event);
  return new Response(null, { status: 200 });
}

Process events idempotently

Webhook delivery can repeat and order is not guaranteed. Insert the provider event ID under a unique constraint before applying side effects. If the event already exists, acknowledge it without sending another email or applying the change twice.

For subscription updates and deletions, retrieve or derive the current authoritative provider state when event ordering could leave the local record stale. Perform the event record and billing-state update in a transaction where possible.

  • Record the event ID and type.
  • Associate it with the local account.
  • Apply the normalized subscription state once.
  • Queue slow follow-up work after durable state changes.

Handle the complete subscription lifecycle

Support customer and subscription creation, plan changes, renewals, failed payments, scheduled cancellation, immediate cancellation, and deletion. Use the customer portal when it matches the product rather than rebuilding payment-method and invoice management without a reason.

Authorization still applies: only the appropriate account role may begin checkout or open billing management. Record who requested a plan change and show the effective date so support can explain the account's state.

Test retries and failures

Use the Stripe CLI or a test event destination to forward signed events. Repeat the same event, deliver an older update after a newer one, use the wrong secret, interrupt the database, and trigger a failed payment. Confirm that access and local state remain explainable.

Test the production build against test-mode credentials before enabling the live endpoint. Keep live and test IDs separate and prevent a preview deployment from registering itself as the production destination accidentally.

Host billing on a stable HTTPS release

Stripe requires a publicly accessible HTTPS webhook endpoint in production. Adios provides the persistent Next.js runtime, generated HTTPS route, custom domains, managed TLS, secret references, health checks, and runtime logs needed to operate that endpoint beside the SaaS UI.

Deploy and verify Checkout in test mode, inspect signature failures without logging payload secrets, then promote the healthy release. Register the stable production webhook URL, not a disposable preview. If a future candidate fails its build or health check, it does not have to replace the working billing endpoint.

secrets:
  STRIPE_SECRET_KEY: secret://STRIPE_SECRET_KEY
  STRIPE_WEBHOOK_SECRET: secret://STRIPE_WEBHOOK_SECRET
  DATABASE_URL: secret://DATABASE_URL

runtime:
  name: node@24
  port: 3000
  health_path: /api/health
  All articles