Switch plan
Move within the same product family: Pro monthly to Pro yearly, or Pro up to Team. Stripe handles the billing change.
Redirect paying customers into Stripe's hosted Customer Portal to switch plans, update cards, fetch invoices, and cancel, all without building a single billing screen yourself.
Checkout turned a visitor into a paying customer. That customer keeps living in your product, and a few weeks in they want to change something. They started on a monthly plan, realized yearly is cheaper, and want to switch. Their bank reissued their card, so the next charge is going to bounce and they need to update it. Their finance person needs last month’s invoice for the books. Or they’re leaving and want to cancel.
Count what each of those would cost you to build. Switching plans is a screen with a plan picker, proration math, and a confirmation step. Updating a card is a PCI-sensitive form with tokenization and validation. Invoices are a list, a renderer, and a PDF. Cancellation is a flow with a confirmation, a “you’ll keep access until…” message, and the billing change behind it. Each one has a happy path and a long tail of edge cases, and each one has to stay correct as tax rules, card networks, and your own pricing change underneath it. Stripe has already built every one of these, keeps them current, and will hand them to you for the cost of a single redirect.
This lesson is about the screens your application doesn’t build. You’ll write one short Server Action, billing.openPortal(), that sends a customer into Stripe’s hosted Customer Portal and brings them back. You’ll see what the Portal can do, the configuration it reads, and three rules that separate an integration that holds up from one that generates support tickets and corrupts your data: cancel at the period end, never compute proration, and never trust the return URL. You’ll finish with the one judgment call that matters here: when you’re allowed to leave the Portal behind and build the screen yourself. Checkout got the money in; this lesson covers how it’s managed after.
Here is the whole integration. You create a portal session with the customer’s id and a URL to return to, and Stripe hands you a link:
const session = await stripe.billingPortal.sessions.create({ customer: org.stripeCustomerId, return_url: absoluteUrl('/settings/billing'),});You redirect the customer to session.url. They land on a Stripe-hosted page where they can switch plans, fix their card, grab invoices, or cancel. When they’re done, Stripe sends them back to your return_url. Your application wrote zero billing UI: no plan picker, no card form, no invoice renderer. The Customer Portal is the closest thing to a free, maintained, compliant settings screen in the entire SaaS toolchain, and defaulting to it is the correct engineering call rather than a shortcut you’ll have to apologize for later.
The shape is a round-trip. It’s worth fixing in your mind before any of the detail, because two of this lesson’s three hard rules live at its two ends.
return_url What Stripe handed you is a portal session : a one-shot URL, scoped to exactly one Customer, that opens the billing screens and then expires. You don’t store it and you don’t reuse it; you mint a new one every time someone clicks “Manage billing.” Now let’s see what those screens actually let a customer do.
The Portal isn’t all-or-nothing. You choose which capabilities it exposes, configured once per account in the Stripe dashboard. Versioning that configuration in code is its own short topic at the end of this lesson; for the chapter, we click it in once. Here is the set this course turns on:
Switch plan
Move within the same product family: Pro monthly to Pro yearly, or Pro up to Team. Stripe handles the billing change.
Cancel
End the subscription. Configured to take effect at the end of the period, the rule we cover next.
Update payment method
Replace an expired or declined card. A PCI-sensitive form, hosted entirely by Stripe.
Invoice history
View and download past invoices and receipts: the finance-person request, self-served.
Four screens you didn’t build, didn’t validate, and don’t maintain. That’s the trade the Portal offers, and for these four it’s almost always the right one to take.
Notice where the line falls, because it matters for the rest of the chapter. The Portal shows Stripe-side billing facts: what plan you’re on, what card is on file, what you’ve been invoiced. It will not show how many API calls you’ve made this month, that you’re three seats over your plan limit, or anything else that lives inside your product. Those are your application’s own screens, and they read from your own data, not from Stripe. Keep that seam clean: billing facts are the Portal’s job, and product state is yours. A later lesson builds the in-app surface that warns a customer their subscription is winding down, which is product state the Portal has no concept of.
billing.openPortal actionNow the code. This is the chapter’s reference action for reaching the Portal, and the second method of the small billing.* interface you started with billing.upgrade last lesson. It’s deliberately small. Read it once top to bottom, then we’ll walk it.
'use server';
export const openPortal = async ( returnPath = '/settings/billing',): Promise<{ url: string }> => { const { orgId } = await requireOrgUser(); const org = await getOrganization(orgId);
if (!org.stripeCustomerId) { throw new BillingError('no_customer', 'Subscribe before managing billing.'); }
const session = await stripe.billingPortal.sessions.create({ customer: org.stripeCustomerId, return_url: absoluteUrl(returnPath), });
return { url: session.url };};The directive and the signature. File-level 'use server' makes this a Server Action, so the body runs only on the server, where the Stripe secret lives. Note what it returns: { url }, not a redirect() call. That’s the same shape as billing.upgrade from last lesson: the action mints the URL and hands it back, and the caller decides the navigation. returnPath is an optional parameter with a default, so most callers pass nothing.
'use server';
export const openPortal = async ( returnPath = '/settings/billing',): Promise<{ url: string }> => { const { orgId } = await requireOrgUser(); const org = await getOrganization(orgId);
if (!org.stripeCustomerId) { throw new BillingError('no_customer', 'Subscribe before managing billing.'); }
const session = await stripe.billingPortal.sessions.create({ customer: org.stripeCustomerId, return_url: absoluteUrl(returnPath), });
return { url: session.url };};Resolve and authorize the org. requireOrgUser() is the same server-side check from the organizations work: it returns the trusted { user, orgId, role } from the session and throws to the framework boundary if there’s no session or no org. getOrganization(orgId), a helper the project ships, then loads the organization row, from which you read stripeCustomerId. A portal session is scoped to one Customer, so you authenticate and org-scope before you mint anything.
'use server';
export const openPortal = async ( returnPath = '/settings/billing',): Promise<{ url: string }> => { const { orgId } = await requireOrgUser(); const org = await getOrganization(orgId);
if (!org.stripeCustomerId) { throw new BillingError('no_customer', 'Subscribe before managing billing.'); }
const session = await stripe.billingPortal.sessions.create({ customer: org.stripeCustomerId, return_url: absoluteUrl(returnPath), });
return { url: session.url };};The no-Customer branch, which is the interesting one. Last lesson’s upgrade created a Customer lazily if one was missing. This action does the opposite: if stripeCustomerId is null, it throws. The logic is tight: no stripeCustomerId means no Customer, which means no subscription, which means there is nothing to manage. BillingError is a small custom Error subclass carrying a machine-readable code; a later lesson gives it its full definition.
'use server';
export const openPortal = async ( returnPath = '/settings/billing',): Promise<{ url: string }> => { const { orgId } = await requireOrgUser(); const org = await getOrganization(orgId);
if (!org.stripeCustomerId) { throw new BillingError('no_customer', 'Subscribe before managing billing.'); }
const session = await stripe.billingPortal.sessions.create({ customer: org.stripeCustomerId, return_url: absoluteUrl(returnPath), });
return { url: session.url };};The actual Stripe call, and it’s two fields. customer says whose billing this is, and return_url says where to send the browser when they’re done. That URL must be absolute, because Stripe is going to redirect a real browser to it and a relative path won’t do. The absoluteUrl helper turns /settings/billing into the full https://… form.
'use server';
export const openPortal = async ( returnPath = '/settings/billing',): Promise<{ url: string }> => { const { orgId } = await requireOrgUser(); const org = await getOrganization(orgId);
if (!org.stripeCustomerId) { throw new BillingError('no_customer', 'Subscribe before managing billing.'); }
const session = await stripe.billingPortal.sessions.create({ customer: org.stripeCustomerId, return_url: absoluteUrl(returnPath), });
return { url: session.url };};Return { url }. From here the client takes over and redirects with window.location.assign(url). The customer leaves your app, manages their billing on Stripe’s pages, and lands back at return_url.
This lives at lib/billing/portal.ts, alongside last lesson’s upgrade. Both files share one non-negotiable rule: the stripe client is only ever imported inside /lib/billing/. That directory is the single place Stripe shapes are allowed to touch your codebase, which is why the file starts with import 'server-only', so a stray import becomes a build error instead of a leaked secret, and import { stripe } from '@/lib/stripe'. The reason for wrapping Stripe at all, the architecture of that billing.* interface, is a later lesson. For now it’s enough that the import lives here and nowhere else.
One property of that returned URL deserves its own warning.
That bearer-style property is why you authorize before minting in step 2: the only access check the Portal ever gets is the one your action does up front. Get that wrong and you’ve handed one customer’s billing to another.
Of everything a customer can do in the Portal, cancellation is the one most likely to cause trouble if it’s misconfigured, so it gets its own section. The rule is short: never cancel immediately by default, because the customer paid for the month.
When a customer clicks Cancel in the Portal, the configured behavior is cancel at period end. The subscription doesn’t vanish. It stays active, gains a cancel_at_period_end: true flag, and keeps a current_period_end date in the future. Access stays fully live until that date. When the period boundary arrives, Stripe ends the subscription. The customer keeps exactly what they already paid for, and not a day more or less.
Get this wrong, by cancelling the moment someone clicks the button mid-cycle, and you’ve taken away access they paid for. That’s a fair-billing problem and a reliable source of angry support tickets. The Portal’s cancel at period end mode exists precisely so you don’t have to think about it: configure it once and the right thing happens.
Here’s the part that shapes how the rest of the chapter models cancellation. A single cancel produces two webhook events, separated in time, one now and one later:
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 18px !important; }'} }%%
sequenceDiagram
participant C as Customer
participant S as Stripe
participant W as Webhook → plan_entitlements
C->>S: clicks Cancel in the Portal
Note over S: sets cancel_at_period_end: true,<br/>status stays active
S->>W: customer.subscription.updated (fires now)
Note over W: record the winding-down state
Note over C,W: … period elapses …
S->>W: customer.subscription.deleted (at current_period_end)
Note over W: flip to no access Hold on to that two-event shape, because it explains a modeling decision a later lesson makes. A single is_canceled boolean can’t represent “canceled but still active until August 14th.” It throws away the when. That’s why the entitlement projection a later lesson builds keeps cancel_at_period_end and current_period_end as first-class columns rather than collapsing them into one flag. The two-event sequence is the reason the richer shape is necessary.
Now the scope boundary, stated plainly so the seam stays clean. This section covers only the Portal configuration (cancel at period end) and the events it emits. The banner that tells a customer “your plan ends on Aug 14,” the undo-and-reactivate link, and the actual decision to cut off access are all built later, and they consume these events. The rule from the previous chapter still holds without exception: the webhook is the only writer for the entitlement. The Portal triggers events; it does not reach into your database. The cancel_at_period_end flag is the primitive that carries this, and reactivation is just setting it back to false.
The other thing customers do in the Portal is switch plans: Pro up to Team, or monthly across to yearly. When they do, Stripe generates a customer.subscription.updated event carrying the new Price, and it computes the money for you. Switch mid-cycle and Stripe issues a credit for the unused part of the old price and an immediate charge for the new one (for an upgrade; a downgrade typically credits forward to the next invoice). That calculation is proration , and the rule about it is one line: trust Stripe’s proration; never recompute it in your app.
It’s worth being clear about why. Proration done correctly is mid-cycle, tax-inclusive, and multi-currency, a genuinely deep well of edge cases. Reimplementing it buys you nothing and exposes you to a class of bugs that show up as wrong charges on real customers’ cards. Your application’s job is not to predict the invoice. Its job is to read the result, the new plan, after the change lands.
There’s one thread from the first lesson of this chapter that pays off here. When the plan-change event arrives, the projection maps the new Stripe Price back to your app’s plan slug (pro, team) through its lookup_key. Because that mapping is by stable key and not by a mode-specific Price id, a customer switching plans in the Portal needs no code change on your side, even after you re-seed the catalog. The mechanics of that mapping are a later lesson; the point now is that a Portal-driven plan change just works.
By default, openPortal() drops the customer on the Portal’s home screen, and from there they navigate to whatever they came for. That’s the right baseline. But sometimes you know exactly where they’re headed: a button that says “Cancel subscription” shouldn’t land them on the home screen to go hunting for the cancel option. For that, you add flow_data to the session and deep-link straight to one flow:
const session = await stripe.billingPortal.sessions.create({ customer: org.stripeCustomerId, return_url: absoluteUrl(returnPath), flow_data: { type: 'subscription_cancel', subscription_cancel: { subscription: org.subscriptionId }, },});The course cares about three flow_data flows: subscription_cancel, subscription_update, and payment_method_update. The pattern is the same for all three: name the flow type, then point it at the right object. The cancel one above is enough to see the shape.
One rule comes attached to this, and it’s about honesty rather than code: name the destination in the link text. If a button deep-links to cancellation, it reads “Cancel subscription,” not “Manage billing.” Dropping a customer onto a cancel-confirmation screen they didn’t ask for is a dark pattern and a trust cost you don’t want to pay. Deep-links are an enhancement layered on top of the plain openPortal(); the Portal-home default is the baseline this chapter ships, and it’s a perfectly good one.
Two situations trip people up, and they’re worth grouping because they turn on the same fact: in Stripe, the Customer outlives the subscription.
The first is the ex-subscriber. Someone paid for months, then downgraded to your free plan. They have no active subscription anymore, but they still have invoices from when they did, and their finance person will eventually need them. The Stripe Customer record persists even with no active subscription, so openPortal() still works for them and the Portal still shows their full invoice history. The rule: the Customer outlives any individual subscription, and the Portal stays reachable for as long as the Customer exists.
The second is the never-subscribed user, the mirror image, which you already handled. No stripeCustomerId means no Customer, which is exactly the BillingError branch in the action. For this person the right move is not the Portal at all; it’s Checkout. Surface a “subscribe to manage billing” message and route them to the upgrade flow from last lesson. The Portal is for existing Customers, and brand-new subscriptions start at Checkout.
That gives you a clean two-way split for any “open billing” button:
%%{init: {'themeCSS': '.nodeLabel, .nodeLabel * { font-size: 18px !important; } .edgeLabel, .edgeLabel * { font-size: 16px !important; }'} }%%
flowchart LR
A([Open billing]) --> B{stripeCustomerId<br/>present?}
B -->|Yes| C["openPortal()"] --> D([Portal])
C@{ shape: rect }
B -->|No| E["upgrade()"] --> F([Checkout])
E@{ shape: rect } Let’s check that those rules stuck while they’re fresh. Fill in each blank:
Each blank pins one rule from this lesson — entry-point routing, period-end cancellation, and who writes the entitlement. Pick the right option from each dropdown, then press Check.
A user who has never paid hits the billing page, so you route them to — not the surface for managing an existing subscription.
A customer mid-cycle clicks Cancel, so the subscription’s cancel_at_period_end becomes and access .
After the Portal redirects the customer back, the app updates plan_entitlements from — the only writer for that row.
That last blank carries the central idea of the lesson, and it earns its own section.
When the customer finishes in the Portal, Stripe redirects them to your return_url. The trap is to read that redirect as news, to think “they’re back, so something must have changed, let me refresh their entitlement.” Nothing has necessarily changed. The return_url fires regardless of what the customer did. They might have switched plans. They might have browsed around and left without touching anything. They might have changed their mind halfway and closed the cancel flow. The redirect proves exactly one thing: the customer came back. It proves nothing about state.
Picture the developer who renders the billing page on return and, on render, calls something like refreshEntitlementFromStripe(org.id) to “sync.” That code has two ways to go wrong, and it’ll hit one of them:
plan_entitlements row, the exact single-writer violation the previous chapter spent a whole lesson teaching you to avoid.The fix isn’t a more careful refresh. It’s structural: do nothing stateful on return. The webhook is the source of truth, so let it do its job. If the return page genuinely needs to reflect a change the customer just made, it does the same thing last lesson’s success page did: it reads the entitlement and, if the projection isn’t finalized yet, mounts the poller from the previous chapter and waits for the webhook to catch up. It reads and polls. It never writes.
Put the wrong and right versions side by side:
export default async function BillingReturn() { const { orgId } = await requireOrgUser(); const org = await getOrganization(orgId); // fires on every return — even when nothing changed await refreshEntitlementFromStripe(org.id); return <BillingSettings />;}export default async function BillingReturn() { const { orgId } = await requireOrgUser(); const org = await getOrganization(orgId); const entitlement = await getEntitlement(org.id); const isFinalized = entitlement.status === 'active'; if (!isFinalized) return <FinalizePoller isFinalized={isFinalized} />; return <BillingSettings entitlement={entitlement} />;}Now connect this to last lesson and hold the pair together as one idea, not two facts to memorize:
Both collapse to a single principle: a redirect is a navigation event, never a transaction-completion signal. This is the same redirect-versus-webhook race from the previous chapter, met again on the other side of the flow. The single-writer rule it taught is the same rule that governs the return page here. The redirect tells you where the browser is, never what happened to your data.
The whole lesson has been one argument: default to the Portal. So let’s close by turning it from a rule into a decision you can defend, including the cases where the answer goes the other way, because sometimes it does. Here are the conditions that earn an in-house build:
Know exactly what you’re signing up for when you carve one of these out, and price it honestly: every screen you build is a Stripe-maintained, PCI-aware, localized, continuously-updated screen you now own forever. The cancellation logic, the proration display, the card tokenization, the invoice rendering, and the long tail of edge cases behind each, all become yours to keep correct as the world changes underneath them. The chapter default is the Portal. An in-house build is a deliberate, justified exception, never a default and never an aesthetic preference. “It’d feel more on-brand” is not a reason that survives contact with the maintenance bill.
So here’s the order of operations an experienced engineer follows: ship the Portal first, instrument it, and let real product and retention data, not taste, earn the in-house screen. That’s the same trigger-before-tool posture that runs through this whole chapter: you reach for the heavier tool only after the default has crossed a named threshold.
Walk the decision in that exact order. Each question is a gate, and only a real product or legal blocker should send you down the build path.
A legal or sales-gated flow is a real product requirement the Portal can’t meet. This is the carve-out the default was waiting for. You still keep the Portal for invoices and the card form; you only own the screen product forces.
Hand-roll just the part that genuinely needs custom controls. Even here you keep the Portal for the routine screens, invoices and card update, and let Stripe keep maintaining those forever.
If the locale is on Stripe’s roadmap, waiting is often cheaper than owning a localized billing surface forever. Build only if the language gap is blocking customers today.
No legal blocker, no custom UX, no missing language. There is no product reason to build. Ship the Portal and move on, and let real product and retention data, not taste, earn any future carve-out.
Notice that even the build outcomes keep the Portal for the screens nobody wants to own: invoices and the card form. An in-house carve-out is almost never “replace the Portal.” It’s “build the one screen product forces, and let Stripe keep the rest.”
One last point, kept short on purpose. Everything the Portal exposes, which capabilities are on and the cancel-at-period-end behavior, was configured by clicking around the Stripe dashboard. But Stripe also exposes those settings through the API, under stripe.billingPortal.configurations.*. That means you can version the Portal’s configuration in code and apply it per mode, exactly the way the first lesson described versioning the price catalog with a seed script. The payoff is the same: reviewable diffs and automatic parity between your test and live worlds, instead of two dashboards a human has to keep in sync.
This chapter ships dashboard-configured, for simplicity. The config-as-code path is named here only so you know it exists and know when to reach for it: when “who changed the cancel behavior?” needs an answer in version control, code wins.
The Portal moves fast, and the dashboard configuration changes more often than the API. When you wire this up for real, the source of truth is Stripe’s own docs.
The integration guide and configuration reference for the hosted billing portal.
The flow_data parameter and the cancel, update, and payment-method flows.
Cancel at period end, reactivation, and which events each path emits: the reference behind this lesson's cancellation rule.
How Stripe credits and charges across a mid-cycle plan change: the math your app reads but never recomputes.