Stripe subscriptions in Next.js: Checkout, webhooks, customer portal, and state

Published on July 27, 2026 · 8 min read

Stripe subscriptions in Next.js: Checkout, webhooks, customer portal, and state

A Stripe subscription in Next.js is not complete when a customer returns from Checkout. It is complete when your application has processed the relevant, verified billing event and updated the access state it owns. That distinction matters for renewals, failed payments, cancellations, delayed payment methods, and customer-portal changes.

This guide gives solo SaaS builders a small lifecycle to implement and test: create Checkout on the server, return the customer to your app, accept signed webhooks, project Stripe state into your own access record, and let the portal send future changes back through that same path. It is deliberately narrower than a full billing system; taxes, usage pricing, invoices, and your exact entitlement rules still need product-specific design.

The short answer: use Checkout for payment, webhooks for truth

Stripe Checkout can create a recurring subscription and handle the payment UI. Your success URL is useful for returning the customer to the product, but it is a navigation event in a browser—not the durable source of access. Stripe documents subscription activity as asynchronous webhook events and recommends handling payment failures and subscription-status changes at a webhook endpoint.

Use a Customer Portal session when you want customers to manage the actions you have enabled in Stripe. It reduces the amount of billing UI you have to own, but it does not remove the need to listen for the resulting events. Stripe’s portal integration guide explicitly includes configuring the portal, redirecting the customer, listening to webhooks, and launching it as separate steps.

The complete subscription lifecycle

The following is the article’s lifecycle asset. It is a practical state flow, not a claim that every SaaS should grant the same entitlements.

  1. Choose a price on the server. Resolve an allowed recurring price from your own configuration. Associate the authenticated application user with the Stripe customer or a stable internal reference; do not accept a price ID or customer ID from an untrusted form as your authorization decision.
  2. Create a Checkout Session. Request subscription mode, line items, and explicit success and cancel URLs. Redirect the browser to Stripe’s hosted Checkout page.
  3. Return to the app without granting access yet. The success page can say that billing is being confirmed and poll or refresh an account-status endpoint. It should not write an entitlement merely because a query parameter says Checkout completed.
  4. Verify the webhook against the raw request body. Check the Stripe signature before parsing or changing application state. Store an event identifier or enforce an equivalent idempotency boundary so a retry does not duplicate work.
  5. Normalize and project the billing state. Map the verified Stripe event and current subscription into an application-owned record: internal user ID, Stripe customer ID, subscription ID, price or plan reference, status, current-period boundary, and access decision.
  6. Authorize product access from the projection. Protected application code reads your access record, not a success page. Make the rule explicit for trialing, active, past_due, canceled, and unpaid states.
  7. Send account changes through the same boundary. Create a portal session for the authenticated customer. Plan changes, cancellation, and payment-method updates eventually return as Stripe events; update the projection from those events rather than trusting a portal redirect.

Stripe’s current subscription documentation lists customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, and invoice.payment_failed among the events that a subscription integration commonly needs to handle. The exact event set and access rule are a product decision, so record that decision next to your event handler.

1. Create Checkout behind an authenticated server boundary

Build the Checkout request in server-side code after identifying the signed-in user. The browser should request an allowed plan key; server code resolves that key to a Stripe Price ID and creates the session. This prevents the client from deciding which customer record to bill or which unpublished price should be used.

type StartSubscriptionInput = {
  plan: "starter" | "pro";
  userId: string;
};

