Stripe Customer Portal Setup Done Right | Coding Capybaras

How to add the Stripe customer portal to a Next.js SaaS: Dashboard configuration, the one API route you need, webhook handling, and when to skip it.

· Justin Boggs

A person holding a credit card while typing on a laptop

Photo by rupixen on Unsplash

Adding a Stripe customer portal to your SaaS takes one Dashboard configuration session and one server route — roughly an hour of work — and in exchange your customers can update cards, download invoices, switch plans, and cancel subscriptions without you building any of those screens. The route creates a portal session with stripe.billingPortal.sessions.create(), redirects the customer to the short-lived URL Stripe returns, and your webhooks pick up whatever they changed. This walkthrough covers the Dashboard decisions that actually matter, the exact Next.js code, the webhook events the portal fires behind your back, and the honest cases where the portal is the wrong tool.

TL;DR

  • The Stripe customer portal is a hosted billing dashboard — card updates, invoice history, plan changes, cancellations — that replaces weeks of UI work with one API call.
  • Configure it in Dashboard → Settings → Billing → Customer portal. The two decisions that matter: cancel at period end vs. immediately, and which plans customers can switch between.
  • Your only code is a server route: look up the customer's Stripe ID, create a portal session, redirect to its URL. Sessions are single-use and short-lived.
  • The portal changes subscriptions without touching your app, so webhooks (customer.subscription.updated, customer.subscription.deleted) are mandatory, not optional.

What is the Stripe customer portal (and what does it replace)?

The Stripe customer portal is a Stripe-hosted, co-branded page where your customers manage their own subscription, payment methods, and invoices — you redirect them there with one API call instead of building billing UI yourself. Stripe's customer portal documentation frames it as subscription, billing, and invoicing management "without building it yourself," and for once the marketing copy undersells the value.

Here's the list of things you don't build when you adopt it:

  • A "update your card" form (which means handling payment method tokenization UI)
  • Invoice history with PDF downloads
  • Plan upgrade/downgrade screens with proration math displayed correctly
  • Cancellation flow, including "cancel at period end" logic
  • Reactivation flow for customers who cancel and change their minds
  • Tax ID collection for your EU customers' VAT invoices

Every one of those is a screen you'd need to design, wire to the API, and keep compliant as Stripe evolves. When I built the billing layer for Coding Capybaras, the entire "manage billing" surface in the boilerplate is a single button — it posts to a route that creates a portal session and redirects. The screens on the other side are Stripe's problem, and Stripe is better at billing UI than I will ever be.

The mental model matters: the portal is part of your app's surface area, but not part of your codebase. Your customer clicks "Manage billing" in your dashboard, lands on a page with your logo and colors (set in Stripe's branding settings), does their business, and returns to the return_url you specified. To them it's seamless. To you it's a redirect.

If you haven't wired Stripe into your app at all yet, start with my Next.js Stripe tutorial — this post assumes you already have customers with Stripe customer IDs stored in your database.

Configure the portal: the four decisions that matter

Before writing any code, open Dashboard → Settings → Billing → Customer portal. Most toggles here are self-explanatory; four of them shape your business, so they deserve actual thought.

1. Cancellation: at period end, or immediately? Choose "at period end" unless you have a specific reason not to. The customer keeps access through what they paid for, you keep the revenue, and the relationship ends politely — which matters, because canceled customers who leave on good terms come back. Immediate cancellation raises refund questions you don't want ("I canceled on day 2, where's my money?"). Note the webhook implication either way: with period-end cancellation, you get customer.subscription.updated (with a set cancel_at) at cancel time and customer.subscription.deleted only when the period actually ends.

2. Cancellation reasons: on. The portal can ask "why are you canceling?" with configurable options. It's free churn research delivered directly into your webhook payloads. Every canceled subscription becomes a data point for the churn analysis I described in the first-month SaaS dashboard.

3. Plan switching: which products, and how does proration work? If customers can upgrade or downgrade, you must define a product catalog — the specific prices they're allowed to move between. Two gotchas from the docs that surprise people: switching plans mid-trial ends the trial immediately, and quantity restrictions are Dashboard-only settings. Decide deliberately whether downgrades are self-serve; letting a customer drop from $49 to $19 without friction is customer-friendly, but you should at least know it's happening (webhooks again).

4. Tax ID collection: on if you sell to businesses. EU companies will ask you to put their VAT number on invoices; the portal collects and validates it, and Stripe adds it to invoices automatically. If you're using Stripe Tax for tax calculation, this checkbox is what makes B2B invoices come out right without support tickets.

Everything you configure has separate live-mode and sandbox versions — settings don't cross over, which is a feature (test wild ideas safely) and a footgun (launching with an unconfigured live portal throws "customer portal not enabled" errors at real customers).

