Customer Portal
Recipe
Create a portal session on the server and redirect the customer to Stripe's hosted billing management page where they can update payment methods, change plans, cancel subscriptions, and view invoice history.
Create a Server Action for the portal redirect:
// app/actions/portal.ts
"use server";
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function createPortalSession() {
const session = await auth();
if (!session?.user?.id) throw new Error("Not authenticated");
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
throw new Error("No Stripe customer found");
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
redirect(portalSession.url);
}Or use a Route Handler:
// app/api/portal/route.ts
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function POST() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
return NextResponse.json(
{ error: "No billing account" },
{ status: 404 }
);
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
return NextResponse.json({ url: portalSession.url });
}Working Example
// app/account/page.tsx
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { createPortalSession } from "@/app/actions/portal";
import { redirect } from "next/navigation";
export default async function AccountPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const user = await db.user.findUnique({
where: { id: session.user.id },
select: {
plan: true,
planStatus: true,
stripeCustomerId: true,
currentPeriodEnd: true,
},
});
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-6">Account Settings</h1>
<div className="border rounded-lg p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Subscription</h2>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-gray-600">Current Plan</dt>
<dd className="font-medium capitalize">{user?.plan ?? "Free"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600">Status</dt>
<dd className="font-medium capitalize">
{user?.planStatus ?? "N/A"}
</dd>
</div>
{user?.currentPeriodEnd && (
<div className="flex justify-between">
<dt className="text-gray-600">Current Period Ends</dt>
<dd className="font-medium">
{user.currentPeriodEnd.toLocaleDateString()}
</dd>
</div>
)}
</dl>
</div>
{user?.stripeCustomerId ? (
<form action={createPortalSession}>
<button
type="submit"
className="bg-gray-900 text-white px-6 py-3 rounded-lg hover:bg-gray-800"
>
Manage Subscription
</button>
</form>
) : (
<a
href="/pricing"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700"
>
View Plans
</a>
)}
</div>
);
}A client component version with loading state:
// components/manage-billing-button.tsx
"use client";
import { useState } from "react";
import { createPortalSession } from "@/app/actions/portal";
export function ManageBillingButton() {
const [loading, setLoading] = useState(false);
async function handleClick() {
setLoading(true);
try {
await createPortalSession();
} catch {
setLoading(false);
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className="bg-gray-900 text-white px-6 py-3 rounded-lg hover:bg-gray-800 disabled:opacity-50"
>
{loading ? "Opening portal..." : "Manage Subscription"}
</button>
);
}Deep Dive
How It Works
- The Customer Portal is a Stripe-hosted page where customers can self-manage their billing. It supports updating payment methods, switching plans, canceling subscriptions, and viewing invoices.
stripe.billingPortal.sessions.creategenerates a short-lived URL (valid for a few minutes) that logs the customer into the portal.- The
return_urlis where Stripe redirects the customer after they leave the portal. - Portal behavior is configured in the Stripe Dashboard under Settings and Customer Portal. You control which features are available (cancellation, plan switching, etc.).
- When a customer makes changes in the portal (e.g., cancels), Stripe fires webhook events (e.g.,
customer.subscription.updated) that your webhook handler should process.
Variations
Configure portal programmatically:
await stripe.billingPortal.configurations.create({
features: {
subscription_cancel: {
enabled: true,
mode: "at_period_end",
proration_behavior: "none",
},
subscription_update: {
enabled: true,
default_allowed_updates: ["price"],
proration_behavior: "create_prorations",
products: [
{
product: "prod_xxx",
prices: ["price_monthly", "price_annual"],
},
],
},
payment_method_update: { enabled: true },
invoice_history: { enabled: true },
},
business_profile: {
headline: "Manage your subscription",
},
});Deep-link to a specific portal section:
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
flow_data: {
type: "subscription_cancel",
subscription_cancel: {
subscription: subscriptionId,
},
},
});TypeScript Notes
stripe.billingPortal.sessions.createreturnsPromise<Stripe.BillingPortal.Session>.- The
urlproperty on the portal session is always a string (never null), unlike Checkout Sessions. - Portal configurations are typed as
Stripe.BillingPortal.Configuration.
import type Stripe from "stripe";
type PortalSession = Stripe.BillingPortal.Session;Gotchas
- The Customer Portal must be configured in the Stripe Dashboard before use. Without configuration, creating a session will fail.
- Portal sessions expire quickly. Always generate a fresh URL when the customer clicks "Manage Subscription" -- never store or cache portal URLs.
- The portal only works for customers who have at least one subscription or payment method on file. Creating a portal session for a customer with no billing history will show a mostly empty page.
- Changes made in the portal trigger webhook events. Make sure your webhook handler processes
customer.subscription.updatedandcustomer.subscription.deletedto keep your database in sync. - The
redirect()call in a Server Action throws internally. Do not catch this error or the redirect will not happen. - If you allow plan switching in the portal, make sure the product and price IDs in the portal configuration match your actual Stripe products.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Stripe Customer Portal | Zero UI code, handles all billing management | Limited branding, leaves your app |
| Custom billing UI with Stripe API | Full control over design and flow | Significant development effort |
| Portal with flow_data | Deep-link to specific actions | Still Stripe-hosted |
| Embedded portal (beta) | Stays in your app | Limited availability |
FAQs
What can customers do in the Stripe Customer Portal?
- Update payment methods
- Switch between subscription plans
- Cancel subscriptions
- View and download invoice history
How do you create a portal session and redirect the user?
const portalSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
redirect(portalSession.url);Gotcha: What happens if you create a portal session for a customer with no billing history?
The portal page renders but is mostly empty. It only shows meaningful content for customers who have at least one subscription or payment method on file.
Where is the Customer Portal configured?
In the Stripe Dashboard under Settings > Customer Portal. You control which features are available (cancellation, plan switching, payment method updates, invoice history). Without Dashboard configuration, creating a session will fail.
Can you deep-link to a specific portal action like cancellation?
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: "...",
flow_data: {
type: "subscription_cancel",
subscription_cancel: { subscription: subscriptionId },
},
});How long are portal session URLs valid?
Portal session URLs expire within a few minutes. Always generate a fresh URL when the user clicks "Manage Subscription" -- never store or cache portal URLs.
What webhook events should you handle when customers make changes in the portal?
customer.subscription.updated-- plan changes, payment method updatescustomer.subscription.deleted-- subscription canceled- Always keep your database in sync by processing these events in your webhook handler
Gotcha: Why must you not catch the error thrown by redirect() in a Server Action?
redirect() throws a NEXT_REDIRECT error internally to trigger the redirect. If your try/catch block catches and swallows this error, the redirect silently fails and the user stays on the current page.
How do you configure the portal programmatically instead of through the Dashboard?
await stripe.billingPortal.configurations.create({
features: {
subscription_cancel: { enabled: true, mode: "at_period_end" },
subscription_update: {
enabled: true,
default_allowed_updates: ["price"],
products: [{ product: "prod_xxx", prices: ["price_a", "price_b"] }],
},
payment_method_update: { enabled: true },
invoice_history: { enabled: true },
},
});TypeScript: What type does stripe.billingPortal.sessions.create return?
It returns Promise<Stripe.BillingPortal.Session>. Unlike Checkout Sessions, the url property is always a string (never null).
What is the difference between using a Server Action vs a Route Handler for the portal redirect?
- Server Action: simpler, call from a form action, use
redirect()directly - Route Handler: returns JSON with the URL, client handles the redirect, useful for non-form flows
- Both require authentication checks before creating the portal session