Skip to content
Tech Stack8 July 2026 · 7 min readUpdated 30 September 2026

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

Stripe's customer portal replaces the billing UI, not your webhook handler or your session mapping. How to create a portal session from Auth.js in Next.js, and the trial and cookie pitfalls to check.

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

If your Next.js app already knows who is signed in through Auth.js and you now need a billing page, start with one question: how does a signed-in user ID become a Stripe customer ID, and what happens when they come back from Stripe? Most tutorials show a "Manage billing" button first. The stripe customer portal handles the UI, but the mapping and the redirect back are still yours, and that's where the surprises live.

Why do most SaaS teams reach for the Stripe customer portal first?

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

Because Stripe's customer portal gives customers a hosted page to update payment methods, cancel subscriptions, download invoices and update billing details, without you building any of those screens.

Stripe's documentation lists the portal's features. Rolling a custom billing UI means building payment-method forms, invoice tables, plan-change previews and proration handling, then keeping all of it in sync with Stripe's webhooks. The portal skips the UI layer. You create a portal session on your server with the customer's ID and a return_url, and Stripe returns a short-lived URL that you redirect the browser to. Stripe documents that new portal sessions expire after five minutes if unused, so create the session at click time, not at page load. It's also worth knowing you can't display the portal inside an iframe, so plan for a full-page redirect.

For a first version of a billing settings page, that trade is usually the right one. It's an opinion, not a rule: the portal has real limits, and the next section is about when they should push you toward your own screens. The broader billing work still involves what a full Stripe integration covers beyond this one page.

Stripe customer portal vs custom billing UI: which wins for a B2B SaaS?

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

For most B2B SaaS teams the portal is the better starting point, and usage-based pricing or a strict need for custom screens are the reasons to build your own.

FactorStripe customer portalCustom UI (Elements plus your own screens)
Effort to shipYou configure it in the Dashboard and add one redirectYou build and maintain every screen
Usage-based subscriptionsCustomers can cancel but can't update themFull control, but you build the usage display
BrandingLogo, icon, colors, font, shapes, headline and terms linkFull
Webhook dependencyStill required to keep your records in syncStill required, with more surface area
Plan switchingUp to 10 products to choose fromWhatever you design

The row people underrate is the webhook dependency. Picking the portal doesn't remove the need for a Stripe webhook handler. Stripe's integration guide tells you to listen for customer.subscription.updated, customer.subscription.deleted and customer.updated so that your database reflects what customers did in the portal. The portal removes the UI on top of the billing state, not the state itself.

The stakes are real. Stripe's 2023 survey of 1,500 subscription business leaders found that 41% reported an increase in involuntary churn, and 32% of those weren't using tools that can automatically help reduce it. That figure is about failed payments in general, not about the portal, but it's a reason to make sure a webhook-synced customer record exists before you build anything on top of it.

Does changing plans in the portal end a trial?

Yes, by default. Stripe documents that when a customer uses the portal to upgrade or downgrade a subscription that has a trial, the trial ends immediately when the new price takes effect, and modifications to a trialing subscription create an invoice for immediate payment.

That's the easiest surprise to create for trial users. The portal configuration has a trial_update_behavior setting, and setting it to continue_trial keeps the trial active, according to Stripe's customer portal limitations. Decide on purpose which behavior you want before you expose plan switching to people who are still on a trial.

How do you wire a Stripe customer portal session to Auth.js in Next.js?

You look up the signed-in user's Stripe customer ID in your own database, create a portal session on the server, and redirect the browser to the session URL.

Stripe doesn't know which auth library you run, and Auth.js doesn't know about Stripe customers. The mapping between the two lives in your own database. Stripe's own guide says to authenticate customers on your site before creating sessions for them.

  1. Get the session. With Auth.js v5, wrap your route handler with auth from your Auth.js config, and read req.auth to confirm the request is signed in. In a server component you can await auth() instead. The Auth.js docs show both forms.
  2. Look up the Stripe customer ID. Use the user ID from the session to read a stripe_customer_id from your own users or subscriptions table. Depending on your setup, you may need a session callback to expose your database user ID.
  3. Create the portal session on the server. Call stripe.billingPortal.sessions.create with the customer and a return_url. It needs your secret key, so never do it in a client component.
  4. Redirect the browser to the returned url.
  5. Re-check the session on return. When the customer comes back to your return_url, validate the Auth.js session again before rendering anything billing-related.

A minimal shape looks like this:

export const POST = auth(async (req) => {
  if (!req.auth?.user) {
    return Response.json({ error: "Not authenticated" }, { status: 401 });
  }
  const customerId = await getStripeCustomerId(req.auth.user.id);
  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: "https://example.com/account",
  });
  return Response.redirect(session.url, 303);
});

getStripeCustomerId is your own function. I haven't wired this exact Auth.js route in a shipped product: BookBed and Callidus use Stripe, but neither case study describes an Auth.js setup, so the code above comes from the documentation, not from a production log.

