Skip to content
Tech Stack8 July 2026 · 8 min read

Building a SaaS Customer Portal With Stripe + Auth.js in 2026

Stripe's hosted portal saves you a UI, not the webhook work underneath it. How to wire it to Auth.js in Next.js, and the cookie bug that silently logs users out after a redirect.

Building a SaaS Customer Portal With Stripe + Auth.js in 2026

Most Stripe-and-Auth.js tutorials solve the wrong problem first. They show a "Manage Billing" button before they show what happens when your app's idea of who is logged in disagrees with Stripe's idea of who is paying. A production SaaS customer portal is that handshake, and it breaks in specific, reproducible ways. I've shipped Stripe billing on two live products, BookBed and Callidus, neither one built on Auth.js. This is the Auth.js-specific version of the same pattern: session lookup, customer mapping, portal session, and the redirect back that quietly logs people out if you get one cookie attribute wrong.

Key Takeaways

A weathered brass skeleton key resting on a small stack of folded cream cards, a single cyan ribbon looped around its bow

  • Creating a portal session is one API call: POST to /v1/billing_portal/sessions with just customer and return_url.
  • Usage-based subscriptions are portal-restricted to cancel-only. Customers can't view metered usage or change plans inside Stripe's hosted UI.
  • Auth.js gives you the session. Stripe gives you the customer. The mapping between the two lives in your own database, not in either library.
  • A cookie set with SameSite=strict can silently drop your user's session the instant Stripe redirects back from the portal, and it will pass every manual test you run.
  • Any trial in progress ends immediately, not at renewal, the moment a customer changes plans through the portal.

Why Do Most SaaS Teams Reach for the Stripe Customer Portal First?

A single cyan door handle mounted flush on a bare concrete panel, casting a long diagonal shadow with no door or frame visible around it

Because it hands off subscription cancellation and payment-method updates to Stripe's own hosted page in one API call, with invoice history included automatically.

Rolling a custom billing UI means building payment-method forms, invoice tables, plan-change previews, and proration math yourself, which covers a fair chunk of what a full Stripe integration actually involves on the billing side alone, then keeping all of it in sync with whatever Stripe's webhooks report. The portal skips that layer entirely. You create a session server-side with stripe.billingPortal.sessions.create, passing the Stripe customer ID and a return_url, and Stripe hands back a short-lived URL you redirect the browser to (the full parameter contract lives in Stripe's own docs). For a v1 SaaS trying to ship a working billing settings page in a day instead of a sprint, that trade is usually the right one.

You've felt the alternative. Three weeks into a billing rebuild, still hand-rolling a plan-change preview with proration math you're not fully sure is right.

Stripe Customer Portal vs Custom Billing UI: Which Wins for a B2B SaaS?

Two folded paper airplanes side by side on a sunlit beige surface, one smooth and bright cyan, the other rough-textured and cast in grey concrete

For most B2B SaaS teams the portal wins by default, and only usage-based billing or heavy brand control should pull you toward a custom UI.

| Factor | Stripe Customer Portal | Custom UI (Elements + your own screens) | |---|---|---| | Build time | Hours | Days to weeks | | Usage-based billing | Cancel only, no usage display | Full control, but you build the dashboard | | Brand control | Logo, color, headline only | Full | | Webhook dependency | Still required for state sync | Still required, plus more surface area | | Maintenance | Stripe ships changes | You track every Stripe API change yourself |

The row people underrate is webhook dependency. Picking the portal doesn't remove the need for a Stripe webhook handler; Stripe writes the truth, and your handler still has to read it and update your database. What the portal removes is the UI layer sitting on top of that truth, not the truth itself. Get that wrong and the cost shows up as revenue, not bugs: Stripe's own 2023 survey of 1,500 subscription business leaders found 41% saw involuntary churn increase, and a third of those still weren't using any automated recovery tooling, which is exactly the kind of thing a webhook-synced customer record is supposed to catch.

The Trial-Ends-Immediately Trap

If a customer is mid-trial and changes plans inside the portal, the trial ends the moment they hit save, immediately, not at the next renewal. Stripe documents this explicitly, and it's the single most common support ticket you'll generate from exposing the plan-change flow to trial users without a warning modal in front of it. Gate that button, or budget for the tickets.

How Do You Wire the Portal Session to Auth.js in Next.js?

You look up the signed-in user's Stripe customer ID from your own database, then create a portal session server-side and redirect.

  1. Get the session. Call auth() from your Auth.js config inside a Route Handler to confirm the request is actually signed in.
  2. Look up the Stripe customer ID. Auth.js gives you a user ID; your own users or subscriptions table maps that ID to a stripe_customer_id. Stripe never sees your Auth.js session, so this mapping is the only bridge between the two.
  3. Create the portal session. Call stripe.billingPortal.sessions.create({ customer, return_url }) server-side, never in a client component, since it needs your secret key.
  4. Redirect the browser. Send the user to the returned url. It's single-use and expires quickly, so don't cache it or reuse it across requests.
  5. Handle the return. When Stripe sends the user back to return_url, re-validate the Auth.js session before rendering anything billing-related. The redirect itself proves nothing about who's signed in on this tab.

None of this is Auth.js-specific in the Stripe sense. Stripe has no idea which auth library you're running. What Auth.js buys you is a stable auth() call you can drop into any Route Handler without re-deriving session logic every time you add a new billing action.

The Redirect Back From Stripe Can Silently Log Your User Out

