GetLaunchpad
Back to blog
5 min read

Adding the Stripe Customer Portal to your Next.js SaaS

The Stripe Customer Portal lets subscribers manage their own billing — canceling, upgrading, and updating payment methods — without you building any of that UI. Here's how to wire it up in Next.js in 30 minutes.

The Stripe Customer Portal is a hosted page where subscribers can manage their own subscriptions — upgrading, downgrading, canceling, and updating their payment method — without you building any of that UI yourself. It takes about 30 minutes to wire up and removes an entire category of customer support tickets.

Here's how to integrate it into a Next.js SaaS.

Enable the portal in your Stripe dashboard

Before writing any code, you need to configure the portal in Stripe:

  1. Go to Stripe Dashboard → Settings → Billing → Customer portal.
  2. Enable the features you want: cancel subscriptions, pause subscriptions, update payment method, update billing address, invoice history.
  3. Set a return URL — this is where Stripe redirects the customer after they're done (usually your dashboard, e.g. https://yourapp.com/dashboard).
  4. Save your configuration.

You only need one portal configuration for all your customers — Stripe handles the per-customer state automatically.

Create the portal API route

The flow is simple: your API route creates a portal session for the customer and returns the URL. The client redirects to it.

// app/api/stripe/portal/route.ts
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { getUserDbId } from "@/lib/db";
import { adminClient } from "@/lib/supabase/admin";
import { APP_URL } from "@/lib/config";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST() {
  const { userId } = await auth();
  if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  // Look up the Stripe customer ID from your database
  const dbUserId = await getUserDbId(userId);
  if (!dbUserId) return NextResponse.json({ error: "User not found" }, { status: 404 });

  const { data: sub } = await adminClient
    .from("subscriptions")
    .select("stripe_customer_id")
    .eq("user_id", dbUserId)
    .single();

  if (!sub?.stripe_customer_id) {
    return NextResponse.json({ error: "No subscription found" }, { status: 404 });
  }

  // Create a portal session
  const session = await stripe.billingPortal.sessions.create({
    customer: sub.stripe_customer_id,
    return_url: `${APP_URL}/dashboard`,
  });

  return NextResponse.json({ url: session.url });
}

Add the "Manage billing" button

Call the API route and redirect to the returned URL. This can be a simple button in your dashboard:

"use client";

import { useState } from "react";

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

  async function handleClick() {
    setLoading(true);
    try {
      const res = await fetch("/api/stripe/portal", { method: "POST" });
      const { url, error } = await res.json();
      if (error) throw new Error(error);
      window.location.href = url;
    } catch (err) {
      console.error(err);
      setLoading(false);
    }
  }

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white
                 hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100
                 dark:text-zinc-900 dark:hover:bg-zinc-300"
    >
      {loading ? "Redirecting…" : "Manage billing"}
    </button>
  );
}

Place this button on your account or billing settings page. When clicked, the user lands on the Stripe-hosted portal, makes their changes, and is redirected back to your dashboard.

Handling portal events via webhooks

When a customer cancels or changes their plan through the portal, Stripe fires webhook events. The same webhook handler you use for checkout needs to handle these:

If you've already implemented a webhook handler for the checkout flow, these events are handled by the same upsert logic. You don't need separate webhook handling for the portal.

case "customer.subscription.updated":
case "customer.subscription.deleted": {
  const sub = event.data.object as Stripe.Subscription;
  await adminClient.from("subscriptions").upsert({
    stripe_subscription_id: sub.id,
    stripe_customer_id: sub.customer as string,
    status: sub.status,
    plan: sub.items.data[0]?.price.id === process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID
      ? "pro"
      : "free",
    current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
    updated_at: new Date().toISOString(),
  }, { onConflict: "stripe_subscription_id" });
  break;
}

Show subscription status in the dashboard

Before showing the "Manage billing" button, check whether the user actually has a subscription. For users on a free plan, show a link to upgrade instead:

// app/(dashboard)/account/page.tsx
import { auth } from "@clerk/nextjs/server";
import { getUserDbId, isProSubscriber, getSubscription } from "@/lib/db";
import { ManageBillingButton } from "@/components/ManageBillingButton";
import Link from "next/link";

export default async function AccountPage() {
  const { userId } = await auth();
  const dbUserId = await getUserDbId(userId!);
  const isPro = dbUserId ? await isProSubscriber(dbUserId) : false;
  const sub = dbUserId ? await getSubscription(dbUserId) : null;

  return (
    <div>
      <h2 className="text-lg font-semibold">Subscription</h2>
      {isPro ? (
        <div className="mt-4 space-y-2">
          <p className="text-sm text-zinc-500">
            Status: <span className="font-medium text-zinc-900">{sub?.status}</span>
          </p>
          {sub?.current_period_end && (
            <p className="text-sm text-zinc-500">
              Renews:{" "}
              <span className="font-medium text-zinc-900">
                {new Date(sub.current_period_end).toLocaleDateString()}
              </span>
            </p>
          )}
          <ManageBillingButton />
        </div>
      ) : (
        <div className="mt-4">
          <p className="text-sm text-zinc-500">You&apos;re on the free plan.</p>
          <Link
            href="/checkout"
            className="mt-2 inline-flex items-center rounded-md bg-zinc-900 px-4 py-2
                       text-sm font-medium text-white hover:bg-zinc-700"
          >
            Upgrade to Pro →
          </Link>
        </div>
      )}
    </div>
  );
}

Cancellation: at period end vs immediately

By default, the Stripe Customer Portal cancels subscriptions at the end of the current billing period — the user keeps access until the period ends, then customer.subscription.deleted fires. This is the right default.

You can configure this in the portal settings. Immediate cancellation (with or without proration) is also available, but most SaaS products keep the default because it reduces cancellation friction and gives you time to trigger a win-back email.

Testing in test mode

The portal works exactly the same in test mode. Use a test customer created by your checkout flow, then visit the portal URL. You can cancel, change plans, and trigger the corresponding webhook events — all without touching real money.

# Forward webhooks to localhost
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Trigger a subscription update
stripe trigger customer.subscription.updated

What GetLaunchpad includes

GetLaunchpad ships with the complete portal integration: the /api/stripe/portal route, the ManageBillingButton component, and webhook handling for customer.subscription.updated and customer.subscription.deleted. Subscription status is stored in Supabase and read server-side — no client-side Stripe calls needed.

Share this article:Share on X

Ready to ship faster?

GetLaunchpad gives you everything covered in this guide — pre-configured, tested, and production-ready. Skip the setup and focus on your product.

Get the boilerplate →

More articles