50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
paymentsMay 27, 2026·7 min read

Stripe Customer Portal: Let Users Manage Their Own Billing (So You Don't Have To)

Stop manually canceling subscriptions and updating cards for users. Here's how to wire up Stripe's Customer Portal in a Next.js app properly.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Stripe Customer Portal: Let Users Manage Their Own Billing (So You Don't Have To)

At some point, every SaaS founder gets this support email: 'Hey, can you cancel my subscription?' or 'My card expired, how do I update it?' If you're doing this manually — logging into Stripe, finding the customer, clicking around — you've already lost. Not because it takes long, but because it will keep happening every single day.

Stripe's Customer Portal solves this completely. Users get a hosted page where they can upgrade, downgrade, cancel, update payment methods, download invoices — the full billing self-service experience. You configure what they're allowed to do, Stripe handles the UI and the logic, and you get webhooks when something changes. It's one of those features that sounds boring until you realize it saves you hours every month.

We're going to walk through the full setup: configuring the portal in Stripe, creating the server-side session, building the button in Next.js, and handling the webhooks that come back. By the end you'll have a working billing page that users can actually use.

First: Configure the Portal in Stripe Dashboard

Before writing a single line of code, go to your Stripe Dashboard → Billing → Customer Portal. This is where you configure what users can actually do. Stripe doesn't expose this via API for initial setup — you do it in the dashboard, and it saves a configuration ID that you'll use later.

The options that matter most:

  • Cancel subscriptions — do you allow it immediately, or at period end? (Almost always pick 'at period end' — gives users time to change their mind)
  • Switch plans — which prices can users upgrade/downgrade to? You have to explicitly list them.
  • Update payment methods — yes, always enable this
  • Invoice history — enable it, users love being able to download their receipts for expenses
  • Update billing information — name, address, tax ID if you're doing tax collection

Save the configuration. Your configuration ID will look like `bpc_1234...`. You can hardcode this as an env variable, or omit it and Stripe will use your default configuration. We usually pass it explicitly so there are no surprises if you ever create a second configuration for a different product.

Creating a Portal Session (The Server-Side Part)

The Customer Portal is a Stripe-hosted page. You don't build the UI — you create a session server-side, get back a URL, and redirect the user there. The user does their thing, then gets redirected back to your app when they're done.

Here's a Route Handler for this in Next.js App Router:

// app/api/billing/portal/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { auth } from '@/lib/auth' // however you handle auth
import { db } from '@/lib/db'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
})

export async function POST(request: Request) {
  const session = await auth()

  if (!session?.user?.id) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // Get the Stripe customer ID from your database
  const user = await db.query.users.findFirst({
    where: (users, { eq }) => eq(users.id, session.user.id),
    columns: {
      stripeCustomerId: true,
    },
  })

  if (!user?.stripeCustomerId) {
    return NextResponse.json(
      { error: 'No billing account found' },
      { status: 404 }
    )
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
    // Optional: pass your config ID to be explicit
    configuration: process.env.STRIPE_PORTAL_CONFIG_ID,
  })

  return NextResponse.json({ url: portalSession.url })
}

A few things worth noting here. The `return_url` is where Stripe sends users when they're done — or when they click the back button. This is not a success callback; Stripe doesn't tell you anything via this redirect. All the important updates come through webhooks. The portal session URL expires after some time, so don't try to cache it.

The return_url is just 'send them back here when done'. It tells you nothing about what they did. For that, you need webhooks.

The Client-Side Button

The UI side is simple. You hit your API route, get the URL, and redirect. The main thing to get right is the loading state — portal session creation takes a second, and users will click again if nothing happens.

'use client'

import { useState } from 'react'
import { Button } from '@/components/ui/button'

export function ManageBillingButton() {
  const [loading, setLoading] = useState(false)

  const handleManageBilling = async () => {
    setLoading(true)

    try {
      const response = await fetch('/api/billing/portal', {
        method: 'POST',
      })

      if (!response.ok) {
        const error = await response.json()
        throw new Error(error.error || 'Failed to open billing portal')
      }

      const { url } = await response.json()
      window.location.href = url
    } catch (error) {
      console.error('Billing portal error:', error)
      // Show a toast or error message here
      setLoading(false)
    }
    // Don't reset loading — we're navigating away
  }

  return (
    <Button
      onClick={handleManageBilling}
      disabled={loading}
      variant="outline"
    >
      {loading ? 'Opening...' : 'Manage Billing'}
    </Button>
  )
}

Notice we don't reset `loading` in the success case. The user is being redirected away — resetting it would cause a flash of the original button state for no reason. Only reset on error.

Webhooks: The Part Everyone Skips (Don't)

Here's where most tutorials drop the ball. They show you how to open the portal, but don't tell you that your database still has the wrong subscription data after a user cancels or upgrades. The portal changes things in Stripe. Your app doesn't know unless you listen for webhooks.

The events you actually need to handle from portal interactions:

  • customer.subscription.updated — plan change, cancellation scheduled, trial changes
  • customer.subscription.deleted — subscription actually ended
  • customer.updated — billing address or name changed
  • payment_method.attached — new card added
  • invoice.payment_succeeded — renewal worked
  • invoice.payment_failed — renewal failed, subscription might downgrade
// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { db } from '@/lib/db'
import { subscriptions } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
})