The Next.js integration: one route and a redirect

Here's the entire server-side integration. In the Coding Capybaras boilerplate this lives behind the PaymentProvider abstraction in /platform/lib/payments/, but the raw Stripe version is short enough to show in full:

// app/api/billing-portal/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { getAuthedUser } from "@/lib/auth"; // your auth helper

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  // 1. Authenticate — never create sessions for unauthenticated requests
  const user = await getAuthedUser();
  if (!user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // 2. Look up the Stripe customer ID you stored at checkout time
  if (!user.stripeCustomerId) {
    return NextResponse.json(
      { error: "No billing account" },
      { status: 400 },
    );
  }

  // 3. Create the portal session
  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/app/settings`,
  });

  // 4. Redirect to the short-lived portal URL
  return NextResponse.redirect(session.url, 303);
}

The client side is a plain form — no Stripe.js required:

<form method="POST" action="/api/billing-portal">
  <button type="submit">Manage billing</button>
</form>

The full round trip looks like this:

sequenceDiagram
    participant U as Customer
    participant A as Your Next.js app
    participant S as Stripe
    U->>A: Clicks "Manage billing"
    A->>A: Authenticates user, loads stripeCustomerId
    A->>S: billingPortal.sessions.create(customer, return_url)
    S-->>A: session.url (short-lived)
    A-->>U: 303 redirect to session.url
    U->>S: Updates card / switches plan / cancels
    S-->>A: Webhooks (subscription.updated, etc.)
    S-->>U: Redirect back to return_url

Three details that save debugging time:

The session URL is single-use and expires quickly. Never cache it, never email it, never render it into a page for later. Create a fresh session on every click — that's the designed flow, per the session API.

Store stripeCustomerId the moment checkout completes. The most common portal bug isn't in portal code at all — it's a missing customer ID because the checkout webhook didn't persist it. No customer ID, no portal session, confused user.

Authenticate before creating sessions. The portal session grants billing control for that customer. If your route creates sessions from an unauthenticated request with a user-supplied ID, you've built a vulnerability, not a feature.

That's genuinely the whole integration. The remaining work — and it's the part that separates a demo from production — is webhooks.

Webhooks: the portal changes state behind your back

Here's the mental shift the portal forces: your app is no longer the only thing that mutates subscriptions. A customer can downgrade, cancel, and change their default card at 2 AM without touching a single route you wrote. If your app's notion of "what plan is this user on" lives in your database — and it should — webhooks are the only way it stays true.

The events that matter, straight from Stripe's integration guide:

| Event | Portal action that fires it | What your handler must do | | --- | --- | --- | | customer.subscription.updated | Upgrade, downgrade, quantity change, cancel-at-period-end, reactivation | Sync plan + status to your DB; adjust feature access | | customer.subscription.deleted | Cancellation takes effect | Revoke access; trigger offboarding email | | customer.updated | Billing email or default payment method changed | Sync billing info — but never treat billing email as a login credential | | payment_method.attached / detached | Card added or removed | Optional: update "card on file" display |

Two subtleties that bite:

Cancel-at-period-end arrives as an update, not a delete. When a customer cancels with period-end behavior, you get customer.subscription.updated with cancel_at_period_end: true (or a non-null cancel_at on flexible billing mode). The deleted event only fires weeks later when the period lapses. If you revoke access on the update event, you just punished a paying customer for canceling politely. If they reactivate before the period ends — the portal supports that — you get another updated event flipping the flag back.

Upgrades during a trial end the trial immediately. A trialing customer who switches plans in the portal becomes a paying customer right now. If your app gates features on trialing status, handle the transition.

Everything I wrote in Stripe webhook hell applies double here: verify signatures on every event, handle duplicate deliveries idempotently, and never trust event ordering. The portal doesn't add new webhook infrastructure — it adds volume and variety to events you should already be handling. In the boilerplate, all of this flows through one webhook handler that syncs subscription state to the database, so the portal integration required zero new webhook code.

Customization, deep links, and when to skip the portal

The default portal is one-size-fits-all, and Stripe ships three tools for when that stops being true.

Deep links jump the customer straight to a specific task. Instead of landing on the portal homepage, portal deep links let you link directly to the payment-method update flow or a specific plan-change confirmation. The killer use case: a dunning email that says "your card failed" should link directly to the card update screen, not to a homepage three clicks away from fixing the problem.

Multiple configurations let different customer segments see different portals. The Dashboard manages your default configuration; additional ones are API-only. Pass configuration when creating a session to override. Useful when your legacy plan holders shouldn't see the new pricing, or enterprise customers get invoice-only views.

The no-code escape hatch: Stripe can generate one shareable login link per configuration — customers authenticate with an emailed code. Zero integration code. It's how you offer billing self-service before your product even has auth, and a fine v0 while you're validating.

And the honest comparison, because the portal isn't always right:

| Approach | Build time | Control | Best for | | --- | --- | --- | --- | | Hosted portal (this post) | ~1 hour | Branding + feature toggles | Almost every indie SaaS | | Portal + deep links | ~2 hours | Task-level entry points | Dunning flows, in-app upgrade buttons | | No-code shareable link | ~5 minutes | Minimal | Pre-launch, invoice-only businesses | | Custom billing UI on the API | Weeks | Total | Usage dashboards, seat management, enterprise quoting |

The custom row is there because some products outgrow the portal: if your pricing has per-seat management with role assignment, usage-based components customers want charted, or sales-negotiated contracts, the portal's fixed UI will pinch. That's a good problem — it means revenue complexity — but most indie products, including mine at $97 one-time for Pro, never get near it. Match the machinery to the pricing model you actually run.

Taking the portal live (the checklist people skip)

Sandbox-to-production is where portal integrations quietly break, because Stripe maintains completely separate portal configurations per mode. The settings you lovingly tuned in the sandbox do not follow you to live mode. Stripe's own launch guidance is explicit about this, and it's worth turning into a concrete checklist:

Configure the live portal separately. Turn off View test data in the Dashboard, open the portal settings again, and re-apply every decision from the configuration section above — cancellation behavior, cancellation reasons, product catalog, tax ID collection. The live product catalog needs your live price IDs, which are different objects from their sandbox twins. This is the single most common "worked in test, broke in prod" cause for portal integrations.

Register your live webhook endpoint. Sandbox webhook endpoints and live endpoints are separate registrations with separate signing secrets. If your STRIPE_WEBHOOK_SECRET env var still holds the sandbox secret in production, every live event fails signature verification — silently, from the customer's point of view. In the boilerplate this is exactly why webhook secrets live in .env.local per environment and never in code.

Check your branding and public details. The portal renders your logo, colors, business name, and support details from the Dashboard's branding and public settings. A portal with a missing logo and "example.com" support info works fine technically and looks like a phishing page to your first paying customer.

Do one real-money rehearsal. Subscribe with a real card on the cheapest plan, open the portal from your production app, update the card, cancel at period end, reactivate, then refund yourself. Ten minutes, a few cents in Stripe fees, and you've exercised the entire loop — session creation, every portal action, and the webhook deliveries that follow — against the exact configuration your customers will hit.

None of this is difficult; all of it is skippable in the excitement of shipping, which is why it makes the list. The portal is infrastructure customers only touch when money is involved — precisely the moment you don't want a config mismatch.

Frequently asked questions

Do I need the customer portal if I don't have subscriptions?

It's less compelling but still useful: the portal handles invoice history and payment method management for invoicing-based businesses, and Stripe's docs note you can skip the product catalog entirely for invoicing-only use. For pure one-time purchases with no stored cards, you likely don't need it.

Why am I getting "customer portal not enabled" errors?

You configured the portal in a sandbox but not in live mode (or vice versa). Configurations are per-mode and don't copy over. Open the portal settings with View test data off and save a live configuration.

Can customers switch between monthly and annual billing in the portal?

Yes — add both prices to the portal's product catalog and customers can switch, with Stripe handling proration. Decide the proration behavior consciously; the math surprises people, and I walked through it in subscription billing math.

How do I test the portal before going live?

In a sandbox, open any customer in the Dashboard, click Actions → Open customer portal to drive it as that customer. The settings page also has a read-only Preview. Pair either with the Stripe CLI forwarding webhooks to localhost so you see the events your changes fire.

Does the portal work with Stripe Checkout?

They're designed as a pair: Checkout creates the customer and subscription, the portal manages the rest of the lifecycle. Store the customer ID from the checkout.session.completed event and you have everything the portal session needs.

Can I customize the portal's appearance?

Branding — logo, icon, and colors — comes from your Dashboard branding settings, and the portal renders co-branded with your look. You can't restructure its layout or inject custom components; if you need that level of control, you're in custom-billing-UI territory.

Conclusion

The Stripe customer portal is the rare integration where the right way is also the lazy way: configure cancellation behavior and your product catalog in the Dashboard, write one authenticated route that creates a portal session and redirects, and let your existing webhook handler absorb the state changes. An hour of work replaces the billing screens you'd least enjoy building and maintaining. Configure cancellations to happen at period end, turn on cancellation reasons, and wire dunning emails to deep links — those three choices are most of the difference between a portal that quietly works and one that generates support tickets.

The Coding Capybaras boilerplate ships with this exact integration already wired — portal route, webhook sync, and the billing settings page — free, so you can see the full pattern in a working codebase before writing your own.