export async function startSubscription(
  input: StartSubscriptionInput
): Promise<{ checkoutUrl: string }> {
  const priceId = allowedRecurringPrices[input.plan];
  if (!priceId) throw new Error("Unsupported subscription plan");

  const customerId = await billing.getOrCreateCustomer(input.userId);
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${appUrl}/billing/return?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${appUrl}/pricing`,
  });

  if (!session.url) throw new Error("Checkout URL was not created");
  return { checkoutUrl: session.url };
}

This is illustrative TypeScript, not an Accelerator repository excerpt. In a real Next.js app, put provider SDK calls behind the payment adapter or server-only billing module your project already uses. Frontend Accelerator follows that adapter approach: its verified product facts describe Stripe and Lemon Squeezy adapters, while the current product repository exposes checkout and customer-portal methods through a payment interface.

What the return page should do

  • Show a clear “payment received; confirming access” state.
  • Read a server-authorized account status, then refresh it briefly if the webhook has not arrived.
  • Give the customer a safe route back to the product instead of treating the return as proof of entitlement.
  • Log support-friendly identifiers server-side; do not expose secrets or make client-side state the authority.

2. Verify and deduplicate every webhook before changing access

Webhook delivery is a server-to-server boundary. Stripe’s signature-verification guidance requires the raw request body, the Stripe-Signature header, and the endpoint secret; modifying or parsing the body before verification can cause verification to fail. Keep that work in a route handler or another server-only endpoint.

export async function POST(request: Request): Promise<Response> {
  const rawBody = await request.text();
  const signature = request.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  if (await billingEvents.hasProcessed(event.id)) {
    return new Response("Already processed", { status: 200 });
  }

  await billingEvents.recordAndProject(event);
  return new Response("ok", { status: 200 });
}

The example leaves out retries, transaction semantics, and event ordering because the right implementation depends on your database. The invariant is more important: a verified event should be recorded once, and its projection should be safe to repeat. Stripe may retry webhook delivery; an endpoint that succeeds only when it is invoked exactly once is not a reliable billing boundary.

Choose your access policy before writing conditionals

Stripe describes trialing as a state where you can safely provision a product, active as in-good-standing, and past_due as a policy decision shaped by your billing settings. It recommends revoking access for canceled and unpaid. Your product might offer a grace period, but make that a documented rule in your access projection rather than an accidental side effect of a page redirect.

3. Keep a small application-owned subscription projection

Stripe is the billing provider; your app needs a focused record for authorization and UI. A useful projection answers “may this account use this feature now?” without asking a browser page to infer billing status.

  • Identity: internal user ID, Stripe customer ID, and Stripe subscription ID.
  • Commercial reference: plan or price reference that your application recognizes.
  • Observed billing state: normalized status, current-period end when relevant, and whether cancellation is scheduled.
  • Authorization decision: active, grace, restricted, or revoked according to your documented policy.
  • Processing metadata: last applied event ID and timestamp so retries and support investigations have an audit trail.

Do not turn this projection into a second billing provider. It is a narrow authorization read model. When an event arrives, retrieve or normalize the canonical subscription state as needed, then update the projection atomically with the event record. If events arrive out of order, compare provider timestamps or retrieve current state instead of allowing an older event to overwrite a newer decision.

4. Use the customer portal for account self-service, not as a state shortcut

A billing portal session is created for a specific customer and redirects them to the capabilities you configured. Before exposing a portal button, confirm that the server has authorized the signed-in user to use that Stripe customer ID and that the return URL is one you control. Decide which portal features match the product: payment-method updates, invoice history, cancellations, plan changes, or quantity changes.

After the customer returns, use the same confirmation pattern as Checkout. A portal redirect proves navigation, not that every billing-related webhook has been applied. The durable account screen should read your projection; the webhook handler remains the path that updates it.

Test the lifecycle as state transitions

Test the failures that make a browser-only implementation misleading. Stripe documents both sandbox actions and the Stripe CLI as ways to trigger events for webhook testing. Use test-mode fixtures and isolate them from real customer data.

  • Checkout completion: a verified completion creates or refreshes the expected projection and grants only the intended access.
  • Duplicate delivery: the same event ID twice produces one durable change.
  • Payment failure: the customer receives the intended restricted or grace experience; no success page overrides it.
  • Cancellation: a scheduled cancellation and an ended subscription produce the policy you documented.
  • Out-of-order delivery: an older update cannot erase a newer state.
  • Portal change: a plan or payment-method update reaches the app through verified events and updates the projection.
  • Signature failure: an invalid signature produces no billing or access write.

A sensible first implementation boundary

For a first recurring SaaS plan, keep the system boring: hosted Checkout, one server-side plan resolver, one verified webhook endpoint, a small idempotent projection, and a customer portal button. Add metered usage, proration rules, complex entitlements, and recovery flows only when the product needs them and you can test their transitions.

Frontend Accelerator can be a useful starting reference if you want a Next.js SaaS foundation with payment adapters, protected application areas, and documented agent conventions. It does not remove the need to decide entitlement policy, configure Stripe, validate webhook behavior, or test your product-specific billing flows.

Explore Frontend Accelerator’s architecture when you want to begin with the surrounding SaaS boundaries rather than assembling every integration from isolated examples.


Sources

Your next step

Put this pattern into a working SaaS foundation

Start with connected authentication, billing, dashboards, and provider boundaries—then spend your build time on what makes your product different.

AI-friendly architecture
Production ready from day one
Lifetime updates