export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json(
      { error: 'Webhook signature verification failed' },
      { status: 400 }
    )
  }

  switch (event.type) {
    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription
      
      await db
        .update(subscriptions)
        .set({
          status: subscription.status,
          priceId: subscription.items.data[0].price.id,
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
          cancelAtPeriodEnd: subscription.cancel_at_period_end,
          updatedAt: new Date(),
        })
        .where(eq(subscriptions.stripeSubscriptionId, subscription.id))

      break
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription

      await db
        .update(subscriptions)
        .set({
          status: 'canceled',
          updatedAt: new Date(),
        })
        .where(eq(subscriptions.stripeSubscriptionId, subscription.id))

      break
    }
  }

  return NextResponse.json({ received: true })
}

If you're not handling `cancel_at_period_end` specifically, you'll have users who 'canceled' but still have active subscriptions until the period ends — and your app will block them when it shouldn't. Store this boolean and use it when checking access. A user with `cancel_at_period_end: true` and `currentPeriodEnd` in the future still has access.

Showing the Right Billing Info in Your App

Once you have the webhook data flowing in, your billing settings page can actually show users something useful instead of 'you have a subscription'. Here's a simple pattern for pulling current subscription state:

// app/settings/billing/page.tsx
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { ManageBillingButton } from '@/components/manage-billing-button'
import { redirect } from 'next/navigation'

export default async function BillingPage() {
  const session = await auth()
  if (!session?.user?.id) redirect('/login')

  const subscription = await db.query.subscriptions.findFirst({
    where: (subs, { eq }) => eq(subs.userId, session.user.id),
  })

  const isActive =
    subscription?.status === 'active' ||
    subscription?.status === 'trialing'

  const isCanceledButActive =
    isActive && subscription?.cancelAtPeriodEnd === true

  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-lg font-semibold">Billing</h2>
        <p className="text-sm text-muted-foreground">
          Manage your subscription and payment methods.
        </p>
      </div>

      {subscription ? (
        <div className="rounded-lg border p-4 space-y-3">
          <div className="flex items-center justify-between">
            <div>
              <p className="font-medium capitalize">
                {subscription.status === 'trialing'
                  ? 'Free Trial'
                  : subscription.planName ?? 'Active Plan'}
              </p>
              {isCanceledButActive && (
                <p className="text-sm text-amber-600">
                  Cancels on{' '}
                  {subscription.currentPeriodEnd?.toLocaleDateString()}
                </p>
              )}
              {!isCanceledButActive && isActive && (
                <p className="text-sm text-muted-foreground">
                  Renews on{' '}
                  {subscription.currentPeriodEnd?.toLocaleDateString()}
                </p>
              )}
            </div>
            <ManageBillingButton />
          </div>
        </div>
      ) : (
        <p className="text-sm text-muted-foreground">
          No active subscription.
        </p>
      )}
    </div>
  )
}

The Edge Cases That Bite You

A few things we've hit personally that are worth knowing before you go live:

  • Users with no Stripe customer ID — this happens if someone signed up but never subscribed. Handle the 404 case gracefully, don't just 500.
  • Test mode vs live mode — your portal configuration is environment-specific. You need a separate config for test mode, otherwise you'll see a different portal layout in production.
  • Webhook ordering is not guaranteed — don't assume subscription.updated always comes before subscription.deleted. Check timestamps and handle out-of-order events.
  • Portal session is single-use in a sense — the URL works once per session, but Stripe manages this. Just don't try to cache the URL and reuse it days later.
  • Free plan users — if you have a free tier, these users may not have a Stripe customer ID at all. Decide upfront: create a Stripe customer on signup anyway, or gate the portal button.
Create a Stripe customer record on signup, even for free users. It makes everything else — trials, upgrades, portal access — way simpler. A Stripe customer with no subscription is fine.

Testing It Locally

To test the full flow locally, you need the Stripe CLI running to forward webhooks. Install it, log in, and run:

# Forward webhook events to your local Next.js server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# The CLI will output your webhook signing secret — use this as
# STRIPE_WEBHOOK_SECRET in your .env.local (different from production!)
# whsec_xxxxxxxxxxxxxxxxxxxxxxxx

# In another terminal, trigger a test event
stripe trigger customer.subscription.updated

Open the portal in test mode with a real test customer, cancel something, and watch your webhook handler fire. It's genuinely satisfying when the database updates in real time as you click around in the Stripe-hosted portal.

One More Thing: Deep Links

The Customer Portal supports flow-specific deep links. Instead of always dumping users on the portal homepage, you can send them directly to update their payment method, or directly to cancel. This is useful when you want to contextually link from different places in your app:

// Create a portal session that starts on the payment method page
const portalSession = await stripe.billingPortal.sessions.create({
  customer: user.stripeCustomerId,
  return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
  flow_data: {
    type: 'payment_method_update',
  },
})

// Or start on subscription cancellation
const cancelSession = await stripe.billingPortal.sessions.create({
  customer: user.stripeCustomerId,
  return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
  flow_data: {
    type: 'subscription_cancel',
    subscription_cancel: {
      subscription: user.stripeSubscriptionId,
    },
  },
})

If your app shows a banner when a payment fails, you can link directly to the payment method update flow from that banner. Small UX detail, but it reduces friction when users need to fix something urgently.

All of this — the portal integration, webhook handling, subscription state management — is wired up in our peal.dev templates so you don't have to build it from scratch. But even if you're building your own, this setup gets you to a working billing management page in an afternoon rather than three days of debugging Stripe documentation.

The actual work here is less about Stripe and more about keeping your database in sync with reality. Stripe's portal is reliable. The failure mode is always 'our DB says one thing, Stripe says another' — and that's a webhook problem, not a portal problem. Get your webhook handler solid and the rest is mostly plumbing.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders