Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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 });
}// 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>
);
}stripe.billingPortal.sessions.create generates a short-lived URL (valid for a few minutes) that logs the customer into the portal.return_url is where Stripe redirects the customer after they leave the portal.customer.subscription.updated) that your webhook handler should process.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,
},
},
});stripe.billingPortal.sessions.create returns Promise<Stripe.BillingPortal.Session>.url property on the portal session is always a string (never null), unlike Checkout Sessions.Stripe.BillingPortal.Configuration.import type Stripe from "stripe";
type PortalSession = Stripe.BillingPortal.Session;customer.subscription.updated and customer.subscription.deleted to keep your database in sync.redirect() call in a Server Action throws internally. Do not catch this error or the redirect will not happen.| 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 |
const portalSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
redirect(portalSession.url);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.
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.
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: "...",
flow_data: {
type: "subscription_cancel",
subscription_cancel: { subscription: subscriptionId },
},
});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.
customer.subscription.updated -- plan changes, payment method updatescustomer.subscription.deleted -- subscription canceledredirect() 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.
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 },
},
});It returns Promise<Stripe.BillingPortal.Session>. Unlike Checkout Sessions, the url property is always a string (never null).
redirect() directlyReviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