By Jake Mcdonald, founder of ShipEmber · November 3, 2026 · About a 9-minute read · Checked against Stripe's customer portal docs
I'm Jake Mcdonald. The first SaaS I wired up, I spent most of a weekend building a "Manage subscription" page: a cancel button, an "are you sure?" modal, a card update form, a plan switcher, and an invoice list. Then I found out Stripe already hosts all of that for you. It's called the customer portal, and for most indie SaaS apps it's the right call.
This post covers how I set it up in a Next.js App Router app: what to switch on in the Stripe Dashboard, the small route that creates a portal session, deep links for "Cancel" and "Change plan" buttons, and the webhooks you still need so your database stays in sync. Everything here was checked against Stripe's customer portal docs on November 3, 2026.
What the portal does for you
The customer portal is a Stripe-hosted, co-branded page. You send a signed-in user there, they manage their billing, and they click back to your app. Depending on how you configure it, they can:
- Cancel their subscription (and optionally tell you why)
- Switch plans (upgrade or downgrade between prices you choose)
- Update their payment method
- Update billing details like name, email, address, and tax ID
- See and download their invoice history
What it doesn't do: it doesn't change anything in your database. It updates the subscription in Stripe and Stripe tells you about it through webhooks. That part is still on you, and it's where most bugs show up.
Step 1: Configure the portal in the Stripe Dashboard
Open the customer portal settings in the Stripe Dashboard. The defaults are a reasonable start, but here's what I look at (defaults per Stripe's configure the portal page):
- Cancel subscription: on by default. You also choose whether cancel happens right away or at the end of the billing period. For a Free/Pro SaaS I pick end of period, because people paid for the month.
- Cancellation reason: on by default. Leave it on. It's free customer research.
- Switch plan: off by default. If you have more than one paid price (monthly and yearly, or Pro and Team), turn this on and add those products to the portal's product catalog. Stripe requires the catalog if you allow upgrades or downgrades.
- Prorations: off by default. Decide on purpose instead of finding out from a confused customer email.
- Payment methods: on by default. Keep it on. This is what saves you from failed-card churn.
- Invoice history: visible by default.
- Default redirect link and terms of service link: set these to your dashboard and your terms page.
Then go to the branding settings and upload your logo and colors so the portal doesn't feel like a different product.
Gotcha #1: Stripe keeps separate portal configurations for live mode and each sandbox. Changing one doesn't change the other. I've shipped with a perfect test configuration and a default live one more than once. When you go live, configure it again in live mode and add your live webhook endpoint.
Step 2: The portal session route
A portal session is a short-lived URL for one customer. You create it on the server, after you've checked who the user is, then redirect them. Stripe's docs suggest a form POST, which works well with an App Router route handler:
// app/api/billing/portal/route.ts import { NextResponse } from "next/server" import { auth } from "@/auth" import { stripe } from "@/lib/stripe" import { db } from "@/lib/db" export async function POST() { const session = await auth() if (!session?.user?.id) { return NextResponse.redirect(`${process.env.APP_URL}/login`, 303) } const user = await db.user.findUniqueOrThrow({ where: { id: session.user.id }, }) if (!user.stripeCustomerId) { // Never bought anything: send them to pricing instead return NextResponse.redirect(`${process.env.APP_URL}/pricing`, 303) } const portal = await stripe.billingPortal.sessions.create({ customer: user.stripeCustomerId, return_url: `${process.env.APP_URL}/dashboard`, }) return NextResponse.redirect(portal.url, 303) } And the button in your dashboard:
<form method="POST" action="/api/billing/portal"> <button type="submit">Manage billing</button> </form> A few things that matter here:
- Always look up the customer ID from your own database using the signed-in user's ID. Never accept a
customerID from the request body. Otherwise anyone could open someone else's billing page. - Create a new session on every click. Portal URLs are short-lived, so don't store them or email them.
-
return_urlis where the "Return to your app" link goes. Stripe requires it unless you've set a default redirect link in the Dashboard. - I use a 303 redirect so the browser follows the POST with a GET.
Step 3: Deep links for "Cancel" and "Change plan" buttons
Sometimes you want a specific button, like "Upgrade to yearly" or "Cancel plan," instead of the whole portal. Stripe calls these portal flows. You pass flow_data when you create the session, and the customer lands right on that one action with the rest of the portal navigation hidden.
The flow types right now are payment_method_update, subscription_cancel, subscription_update, subscription_update_confirm, and customer_update. Here's a cancel flow that sends the user back to a page of yours when they're done:
const portal = await stripe.billingPortal.sessions.create({ customer: user.stripeCustomerId, return_url: `${process.env.APP_URL}/dashboard`, flow_data: { type: "subscription_cancel", subscription_cancel: { subscription: user.stripeSubscriptionId }, after_completion: { type: "redirect", redirect: { return_url: `${process.env.APP_URL}/dashboard?canceled=1` }, }, }, }) The top-level return_url is the "never mind" link. The after_completion redirect is where they go after they actually finish. That's a nice place for a short "Sorry to see you go, here's how to export your data" message.
payment_method_update is the one I'd add first. If a renewal fails, link the "Update your card" banner in your app straight to that flow. It's one click for the user and it saves revenue you'd otherwise lose.
Step 4: The webhooks that keep your app honest
This is the part people skip. The portal changes the subscription in Stripe and your app finds out from webhooks. Here's the list I care about, from Stripe's portal integration guide:
-
customer.subscription.updated: upgrades, downgrades, quantity changes, scheduled cancellations, and reactivations. Checksubscription.items.data[0].priceto see which price they're on now. -
customer.subscription.deleted: the subscription actually ended. Revoke Pro. -
customer.updated: billing info changed, including the default payment method. -
payment_method.attached/payment_method.detached: cards added or removed.
Here's the plan-sync part of a webhook handler. It assumes you already verify the signature on the raw body and skip duplicate event IDs (I go through that in my Stripe subscriptions guide):
case "customer.subscription.updated": case "customer.subscription.deleted": { const sub = event.data.object as Stripe.Subscription const priceId = sub.items.data[0]?.price.id const isActive = sub.status === "active" || sub.status === "trialing" await db.user.update({ where: { stripeCustomerId: sub.customer as string }, data: { plan: isActive && priceId === process.env.STRIPE_PRICE_PRO ? "pro" : "free", // "Your plan ends on..." banner cancelsAt: sub.cancel_at ? new Date(sub.cancel_at * 1000) : null, }, }) break } Gotcha #2: canceling isn't the same as canceled. If you set cancellations to happen at period end, clicking "Cancel" in the portal does not send customer.subscription.deleted right away. You get customer.subscription.updated now, and deleted when the period actually ends. If you revoke access on the first event, you've taken away something the customer already paid for. Stripe's docs say to check cancel_at_period_end on classic billing mode subscriptions, and cancel_at on the newer flexible billing mode. That's why I store a date and show a banner instead of flipping the plan early.
Gotcha #3: people change their minds. If they reactivate before the period ends, you get another customer.subscription.updated, with cancel_at back to null (or cancel_at_period_end back to false). If you only handle the cancel direction, your "Your plan ends Nov 30" banner stays up forever.
Gotcha #4: trials end on plan switch. Stripe notes that if a customer upgrades or downgrades in the portal during a trial, the trial ends right away when they switch to the new price. If you offer trials, mention that in your UI so it isn't a surprise.
Gotcha #5: billing email isn't login email. Customers can change their email in the portal. Stripe's docs say to treat customer.updated as billing information only and not to use the billing email as a login credential. Key everything on your own user ID and the Stripe customer ID.
Step 5: Test it before real customers do
My pre-launch run through, all in a sandbox first:
- In the Stripe Dashboard, open a test customer, click Actions → Open customer portal, and click around. Stripe also has a read-only Preview button in portal settings.
- Run
stripe listen --forward-to localhost:3000/api/stripe/webhookand watch the events while you test. - Subscribe with a test card, open the portal from your app, and cancel. Confirm you still have Pro and the "ends on" banner shows up.
- Reactivate in the portal. Confirm the banner goes away.
- Switch plans (if enabled). Confirm the price ID in your database changed.
- Use Stripe's test clocks or cancel immediately from the Dashboard to trigger
customer.subscription.deleted. Confirm you're back on Free. - Do all of it again in live mode with a real card and refund yourself. Live config and live webhook secrets are separate. That's the one people forget.
Why choose ShipEmber
You can build everything above in an afternoon or two, and if you want to own every line, this post should get you there. I built ShipEmber because I was tired of rebuilding this same loop for every project.
ShipEmber is a Next.js App Router starter that already has it working: Auth.js sign-in (Google, GitHub, magic links), Stripe Checkout, a customer portal route, signed and idempotent webhooks that keep Free/Pro in sync, Prisma with Postgres, and a dashboard gated on the server. You rename it, add your Stripe keys, and spend the weekend on the thing your customers actually pay for.
To be honest about where it doesn't fit: if you need multi-tenant teams, organizations, and per-seat billing from day one, a bigger kit will be a better fit. ShipEmber is deliberately small, built for one product with a Free plan and a Pro plan.
It's a one-time payment: $49 for launch week, which ends Wednesday, Nov 4. It goes to $79 on Thursday, Nov 5. You can use it in unlimited personal and client projects, and you get 14 days to ask for a refund if it doesn't work as described.
Prefer watching over reading? There's a short walkthrough:
Watch the ShipEmber walkthrough on YouTube
See pricing or Get ShipEmber, $49.
Related: Next.js + Stripe + Auth.js setup checklist · How to add Stripe subscriptions to a Next.js App Router app · Free vs paid Next.js boilerplates
— Jake Mcdonald, Springfield, IL
Originally published on shipember.com.
