React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

stripecustomer portalbillingsubscriptionsself-service

Customer Portal

Recipe

Create a portal session on the server and redirect the customer to Stripe's hosted billing management page where they can update payment methods, change plans, cancel subscriptions, and view invoice history.

Create a Server Action for the portal redirect:

// app/actions/portal.ts
"use server";
 
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
 
export async function createPortalSession() {
  const session = await auth();
  if (!session?.user?.id) throw new Error("Not authenticated");
 
  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: { stripeCustomerId: true },
  });
 
  if (!user?.stripeCustomerId) {
    throw new Error("No Stripe customer found");
  }
 
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
  });
 
  redirect(portalSession.url);
}

Or use a Route Handler:

// app/api/portal/route.ts
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
 
export async function POST() {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: { stripeCustomerId: true },
  });
 
  if (!user?.stripeCustomerId) {
    return NextResponse.json(
      { error: "No billing account" },
      { status: 404 }
    );
  }
 
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
  });
 
  return NextResponse.json({ url: portalSession.url });
}

Working Example

// app/account/page.tsx
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { createPortalSession } from "@/app/actions/portal";
import { redirect } from "next/navigation";
 
export default async function AccountPage() {
  const session = await auth();
  if (!session?.user?.id) redirect("/login");
 
  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: {
      plan: true,
      planStatus: true,
      stripeCustomerId: true,
      currentPeriodEnd: true,
    },
  });
 
  return (
    <div className="max-w-2xl mx-auto p-8">
      <h1 className="text-2xl font-bold mb-6">Account Settings</h1>
 
      <div className="border rounded-lg p-6 mb-6">
        <h2 className="text-lg font-semibold mb-4">Subscription</h2>
 
        <dl className="space-y-2">
          <div className="flex justify-between">
            <dt className="text-gray-600">Current Plan</dt>
            <dd className="font-medium capitalize">{user?.plan ?? "Free"}</dd>
          </div>
          <div className="flex justify-between">
            <dt className="text-gray-600">Status</dt>
            <dd className="font-medium capitalize">
              {user?.planStatus ?? "N/A"}
            </dd>
          </div>
          {user?.currentPeriodEnd && (
            <div className="flex justify-between">
              <dt className="text-gray-600">Current Period Ends</dt>
              <dd className="font-medium">
                {user.currentPeriodEnd.toLocaleDateString()}
              </dd>
            </div>
          )}
        </dl>
      </div>
 
      {user?.stripeCustomerId ? (
        <form action={createPortalSession}>
          <button
            type="submit"
            className="bg-gray-900 text-white px-6 py-3 rounded-lg hover:bg-gray-800"
          >
            Manage Subscription
          </button>
        </form>
      ) : (
        <a
          href="/pricing"
          className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700"
        >
          View Plans
        </a>
      )}
    </div>
  );
}

A client component version with loading state:

// components/manage-billing-button.tsx
"use client";
 
import { useState } from "react";
import { createPortalSession } from "@/app/actions/portal";
 
export function ManageBillingButton() {
  const [loading, setLoading] = useState(false);
 
  async function handleClick() {
    setLoading(true);
    try {
      await createPortalSession();
    } catch {
      setLoading(false);
    }
  }
 
  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className="bg-gray-900 text-white px-6 py-3 rounded-lg hover:bg-gray-800 disabled:opacity-50"
    >
      {loading ? "Opening portal..." : "Manage Subscription"}
    </button>
  );
}

Deep Dive

How It Works

  • The Customer Portal is a Stripe-hosted page where customers can self-manage their billing. It supports updating payment methods, switching plans, canceling subscriptions, and viewing invoices.
  • stripe.billingPortal.sessions.create generates a short-lived URL (valid for a few minutes) that logs the customer into the portal.
  • The return_url is where Stripe redirects the customer after they leave the portal.
  • Portal behavior is configured in the Stripe Dashboard under Settings and Customer Portal. You control which features are available (cancellation, plan switching, etc.).
  • When a customer makes changes in the portal (e.g., cancels), Stripe fires webhook events (e.g., customer.subscription.updated) that your webhook handler should process.

Variations

Configure portal programmatically:

await stripe.billingPortal.configurations.create({
  features: {
    subscription_cancel: {
      enabled: true,
      mode: "at_period_end",
      proration_behavior: "none",
    },
    subscription_update: {
      enabled: true,
      default_allowed_updates: ["price"],
      proration_behavior: "create_prorations",
      products: [
        {
          product: "prod_xxx",
          prices: ["price_monthly", "price_annual"],
        },
      ],
    },
    payment_method_update: { enabled: true },
    invoice_history: { enabled: true },
  },
  business_profile: {
    headline: "Manage your subscription",
  },
});