One rule from Stripe's guide deserves its own line in your code review. When customers change billing details in the portal, Stripe sends customer.updated, and the guide says to treat those updates as billing information changes only. It explicitly warns you not to use the customer billing email address as a login credential. A customer can type a different billing email into the portal, so if your Auth.js account lookup ever matches on that address, you've handed a billing form the power to decide who is signed in. Keep the Auth.js user ID as the identity and the Stripe customer ID as an attribute of it.

Why can the redirect back from Stripe make a signed-in user look signed out?

If your session cookie is set with SameSite=Strict, the browser doesn't send it on a request that arrives from another site, such as the redirect back from Stripe. MDN's Set-Cookie reference says Strict sends the cookie only for same-site requests, while Lax also sends it on top-level navigations such as link clicks.

So the first server-rendered request after the redirect can arrive without the session cookie and look signed out, while a refresh works because it's a same-site request. That's my reading of the documented cookie rules, and it matches the symptom of a page that fails once and then recovers. The fix is SameSite=Lax on the session cookie. Auth.js's own default cookie options use sameSite: "lax" for the session token, so you'd hit this mainly if you overrode the cookie settings or run your own session cookie next to Auth.js. If you see the symptom, check the Set-Cookie header in your browser's network panel before you look anywhere near Stripe.

Where does the portal fall short for usage-based billing?

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

It lets customers cancel a usage-based subscription but not update it. Stripe's portal limitations list usage-based billing, multiple-product subscriptions, subscriptions collected by invoice and unsupported payment methods as cases where the customer can cancel but can't update.

If any part of your pricing is metered, you'll build the usage display yourself, and the portal keeps handling what stays static: card on file and invoice history. That gap isn't a bug in the portal so much as a sign that metered pricing needs its own screen.

Does Accounts v2 change the portal integration?

It can. Stripe's portal guide notes that the Accounts v2 API is generally available for Connect users and in public preview for others, and shows portal sessions created with a customer_account parameter instead of customer. Its guide also says that when you use the portal with Connect, you configure it for the platform rather than a connected account.

Callidus is a Stripe Connect Standard build with direct charges, per its case study, so those Connect notes are the ones I'd read first there. Check which object model your integration expects before you copy code from an older tutorial.

What does Auth.js give you that a hand-rolled session layer doesn't?

Auth.js gives you one stable auth() call that reads a signed session on every request, so the check guarding your portal route is the same one guarding every other protected route. It does nothing for billing, and it shouldn't.

Swap in Firebase Auth or Supabase Auth and the billing shape stays the same: Stripe writes the truth through webhooks, your database reflects it, and your session layer decides who may read it. If you're already on a Supabase and Stripe stack, the route looks nearly identical with Supabase's user lookup instead of auth(). The auth library is a small part of the SaaS MVP stack you've chosen. The webhook handler underneath it is what keeps the portal honest.

Open your Auth.js config and look at the session cookie options. Is sameSite set to lax, or did someone override it? Then wire the portal route before you touch any other billing screen.

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 using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do I create a Stripe customer portal session in Next.js?

Create the portal session on the server with the signed-in user's Stripe customer ID, then redirect the browser to the URL Stripe returns. In an Auth.js app, read the session first, look up the stripe_customer_id from your own database, and call stripe.billingPortal.sessions.create with the customer and a return_url. Stripe's integration guide says to authenticate customers on your site before creating sessions for them, and it describes the returned URL as short-lived. New portal sessions expire after five minutes if unused, so create one when the user clicks.

Can the Stripe customer portal cancel a subscription?

Yes, customers can cancel subscriptions from the Stripe customer portal, immediately or at the end of the current billing period, depending on how you configure it. Stripe's portal documentation lists cancellation alongside updating payment methods, billing details and viewing invoices. You can also collect cancellation reasons and offer retention coupons. Because cancellations happen outside your app, listen for customer.subscription.updated and customer.subscription.deleted webhooks so your own database and access checks reflect the change.

Does changing plans in the Stripe customer portal end a free trial?

By default, yes: customer changes to a trialing subscription in the portal end the free trial and create an invoice for immediate payment. Stripe's documentation says you can keep the trial active by setting features.subscription_update.trial_update_behavior to continue_trial when you configure the portal. Decide which behavior you want before you allow plan switching for customers who are still on a trial, because the default can surprise people who expected the trial to run to its end date.

Does the Stripe customer portal support usage-based billing?

Only partly: customers can cancel a usage-based subscription in the portal, but they can't update it. Stripe's portal limitations also list subscriptions with multiple products, subscriptions collected by invoice and unsupported payment methods as cancel-only cases. If any part of your pricing is metered, expect to build your own usage display, while the portal keeps handling the static parts such as payment methods and invoice history.

How does Auth.js work with Stripe customer IDs?

Auth.js has no built-in concept of a Stripe customer, so you store the mapping yourself, usually as a stripe_customer_id column linked to your user record. Auth.js gives you the signed-in session on each request, and your database turns that user ID into the customer ID a portal or checkout call needs. Depending on your setup you may need a session callback to expose your database user ID. Keep the Auth.js user ID as the identity and treat the Stripe customer ID as an attribute of it.