pealdevDOCS
Back to peal.dev

STRIPE STARTER · CUSTOMER PORTAL

Customer Portal

Stripe-hosted page where customers cancel, update card, change plan, download invoices. You don't build any of it. Toggle in payments.config.ts, customize in the Stripe dashboard.

The Customer Portal is Stripe's hosted self-serve page. Your customer clicks "Manage billing" → they're on stripe.com (well, billing.stripe.com), updating their card, swapping plans, downloading invoices. You wire a button and a return URL. That's it.

Step 1: Enable it

TypeScript
// payments.config.ts
customerPortal: { enabled: true },

Step 2: Configure what shows up

Open dashboard.stripe.com → Settings → Billing → Customer portal. Toggle which features customers can use: cancel, change plan, update payment method, download invoices, etc. The portal honors these settings — you don't write code to gate features.

Step 3: Drop in the button

TSX
import { CustomerPortalButton } from "@/components/checkout/CustomerPortalButton";

<CustomerPortalButton
  customerId="cus_xxx"           // the Stripe Customer ID
  returnPath="/dashboard"        // where to land after closing the portal
  variant="outline"
>
  Manage Subscription
</CustomerPortalButton>

Where does the Customer ID come from?

Stripe creates a Customer the first time a user checks out. The ID arrives on the session object — you persist it in your own DB linked to your user. Subsequent portal calls look it up from there. This template doesn't include a DB layer (intentional — bring your own), but the bridge point is the webhook handler.

TypeScript
// In your onCheckoutSessionCompleted handler:
const customerId = getCustomerId(session.customer); // "cus_xxx"
await db.users.update(userId, { stripeCustomerId: customerId });

// Later, when rendering a settings page:
<CustomerPortalButton customerId={user.stripeCustomerId!} />

Return path — and the open-redirect plug

returnPath is the URL the portal sends users back to. Our schema in src/lib/validation.ts requires it to start with / and match a strict regex. You can't pass a full URL — that prevents an attacker from rewriting the button's returnPath to redirect off-domain.

The portal needs a Customer ID — not a user ID

If you render the button before the user has ever checked out, you have no Customer ID yet. Either gate the button on user.stripeCustomerId existing, or guide the user to checkout first. The Server Action returns "Invalid input" if you pass anything that isn't cus_xxx.

Portal changes fire webhooks

When a user updates their plan in the portal, customer.subscription.updated fires. When they cancel, you'll see cancel_at_period_end=true in the same event. The webhook is where you react — not on a return-URL callback (which the user might never reach).

Custom portal branding

In the same Stripe Settings page, you can upload a logo, set primary color, customize the language. Free, takes a minute, makes the portal feel like part of your product instead of a third-party detour.