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?

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?

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.
| Factor | Stripe customer portal | Custom UI (Elements plus your own screens) |
|---|---|---|
| Effort to ship | You configure it in the Dashboard and add one redirect | You build and maintain every screen |
| Usage-based subscriptions | Customers can cancel but can't update them | Full control, but you build the usage display |
| Branding | Logo, icon, colors, font, shapes, headline and terms link | Full |
| Webhook dependency | Still required to keep your records in sync | Still required, with more surface area |
| Plan switching | Up to 10 products to choose from | Whatever 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.
- Get the session. With Auth.js v5, wrap your route handler with
authfrom your Auth.js config, and readreq.authto confirm the request is signed in. In a server component you canawait auth()instead. The Auth.js docs show both forms. - Look up the Stripe customer ID. Use the user ID from the session to read a
stripe_customer_idfrom your own users or subscriptions table. Depending on your setup, you may need a session callback to expose your database user ID. - Create the portal session on the server. Call
stripe.billingPortal.sessions.createwith the customer and areturn_url. It needs your secret key, so never do it in a client component. - Redirect the browser to the returned
url. - 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?

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.
