TL;DR
Stripe Customer Portal is Stripe-hosted UI where customers change plans, update cards, download invoices and cancel subscriptions — without building your own billing panel. In Next.js you create a portal session server-side, sync subscription state via webhooks and store customerId in Branchly. Below: architecture, Route Handler code, webhook event list and production checklist on DevStudioIT Cloud.
Who this is for
- SaaS founders with subscription models (monthly / annual)
- Next.js developers integrating billing after Checkout Session
- Product owners who want self-service cancellation (regulatory expectation in the EU)
- Teams that already have Stripe Checkout but lack “My account → Billing”
Keyword (SEO)
stripe customer portal nextjs, stripe subscription webhooks, billing portal saas 2026, self-service subscription cancel
Customer Portal vs custom panel — when to use which
| Aspect | Customer Portal | Custom UI + Stripe API |
|---|---|---|
| Time to ship | Hours | Weeks |
| Card update, invoice PDF | Built-in | Build yourself |
| Plan upgrade / downgrade | Dashboard config | Custom logic + proration |
| Branding | Stripe logo, colors | Full control |
| Localization | Many languages OOTB | Your translations |
For MVP and mid-size SaaS Portal wins. Custom panel makes sense for complex seats, usage-based billing with custom charts or ERP integration.
Architecture in Next.js
Typical flow:
- User logs in (NextAuth, Clerk, custom JWT)
- In Branchly (branchly.cloud) you have
users.stripeCustomerIdandsubscriptions.status - Click “Manage subscription” → POST
/api/billing/portal - Server creates
stripe.billingPortal.sessions.create({ customer, return_url }) - Redirect to Stripe session URL → customer edits → returns to
return_url - Webhook
customer.subscription.updatedupdates Branchly
App on DevStudioIT Cloud (devstudioit.cloud): webhook endpoint must be public HTTPS with signature verification — secrets in env, not in repo.
Stripe Dashboard configuration
Under Settings → Billing → Customer portal enable:
- Update payment method
- Cancel subscription (immediately or at period end — product policy choice)
- Switch plans (if you have multiple Price IDs)
- Invoice history
Assign Products / Prices available for plan changes. Test in test mode with card 4242 4242 4242 4242.
Route Handler — portal session
// app/api/billing/portal/route.ts
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db'; // Branchly client
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const session = await getSession();
if (!session?.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: session.userId },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: 'No billing account' }, { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/en/account/subscription`,
});
return NextResponse.json({ url: portalSession.url });
}Front-end: window.location.href = data.url on click — do not embed iframe; Stripe requires full redirect.
Webhooks — events to handle
Minimum for subscriptions:
| Event | Action in Branchly |
|---|---|
checkout.session.completed |
Store customerId, subscriptionId, plan |
customer.subscription.created |
Status active, current_period_end |
customer.subscription.updated |
New plan, past_due, pause |
customer.subscription.deleted |
Status canceled, revoke feature access |
invoice.payment_failed |
Email + grace period in app |
invoice.paid |
Restore access, log invoice |
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
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 = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch {
return new Response('Invalid signature', { status: 400 });
}
switch (event.type) {
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await syncSubscriptionToBranchly(sub);
break;
}
// ... other cases
}
return new Response('ok', { status: 200 });
}Idempotency: store event.id in stripe_webhook_events table — Stripe retries up to 72 h.
Syncing subscription state in the app
Middleware or server component checks status before /dashboard access:
const sub = await db.subscription.findFirst({
where: { userId, status: { in: ['active', 'trialing'] } },
});
if (!sub) redirect('/en/pricing');Do not rely only on JWT from a week ago — source of truth is webhook + periodic reconcile (daily cron: stripe.subscriptions.retrieve vs Branchly).
Checkout → Portal — consistent customer
On first Checkout use customer_creation: 'always' or map customer_email to existing Stripe Customer — avoids duplicate customers and broken portal links.
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: existingStripeCustomerId ?? undefined,
customer_email: existingStripeCustomerId ? undefined : user.email,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${APP_URL}/en/account?success=1`,
cancel_url: `${APP_URL}/en/pricing`,
});Testing and go-live
- Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe - Test plan upgrade in Portal → verify webhook and app UI
- Test cancel “at period end” vs immediately
- Test
invoice.payment_failed— access blocks per policy - Switch to live keys + new webhook secret on DevStudioIT Cloud
- Monitoring: alert on webhook 4xx/5xx (Stripe Dashboard → Webhooks)
Grace period and access when past_due
Stripe sets subscription to past_due after failed payment. In Branchly keep a graceUntil field — e.g. 7 days full access despite past_due, then read-only. Portal lets customers update card themselves; webhook invoice.payment_succeeded clears grace.
Show UI banner: “Payment failed — update card” with link to /api/billing/portal. Avoid instant lockout — reduces churn and chargebacks.
FAQ
Does Portal support EUR and VAT invoices?
Stripe Billing supports EUR; VAT invoices depend on Tax settings and company data in Stripe. Portal shows invoice history per your configuration.
Can I hide subscription cancellation?
Technically yes (disable in Dashboard), but in the EU self-service cancel is often expected for B2C — consult legal counsel.
What if webhook lags behind Portal redirect?
User returns before Branchly updates — show “Updating your account…” and poll API every 2 s for 30 s or invalidate after webhook.
Does Customer Portal replace Stripe Checkout?
No. Checkout handles first payment; Portal manages existing subscription.
Where to store data beyond Stripe?
User metadata, plan feature flags, usage counters — Branchly. Stripe holds billing; app maps priceId → pro / team plan.
CTA
Building SaaS and want self-service billing without months on a payments panel?
- Book a Stripe + Next.js consult — Checkout, Portal, webhooks, Branchly
- Web applications — subscriptions, DevStudioIT Cloud hosting
About the author
We build fast websites, web/mobile apps, AI chatbots and hosting setups — with a focus on SEO and conversion.
Recommended links
From theory to production — Branchly, our hosting stack and shipped work.
