The Stripe Customer Portal is a hosted page where subscribers can manage their own subscriptions — upgrading, downgrading, canceling, and updating their payment method — without you building any of that UI yourself. It takes about 30 minutes to wire up and removes an entire category of customer support tickets.
Here's how to integrate it into a Next.js SaaS.
Enable the portal in your Stripe dashboard
Before writing any code, you need to configure the portal in Stripe:
- Go to Stripe Dashboard → Settings → Billing → Customer portal.
- Enable the features you want: cancel subscriptions, pause subscriptions, update payment method, update billing address, invoice history.
- Set a return URL — this is where Stripe redirects the customer after they're done (usually your dashboard, e.g.
https://yourapp.com/dashboard). - Save your configuration.
You only need one portal configuration for all your customers — Stripe handles the per-customer state automatically.
Create the portal API route
The flow is simple: your API route creates a portal session for the customer and returns the URL. The client redirects to it.
// app/api/stripe/portal/route.ts
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { getUserDbId } from "@/lib/db";
import { adminClient } from "@/lib/supabase/admin";
import { APP_URL } from "@/lib/config";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// Look up the Stripe customer ID from your database
const dbUserId = await getUserDbId(userId);
if (!dbUserId) return NextResponse.json({ error: "User not found" }, { status: 404 });
const { data: sub } = await adminClient
.from("subscriptions")
.select("stripe_customer_id")
.eq("user_id", dbUserId)
.single();
if (!sub?.stripe_customer_id) {
return NextResponse.json({ error: "No subscription found" }, { status: 404 });
}
// Create a portal session
const session = await stripe.billingPortal.sessions.create({
customer: sub.stripe_customer_id,
return_url: `${APP_URL}/dashboard`,
});
return NextResponse.json({ url: session.url });
}
Add the "Manage billing" button
Call the API route and redirect to the returned URL. This can be a simple button in your dashboard:
"use client";
import { useState } from "react";
export function ManageBillingButton() {
const [loading, setLoading] = useState(false);
async function handleClick() {
setLoading(true);
try {
const res = await fetch("/api/stripe/portal", { method: "POST" });
const { url, error } = await res.json();
if (error) throw new Error(error);
window.location.href = url;
} catch (err) {
console.error(err);
setLoading(false);
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white
hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100
dark:text-zinc-900 dark:hover:bg-zinc-300"
>
{loading ? "Redirecting…" : "Manage billing"}
</button>
);
}
Place this button on your account or billing settings page. When clicked, the user lands on the Stripe-hosted portal, makes their changes, and is redirected back to your dashboard.
Handling portal events via webhooks
When a customer cancels or changes their plan through the portal, Stripe fires webhook events. The same webhook handler you use for checkout needs to handle these:
customer.subscription.updated — plan change, trial end, payment failurecustomer.subscription.deleted — cancellation takes effect
If you've already implemented a webhook handler for the checkout flow, these events are handled by the same upsert logic. You don't need separate webhook handling for the portal.
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await adminClient.from("subscriptions").upsert({
stripe_subscription_id: sub.id,
stripe_customer_id: sub.customer as string,
status: sub.status,
plan: sub.items.data[0]?.price.id === process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID
? "pro"
: "free",
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
updated_at: new Date().toISOString(),
}, { onConflict: "stripe_subscription_id" });
break;
}
Show subscription status in the dashboard
Before showing the "Manage billing" button, check whether the user actually has a subscription. For users on a free plan, show a link to upgrade instead:
// app/(dashboard)/account/page.tsx
import { auth } from "@clerk/nextjs/server";
import { getUserDbId, isProSubscriber, getSubscription } from "@/lib/db";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import Link from "next/link";
export default async function AccountPage() {
const { userId } = await auth();
const dbUserId = await getUserDbId(userId!);
const isPro = dbUserId ? await isProSubscriber(dbUserId) : false;
const sub = dbUserId ? await getSubscription(dbUserId) : null;
return (
<div>
<h2 className="text-lg font-semibold">Subscription</h2>
{isPro ? (
<div className="mt-4 space-y-2">
<p className="text-sm text-zinc-500">
Status: <span className="font-medium text-zinc-900">{sub?.status}</span>
</p>
{sub?.current_period_end && (
<p className="text-sm text-zinc-500">
Renews:{" "}
<span className="font-medium text-zinc-900">
{new Date(sub.current_period_end).toLocaleDateString()}
</span>
</p>
)}
<ManageBillingButton />
</div>
) : (
<div className="mt-4">
<p className="text-sm text-zinc-500">You're on the free plan.</p>
<Link
href="/checkout"
className="mt-2 inline-flex items-center rounded-md bg-zinc-900 px-4 py-2
text-sm font-medium text-white hover:bg-zinc-700"
>
Upgrade to Pro →
</Link>
</div>
)}
</div>
);
}
Cancellation: at period end vs immediately
By default, the Stripe Customer Portal cancels subscriptions at the end of the current billing period — the user keeps access until the period ends, then customer.subscription.deleted fires. This is the right default.
You can configure this in the portal settings. Immediate cancellation (with or without proration) is also available, but most SaaS products keep the default because it reduces cancellation friction and gives you time to trigger a win-back email.
Testing in test mode
The portal works exactly the same in test mode. Use a test customer created by your checkout flow, then visit the portal URL. You can cancel, change plans, and trigger the corresponding webhook events — all without touching real money.
# Forward webhooks to localhost
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Trigger a subscription update
stripe trigger customer.subscription.updated
What GetLaunchpad includes
GetLaunchpad ships with the complete portal integration: the /api/stripe/portal route, the ManageBillingButton component, and webhook handling for customer.subscription.updated and customer.subscription.deleted. Subscription status is stored in Supabase and read server-side — no client-side Stripe calls needed.