Stripe Customer Portal in Next.js App Router (Route Handler)

Add the Stripe Customer Portal to Next.js App Router
Your SaaS needs a Manage billing link where customers update payment methods, download invoices, and cancel or change plans. Stripe hosts that UI in the Customer Portal you create a short-lived portal session server-side and redirect the user to Stripe’s URL. In the Next.js App Router, that usually means one authenticated Route Handler calling stripe.billingPortal.sessions.create and returning the session URL to the client.
This guide shows a production-style Stripe customer portal Next.js App Router route handler: auth, stripeCustomerId on the user, return URLs, Dashboard configuration, and how portal changes sync back to Prisma via webhooks (not via the redirect).
What the Customer Portal does (and what your app still owns)
User action in Portal Stripe Your Next.js app Update card Updates default payment method Optional: show “billing updated” toast after return Cancel subscription Sets cancel at period end or immediate cancel Webhook → update Prisma Subscription Switch plan (if enabled) Changes subscription items Webhook → upsert new stripePriceId View invoices Stripe-hosted PDFs No custom invoice UI required
The portal does not write to your database. After redirect back to your site, read subscription state from Prisma only after customer.subscription.updated (and related events) fire same pipeline as syncing Stripe subscriptions to Prisma.
Prerequisites
Stripe Customer Portal enabled and configured in the Dashboard (test mode first).
Users with a stored
stripeCustomerId(created at Checkout or on first portal visit).Authenticated dashboard user (Auth.js or your session layer).
Webhook handler already processing
customer.subscription.updatedandcustomer.subscription.deleted.
Step 1: Configure the portal in Stripe Dashboard
Go to Settings → Billing → Customer portal (or Product catalog → Customer portal depending on Dashboard version).
Enable features you want: cancel subscription, switch plans, update payment methods, invoice history.
Assign products/prices the portal may switch between (must match what you sell in Checkout).
Set branding (logo, colors) if desired.
Without plan switching configured, users can still update cards and cancel—common for early-stage SaaS.
Step 2: Ensure every paying user has a Stripe customer id
Portal sessions require customer: 'cus_...'. Persist on signup or first Checkout:
// lib/stripe/customer.ts (example)
import Stripe from "stripe";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function getOrCreateStripeCustomer(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) throw new Error("User not found");
if (user.stripeCustomerId) {
return user.stripeCustomerId;
}
const customer = await stripe.customers.create({
email: user.email ?? undefined,
metadata: { userId: user.id },
});
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customer.id },
});
return customer.id;
}
Users who never subscribed may not need portal access gate the Manage billing button on having a customer id or an active/historical subscription.
Step 3: Create the portal Route Handler
Add app/api/stripe/portal/route.ts:
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { auth } from "@/lib/auth";
import { getOrCreateStripeCustomer } from "@/lib/stripe/customer";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
if (!appUrl) {
console.error("NEXT_PUBLIC_APP_URL is not set");
return NextResponse.json(
{ error: "Server misconfiguration" },
{ status: 500 },
);
}
try {
const customerId = await getOrCreateStripeCustomer(session.user.id);
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${appUrl}/dashboard/billing`,
});
return NextResponse.json({ url: portalSession.url });
} catch (error) {
console.error("Failed to create portal session:", error);
return NextResponse.json(
{ error: "Could not open billing portal" },
{ status: 500 },
);
}
}
This is the canonical Stripe customer portal Next.js App Router route handler pattern: POST, authenticated, returns JSON with url for a client redirect.
Optional: GET with immediate redirect
Some teams prefer a single link without client JavaScript:
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.redirect(new URL("/login", process.env.NEXT_PUBLIC_APP_URL!));
}
const customerId = await getOrCreateStripeCustomer(session.user.id);
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
});
return NextResponse.redirect(portalSession.url);
}
Use POST + fetch when you want clearer CSRF patterns; GET is fine for same-site navigation from a server component link if your auth cookies are HttpOnly and SameSite.
Step 4: Wire the billing settings UI
Client component example (dashboard billing page):
"use client";
import { useState } from "react";
export function ManageBillingButton() {
const [loading, setLoading] = useState(false);
async function openPortal() {
setLoading(true);
try {
const res = await fetch("/api/stripe/portal", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Portal failed");
window.location.href = data.url;
} catch (e) {
console.error(e);
setLoading(false);
}
}
return (
<button type="button" onClick={openPortal} disabled={loading}>
{loading ? "Opening…" : "Manage billing"}
</button>
);
}
Server component alternative: link to /api/stripe/portal if you implemented GET redirect.
Show current plan from Prisma, not from Stripe’s portal URL. After return from Stripe, revalidate the page or rely on router refresh so webhook-processed updates appear (may take a second see UX note below).
Step 5: return_url and deep links
return_url must be an absolute URL on your domain. Common choices:
/dashboard/billing— default manage page/dashboard/billing?portal=1— trigger a “You can close this tab” or refresh hint
Do not put secrets in query params. Stripe appends nothing sensitive to your return URL; subscription changes still arrive via webhooks.
For multi-environment setups, NEXT_PUBLIC_APP_URL should match the deployment (localhost in dev, production domain on Vercel) same as Checkout success_url.
Step 6: Middleware and auth
Unlike webhooks, the portal route must require login. Typical middleware protects /dashboard/* and /api/stripe/portal only if you mount API routes under auth.
Ensure:
/api/stripe/webhookstays excluded from auth middleware./api/stripe/portalrequires a valid session.
Example matcher exclusion (webhook only):
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|api/stripe/webhook).*)",
],
};
Portal routes still run through your normal session check for protected API paths if you add explicit rules.
Step 7: Sync portal changes via webhooks
When a user cancels in the portal, Stripe emits customer.subscription.updated (cancel at period end) or customer.subscription.deleted. Your handler should already upsert status and cancelAtPeriodEnd see the sync guide. No extra portal-specific webhook type exists.
If the user upgrades in the portal, expect customer.subscription.updated with a new price id on subscription items your upsert updates stripePriceId and plan gates read the new value.
UX: After redirect, show copy like “Changes may take a moment to reflect” if webhooks are slow; optional polling or router.refresh() after a short delay improves perceived reliability.
Step 8: Test in Stripe test mode
Create a test customer and subscription via Checkout (checkout.session.completed flow).
Log in as that user in your app.
Click Manage billing → confirm redirect to
billing.stripe.com.Cancel at period end or update card.
Confirm webhook deliveries succeed and Prisma
Subscriptionupdates.
Use test clocks in Stripe if you need to simulate period rollovers without waiting a month.
Common failures
“No configuration provided” or empty portal
Customer portal not activated in Dashboard, or no products linked for plan switching.
401 on /api/stripe/portal
Session missing; user not logged in or cookie not sent on fetch (use same-origin fetch with credentials default on same site).
Portal opens for wrong customer
Never accept customerId from the client body; always derive from the authenticated user’s stripeCustomerId.
Dashboard still shows old plan after portal change
Webhooks not handling subscription.updated, or gating caches stale data. Fix handler first; then add revalidatePath('/dashboard/billing') after return if needed.
Live vs test customer mismatch
Test mode portal sessions require test mode customers created with test keys same as Checkout.
Plan gating after portal changes
Portal updates Stripe; your app enforces access from Prisma. Before rendering Pro features, check something like:
const sub = await db.subscription.findFirst({
where: {
userId: user.id,
status: { in: ["ACTIVE", "TRIALING"] },
},
});
Combine with currentPeriodEnd and canceled-at-period-end rules as your product requires (detailed in the upcoming plan gating article). Portal + webhooks + server-side gates stay in sync when subscription status drives access not a one-time Checkout flag.
Portal + Checkout in one kit
Checkout gets users on a plan; the portal lets them self-serve without support tickets. Both need correct Stripe keys, customer ids, and webhook sync boilerplate that every Next.js SaaS repeats.
Skryptix AI SaaS Starter Kit includes:
/api/stripe/portalRoute Handler and billing settings UICheckout route with metadata and customer creation
Webhook sync for subscription updates after portal actions
Auth-protected dashboard routes
Portal + billing settings UI are part of AI SaaS Starter Kit — Get it here.
FAQ
How do I add the Stripe Customer Portal in Next.js App Router?
Create an authenticated Route Handler that calls stripe.billingPortal.sessions.create({ customer, return_url }) and redirect the user to session.url. Store stripeCustomerId on your user record when you create customers in Checkout.
Should the portal route use GET or POST?
Both work. POST returning { url } is common with a client button; GET with NextResponse.redirect works for simple links. Always create the portal session server-side with the logged-in user’s customer id.
Does the Customer Portal update my Prisma database?
Not directly. Stripe sends webhook events such as customer.subscription.updated after portal actions. Your webhook handler must upsert subscription rows in Prisma.
What is the correct return_url for the billing portal?
An absolute URL on your app, typically the billing settings page (for example https://yourapp.com/dashboard/billing). Use the same base URL env var as Checkout success URLs.
Do I need a separate Stripe webhook for the portal?
No. Portal actions reuse standard subscription and invoice events on your existing /api/stripe/webhook endpoint.
Written by the team behind Skryptix. We build the AI SaaS Starter Kit so founders can focus on product—not auth and billing infrastructure.