Wednesday afternoon, a user finishes updating their card in the portal, gets redirected back to the dashboard, and lands on a page that treats them as signed out. Support ticket, confused user, nothing useful in the logs.

The cause sat in one cookie attribute. When Stripe redirects back to your app, the browser treats it as a cross-site navigation, and a session cookie set with SameSite=strict gets dropped on exactly that hop. A long-running Next.js discussion thread traces this to the same root cause across dozens of reports: refreshing the page fixes it, because a same-origin request sends the cookie fine, which is exactly why it survives manual testing and only shows up once real users hit it. The fix is SameSite=lax on the session cookie, not anything on Stripe's side at all.

Actually, the first time I saw this reported I assumed it was a webhook timing issue. It wasn't. It's a browser default fighting a payment provider's redirect pattern, and Stripe is just the messenger standing at the door when the alarm goes off.

Where Does the Portal Fall Short for Usage-Based Billing?

It lets customers cancel a metered subscription but shows no usage data and blocks any plan change on it entirely.

Stripe's own customer-management docs are explicit about this: usage-based subscriptions can be cancelled from the portal, but customers can't upgrade, downgrade, or see what they've actually used this period. If your pricing has any metered component, API calls, fluctuating seats, storage tiers, you're building a usage dashboard yourself regardless of whether the portal handles the rest of billing. That gap isn't really a portal shortcoming so much as a sign that usage-based SaaS needs its own billing surface, with the portal handling only what stays static: card on file and invoice history.

One more thing worth checking before you build this today. Stripe's Accounts v2 API went GA for new Connect platforms in late 2025, and Stripe's own migration notes now recommend the v2 path for new Connect integrations instead of the older Accounts v1 plus Customers v1 split. Callidus runs on Stripe Connect, and Connect is exactly the case where this newer object model matters most: fewer duplicate identities, one KYC pass instead of two. If you're building anything Connect-shaped on top of this portal pattern, check which object model your integration actually expects before copying code from an older tutorial.

What Auth.js Actually Buys You Over Rolling Your Own Session Layer

Auth.js doesn't touch Stripe at all, and that's the point. It owns exactly one job: giving you a signed, verifiable session on every request, so the auth() call at the top of your portal-session Route Handler is the same one guarding every other protected route in the app. On BookBed I solved the equivalent problem with Firebase Auth instead, mapping the user ID to a Stripe customer ID in Firestore rather than Postgres, and the webhook-driven billing state that keeps that mapping honest is the same pattern regardless of which auth library sits underneath it: Stripe writes, your database reflects, your session layer reads. Swap Auth.js for Firebase Auth or Supabase Auth and the shape barely changes. If you're already committed to a Supabase-plus-Stripe stack, the portal-session route looks nearly identical, just with getUser() from Supabase instead of auth() from Auth.js.

None of this billing work happens in isolation from the rest of the SaaS MVP stack you've already picked. The auth library is a small piece. The webhook handler underneath it is the piece that actually keeps your customer portal honest, trial after trial, redirect after redirect.

Wire up the portal session route first, before you touch a single billing screen. Then go check your session cookie's SameSite attribute. Is it lax?

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps 2× faster than agencies using AI-augmented workflows. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do I add a Stripe customer portal to a Next.js app?

Create a portal session server-side with the signed-in user's Stripe customer ID, then redirect the browser to the URL Stripe returns. In an Auth.js app, that means calling `auth()` inside a Route Handler, then looking up `stripe_customer_id` from your own database before calling `stripe.billingPortal.sessions.create({ customer, return_url })` and redirecting. [Stripe's own API reference](https://docs.stripe.com/customer-management/integrate-customer-portal) covers every optional parameter, including a `configuration` ID if you're running more than one portal experience.

What is self-serve SaaS billing?

Self-serve SaaS billing lets customers manage their own subscription and payment details without emailing support or waiting on a sales rep. In practice this usually means either Stripe's hosted Customer Portal or a custom billing screen built on Stripe Elements, both reading from the same webhook-synced subscription state. Done well, a card update or a plan swap never needs to touch your support queue at all.

Should I use Stripe Elements or the Customer Portal?

Use the Customer Portal by default; reach for Stripe Elements plus a custom screen only for usage-based billing or full brand control. The portal ships faster because Stripe owns the UI and the compliance logic behind it, and it inherits new features automatically. Elements gives you a fully custom experience, embedded payment forms and in-app plan comparisons, but every screen becomes something you maintain and keep in sync with Stripe's webhooks yourself.

How does Auth.js work with Stripe customer IDs?

Auth.js has no built-in concept of a Stripe customer; you store the mapping yourself, usually a `stripe_customer_id` column on your users table, set once at signup or first checkout. Auth.js's `auth()` function gives you the session and user ID on every request, and your database is what turns that user ID into the Stripe customer ID a portal session or checkout call actually needs. Get this mapping wrong and you end up with duplicate customers or missing IDs, and billing actions fail quietly instead of loudly.

Does the Stripe customer portal support usage-based billing?

No, the portal only lets customers cancel a usage-based subscription; it doesn't show metered usage or allow plan changes on it. [Stripe's own customer-management docs confirm this restriction directly](https://docs.stripe.com/customer-management): usage-based plans are cancel-only inside the hosted portal. If your pricing includes any metered component, budget for a custom usage dashboard regardless of whether the rest of your billing runs through the portal.