Stripe Customer Portalsubscriptions, webhooks and Next.js in 2026

stripe5 min readJuly 20, 2026

Author: DevStudio.it

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:

  1. User logs in (NextAuth, Clerk, custom JWT)
  2. In Branchly (branchly.cloud) you have users.stripeCustomerId and subscriptions.status
  3. Click “Manage subscription” → POST /api/billing/portal
  4. Server creates stripe.billingPortal.sessions.create({ customer, return_url })
  5. Redirect to Stripe session URL → customer edits → returns to return_url
  6. Webhook customer.subscription.updated updates 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

  1. Stripe CLI: stripe listen --forward-to localhost:3000/api/webhooks/stripe
  2. Test plan upgrade in Portal → verify webhook and app UI
  3. Test cancel “at period end” vs immediately
  4. Test invoice.payment_failed — access blocks per policy
  5. Switch to live keys + new webhook secret on DevStudioIT Cloud
  6. 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 priceIdpro / team plan.

CTA

Building SaaS and want self-service billing without months on a payments panel?

Related posts

Sitemap.xml and RSS Feed in Next.js App Router — Technical SEO 2026
6 min read
RAG chatbot — embeddings, vector search and deployment in 2026
5 min read
n8n — Form Automation: Webhook from Next.js → CRM → Slack (2026)
6 min read

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.

Like how we think? Let's build something together.

Start project configuration