Deep-link to a specific portal section:

const portalSession = await stripe.billingPortal.sessions.create({
  customer: customerId,
  return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
  flow_data: {
    type: "subscription_cancel",
    subscription_cancel: {
      subscription: subscriptionId,
    },
  },
});

TypeScript Notes

  • stripe.billingPortal.sessions.create returns Promise<Stripe.BillingPortal.Session>.
  • The url property on the portal session is always a string (never null), unlike Checkout Sessions.
  • Portal configurations are typed as Stripe.BillingPortal.Configuration.
import type Stripe from "stripe";
 
type PortalSession = Stripe.BillingPortal.Session;

Gotchas

  • The Customer Portal must be configured in the Stripe Dashboard before use. Without configuration, creating a session will fail.
  • Portal sessions expire quickly. Always generate a fresh URL when the customer clicks "Manage Subscription" -- never store or cache portal URLs.
  • The portal only works for customers who have at least one subscription or payment method on file. Creating a portal session for a customer with no billing history will show a mostly empty page.
  • Changes made in the portal trigger webhook events. Make sure your webhook handler processes customer.subscription.updated and customer.subscription.deleted to keep your database in sync.
  • The redirect() call in a Server Action throws internally. Do not catch this error or the redirect will not happen.
  • If you allow plan switching in the portal, make sure the product and price IDs in the portal configuration match your actual Stripe products.

Alternatives

ApproachProsCons
Stripe Customer PortalZero UI code, handles all billing managementLimited branding, leaves your app
Custom billing UI with Stripe APIFull control over design and flowSignificant development effort
Portal with flow_dataDeep-link to specific actionsStill Stripe-hosted
Embedded portal (beta)Stays in your appLimited availability

FAQs

What can customers do in the Stripe Customer Portal?
  • Update payment methods
  • Switch between subscription plans
  • Cancel subscriptions
  • View and download invoice history
How do you create a portal session and redirect the user?
const portalSession = await stripe.billingPortal.sessions.create({
  customer: stripeCustomerId,
  return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
redirect(portalSession.url);
Gotcha: What happens if you create a portal session for a customer with no billing history?

The portal page renders but is mostly empty. It only shows meaningful content for customers who have at least one subscription or payment method on file.

Where is the Customer Portal configured?

In the Stripe Dashboard under Settings > Customer Portal. You control which features are available (cancellation, plan switching, payment method updates, invoice history). Without Dashboard configuration, creating a session will fail.

Can you deep-link to a specific portal action like cancellation?
const portalSession = await stripe.billingPortal.sessions.create({
  customer: customerId,
  return_url: "...",
  flow_data: {
    type: "subscription_cancel",
    subscription_cancel: { subscription: subscriptionId },
  },
});
How long are portal session URLs valid?

Portal session URLs expire within a few minutes. Always generate a fresh URL when the user clicks "Manage Subscription" -- never store or cache portal URLs.

What webhook events should you handle when customers make changes in the portal?
  • customer.subscription.updated -- plan changes, payment method updates
  • customer.subscription.deleted -- subscription canceled
  • Always keep your database in sync by processing these events in your webhook handler
Gotcha: Why must you not catch the error thrown by redirect() in a Server Action?

redirect() throws a NEXT_REDIRECT error internally to trigger the redirect. If your try/catch block catches and swallows this error, the redirect silently fails and the user stays on the current page.

How do you configure the portal programmatically instead of through the Dashboard?
await stripe.billingPortal.configurations.create({
  features: {
    subscription_cancel: { enabled: true, mode: "at_period_end" },
    subscription_update: {
      enabled: true,
      default_allowed_updates: ["price"],
      products: [{ product: "prod_xxx", prices: ["price_a", "price_b"] }],
    },
    payment_method_update: { enabled: true },
    invoice_history: { enabled: true },
  },
});
TypeScript: What type does stripe.billingPortal.sessions.create return?

It returns Promise<Stripe.BillingPortal.Session>. Unlike Checkout Sessions, the url property is always a string (never null).

What is the difference between using a Server Action vs a Route Handler for the portal redirect?
  • Server Action: simpler, call from a form action, use redirect() directly
  • Route Handler: returns JSON with the URL, client handles the redirect, useful for non-form flows
  • Both require authentication checks before creating the portal session