Add Stripe Customer Portal to SaaS 2026
TL;DR
The Stripe Customer Portal is the fastest way to handle subscription management — upgrades, downgrades, cancellations, invoices, and payment method updates — without building any UI. Stripe hosts it, Stripe handles it, and you get webhook events when things change. Adding it to any SaaS boilerplate takes under an hour. The only catch: you need to configure it in your Stripe Dashboard first (5 minutes) and handle the webhooks that fire when users make changes.
Key Takeaways
- What Stripe Portal handles: plan upgrades/downgrades, cancellation, payment method update, invoice history
- What you build: one API route to create a portal session (< 20 lines)
- Configuration: set up in Stripe Dashboard, not in your code
- Webhooks required:
customer.subscription.updated,customer.subscription.deleted - Works with any boilerplate: ShipFast, T3 Stack, Makerkit, Supastarter — same code
- Time to add: 30-60 minutes including webhook handling
What the Customer Portal Gives You for Free
Without building any UI:
Customer Portal Features:
✅ View current plan and billing date
✅ Upgrade or downgrade plan
✅ Cancel subscription (with optional pause instead of cancel)
✅ Update payment method (card, SEPA, etc.)
✅ View and download all invoices
✅ Update billing address and tax info
✅ Manage multiple subscriptions
Things you still need to build:
❌ Your pricing page (show plans before subscribing)
❌ Initial checkout (Stripe Checkout or custom)
❌ Post-change UI updates (webhook handling)
Step 1: Configure in Stripe Dashboard
Before writing any code, configure the portal in your Stripe Dashboard:
Stripe Dashboard → Settings → Billing → Customer portal
Configure:
1. Business information
- Business name (shows in portal header)
- Privacy policy URL
- Terms of service URL
- Support URL (link to your support page)
2. Features
✅ Invoice history (always enable)
✅ Payment methods (always enable)
✅ Subscriptions → Enable customer cancellations
✅ Subscriptions → Allow plan switches
3. Cancellation options
→ Allow cancel at end of period (recommended)
→ Optional: offer pause instead of cancel
4. Subscription details
→ List your price IDs to allow upgrades/downgrades between
5. Save and get your portal configuration ID
(looks like: bpc_1234567890)
Step 2: The Portal Session API Route
This is the only code you write — create a portal session and redirect:
// app/api/billing/portal/route.ts
// Works in: ShipFast, T3 Stack, Makerkit, Supastarter — any Next.js boilerplate
import Stripe from 'stripe';
import { auth } from '@/lib/auth'; // Your boilerplate's auth helper
import { db } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
// 1. Get authenticated user:
const session = await auth();
if (!session?.user) {
return new Response('Unauthorized', { status: 401 });
}
// 2. Get their Stripe customer ID from your database:
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
return new Response('No billing account found', { status: 404 });
}
// 3. Create the portal session:
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
// Optional: use a specific portal configuration
// configuration: process.env.STRIPE_PORTAL_CONFIG_ID,
});
// 4. Redirect to the Stripe-hosted portal:
return Response.redirect(portalSession.url);
}
That's it for the core route. The entire portal UI is hosted by Stripe — you don't build anything else for the portal itself.
Step 3: The "Manage Billing" Button
// components/BillingSection.tsx
'use client';
export function BillingSection({
plan,
nextBillingDate,
amount,
}: {
plan: string;
nextBillingDate: string;
amount: number;
}) {
const handleManageBilling = async () => {
// POST to your API route, which redirects to Stripe portal:
const res = await fetch('/api/billing/portal', { method: 'POST' });
if (res.redirected) {
window.location.href = res.url;
}
};
return (
<div className="rounded-lg border p-6">
<h3 className="text-lg font-semibold">Billing</h3>
<div className="mt-4 space-y-2">
<div className="flex justify-between">
<span className="text-gray-500">Current plan</span>
<span className="font-medium capitalize">{plan}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Next billing date</span>
<span>{nextBillingDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Amount</span>
<span>${(amount / 100).toFixed(2)}/month</span>
</div>
</div>
<button
onClick={handleManageBilling}
className="mt-6 w-full rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50"
>
Manage billing
</button>
</div>
);
}
Or as a simple link (simpler approach):
// Direct form submit → redirect:
export function ManageBillingButton() {
return (
<form action="/api/billing/portal" method="POST">
<button type="submit" className="btn-outline">
Manage billing
</button>
</form>
);
}
Step 4: Handle Webhooks (Critical)
When a customer makes changes in the portal, Stripe sends webhooks. You must handle these to keep your database in sync:
// app/api/webhooks/stripe/route.ts
// (Most boilerplates already have this — add cases for portal events)
import Stripe from 'stripe';
import { db } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
return new Response('Webhook signature verification failed', { status: 400 });
}
switch (event.type) {
// ✅ Subscription upgraded or downgraded:
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await db.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: {
status: subscription.status,
stripePriceId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
// Update user's plan based on new price:
const newPlan = getPlanFromPriceId(subscription.items.data[0].price.id);
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: { plan: newPlan },
});
break;
}
// ✅ Subscription canceled (from portal or programmatically):
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: { status: 'canceled' },
});
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: { plan: 'free' },
});
break;
}
// ✅ Payment method updated (no DB change needed, just log):
case 'payment_method.attached': {
console.log('Payment method attached:', event.data.object.id);
break;
}
// ✅ Invoice paid (good to track):
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
await db.invoice.create({
data: {
stripeInvoiceId: invoice.id,
stripeCustomerId: invoice.customer as string,
amount: invoice.amount_paid,
status: 'paid',
paidAt: new Date(invoice.status_transitions.paid_at! * 1000),
},
});
break;
}
}
return new Response('OK', { status: 200 });
}
function getPlanFromPriceId(priceId: string): string {
const planMap: Record<string, string> = {
[process.env.STRIPE_PRICE_PRO_MONTHLY!]: 'pro',
[process.env.STRIPE_PRICE_PRO_ANNUAL!]: 'pro',
[process.env.STRIPE_PRICE_ENTERPRISE!]: 'enterprise',
};
return planMap[priceId] ?? 'free';
}
Boilerplate-Specific Integration
ShipFast
ShipFast includes Stripe but the portal route isn't always pre-built:
// Add to ShipFast's existing /api/stripe/ directory:
// app/api/stripe/portal/route.ts
import { getServerSession } from 'next-auth'; // or Supabase auth
import { authOptions } from '@/libs/next-auth';
import Stripe from 'stripe';
import connectMongo from '@/libs/mongoose';
import User from '@/models/User';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const session = await getServerSession(authOptions);
await connectMongo();
const user = await User.findById(session?.user?.id);
if (!user?.customerId) {
return new Response('No billing account', { status: 404 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.customerId, // ShipFast uses 'customerId' field
return_url: `${process.env.NEXTAUTH_URL}/dashboard`,
});
return Response.redirect(portalSession.url);
}
T3 Stack
// app/api/billing/portal/route.ts
import { auth } from '@/server/auth'; // T3 Stack NextAuth
import { db } from '@/server/db';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const session = await auth();
if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
const portalSession = await stripe.billingPortal.sessions.create({
customer: user!.stripeCustomerId!,
return_url: `${process.env.NEXTAUTH_URL}/dashboard/billing`,
});
return Response.redirect(portalSession.url);
}
Supastarter
Supastarter already has this — look in:
app/[locale]/(app)/[organization]/settings/billing/page.tsx
The billing page has a "Manage Subscription" button that calls:
/api/billing/portal (Supastarter's built-in route)
Advanced: Pre-populate the Portal at a Specific Flow
Instead of showing the portal homepage, deep-link to a specific section:
// Deep-link to "Update Payment Method" directly:
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
flow_data: {
type: 'payment_method_update', // Skip portal homepage
},
});
// Deep-link to subscription cancellation:
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
flow_data: {
type: 'subscription_cancel',
subscription_cancel: {
subscription: user.stripeSubscriptionId,
},
},
});
Use case: if you want a "Cancel subscription" button that goes directly to the cancellation flow (with Stripe's cancellation survey and retention prompts).
Testing the Portal
# Install Stripe CLI:
brew install stripe/stripe-cli/stripe
# Listen for webhooks locally:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Test a subscription update:
stripe trigger customer.subscription.updated
# Test cancellation:
stripe trigger customer.subscription.deleted
Test the full portal flow:
- Create a test customer in Stripe Test Mode
- Subscribe them to a plan
- Hit your
/api/billing/portalendpoint - You'll be redirected to a test portal
- Try upgrading, canceling — verify webhooks fire and your DB updates
Why the Customer Portal Exists
Stripe built the Customer Portal to eliminate a category of development work that every subscription SaaS needs but none wants to build from scratch. Upgrading a plan, swapping a credit card, downloading a past invoice, pausing a subscription — each of these is a non-trivial UI and webhook interaction. The Customer Portal handles all of them through a Stripe-hosted interface. Your application doesn't render any of this UI. Stripe does.
The security advantage is also significant: your application never handles sensitive card details. Users update payment methods directly through Stripe's interface, which is PCI-compliant by design. You receive a webhook confirming the update succeeded. This is a cleaner security boundary than building your own card update form using Stripe Elements, which, while also PCI-compliant, requires more implementation care.
The primary limitation is that you cannot deeply customize the Customer Portal's visual appearance. You can configure the features it shows (which plan switches are allowed, whether cancellation is enabled, whether pause is offered), add your logo, set the business name, and configure privacy and terms URLs. But the layout, color scheme, and interaction patterns are Stripe's. For most SaaS products this is acceptable — users interact with Stripe's billing interface on many other products and recognize the pattern.
Common Mistakes When Adding the Portal
The most common mistake is forgetting to handle the customer.subscription.updated webhook. This event fires when a user upgrades, downgrades, or cancels-at-period-end through the portal. If you don't handle it, your database will be out of sync with Stripe's state. Users who upgrade will still see the old plan in your UI. Users who cancel will continue to see active subscription status until their next invoice event. This causes support tickets and user frustration.
The second most common mistake is not storing the stripeCustomerId on first checkout. The portal session creation requires a Stripe customer ID. If you create a Stripe checkout session using the customer's email without also creating and persisting a customer object, you won't have a customer ID to pass to the portal session. The fix is to create the Stripe customer explicitly during checkout and save the returned customer.id to your users table.
A third mistake is not testing the portal in Stripe's test mode with actual test events. Developers frequently test the portal link works (it redirects to Stripe) but don't test the webhook handling for subscription changes. The stripe trigger command and the Stripe CLI make this easy — run it before deploying to production.
Environment Variables and Security
The portal session is created server-side using your Stripe secret key. Never expose the secret key to the client. The API route pattern shown above is the correct approach: the client posts to your API route, your API route creates the portal session server-side using the secret key, and redirects the client to Stripe's URL.
Verify the webhook signature on every incoming webhook to prevent forged events. The stripe.webhooks.constructEvent call shown above does this — it validates the stripe-signature header against your webhook secret. Do not process webhook events that fail signature verification.
If you have multiple Stripe webhook endpoints (test and production), use separate webhook secrets. The test secret from your Stripe test mode dashboard is different from the production secret. Using the wrong one causes signature verification failures that are difficult to debug.
Retention Flows and the Cancel Flow
Stripe's portal cancel flow includes optional retention prompts — you can configure a cancellation survey, offer a pause instead of cancellation, or show a discount. These features are configured in the Stripe Dashboard under the portal configuration, not in your code. Most SaaS products benefit from enabling the cancellation survey at minimum, since the feedback reveals why users are churning.
The flow_data.type: 'subscription_cancel' deep link shown in the Advanced section lets you build a dedicated "Cancel subscription" button that bypasses the portal homepage and takes the user directly to the cancellation flow. This is useful if you want to include a cancellation flow in your settings page without requiring users to navigate through the full portal.
For pausing subscriptions, Stripe's portal supports a pause-instead-of-cancel option that pauses billing for a configurable period (30-90 days typically). Configure this in the portal settings and handle the customer.subscription.paused and customer.subscription.resumed webhooks to update your database accordingly.
After Integration: Next Steps
After adding the Customer Portal, the typical next steps for subscription management are: adding a dunning flow (automated emails for failed payments), building an admin view to manage subscriptions on behalf of users, and tracking revenue metrics (MRR, churn) from Stripe events.
When the Customer Portal Is Enough
For most SaaS products below $50K MRR with standard subscription pricing, the Stripe Customer Portal covers the entire subscription management surface area without any custom UI work. Users can upgrade when they need more capacity, cancel when they're not getting value, and update payment methods when a card expires. These are the three actions that generate 90% of support tickets around billing in early-stage SaaS.
The cases where the portal is not enough are narrow but real. Per-seat billing with granular seat assignment (where adding a user should increment the subscription quantity) often requires a custom billing UI because the portal doesn't expose seat management as a configurable flow. Usage-based billing metered on API calls, storage, or compute requires a custom billing page that shows current consumption against the plan limit. Feature flag gating (where higher-tier plans unlock specific features visible in the UI) requires your own plan-aware components, not the portal, to surface the difference.
For early-stage products, treating the Customer Portal as the complete billing UI and investing the saved development time in core product features is almost always the right tradeoff. Most founders who build custom billing UIs before they have customers regret the time spent. Build the custom billing UI when user research or growing support volume reveals that the portal's standard flows don't match how your specific users think about managing their subscription.
The portal configuration ID (bpc_xxxxx) lets you create multiple portal configurations for different use cases — one configuration with cancellation disabled for enterprise contracts, another with full cancellation options for standard subscriptions. Pass the configuration parameter to stripe.billingPortal.sessions.create to route different customer types to the appropriate portal configuration. This is an underused Stripe feature that gives you fine-grained control without building custom billing UI.
See the SaaSBold vs ShipFast vs Supastarter comparison for how the major boilerplates pre-configure Stripe billing flows. The Stripe vs Polar vs Lemon Squeezy comparison covers how different payment providers handle customer portal equivalents. The how-to guide for usage-based billing covers the next layer of complexity if you need metered billing.