Skip to main content

Add Stripe Customer Portal to Any SaaS Boilerplate

·StarterPick Team
stripebillingcustomer-portalsaas-boilerplatesubscription-management

TL;DR

The Stripe Customer Portal is the fastest way to handle subscription management — upgrades, downgrades, cancellations, invoices, and payment method updates — without building any UI. Stripe hosts it, Stripe handles it, and you get webhook events when things change. Adding it to any SaaS boilerplate takes under an hour. The only catch: you need to configure it in your Stripe Dashboard first (5 minutes) and handle the webhooks that fire when users make changes.

Key Takeaways

  • What Stripe Portal handles: plan upgrades/downgrades, cancellation, payment method update, invoice history
  • What you build: one API route to create a portal session (< 20 lines)
  • Configuration: set up in Stripe Dashboard, not in your code
  • Webhooks required: customer.subscription.updated, customer.subscription.deleted
  • Works with any boilerplate: ShipFast, T3 Stack, Makerkit, Supastarter — same code
  • Time to add: 30-60 minutes including webhook handling

What the Customer Portal Gives You for Free

Without building any UI:

Customer Portal Features:
✅ View current plan and billing date
✅ Upgrade or downgrade plan
✅ Cancel subscription (with optional pause instead of cancel)
✅ Update payment method (card, SEPA, etc.)
✅ View and download all invoices
✅ Update billing address and tax info
✅ Manage multiple subscriptions

Things you still need to build:
❌ Your pricing page (show plans before subscribing)
❌ Initial checkout (Stripe Checkout or custom)
❌ Post-change UI updates (webhook handling)

Step 1: Configure in Stripe Dashboard

Before writing any code, configure the portal in your Stripe Dashboard:

Stripe Dashboard → Settings → Billing → Customer portal

Configure:
1. Business information
   - Business name (shows in portal header)
   - Privacy policy URL
   - Terms of service URL
   - Support URL (link to your support page)

2. Features
   ✅ Invoice history (always enable)
   ✅ Payment methods (always enable)
   ✅ Subscriptions → Enable customer cancellations
   ✅ Subscriptions → Allow plan switches

3. Cancellation options
   → Allow cancel at end of period (recommended)
   → Optional: offer pause instead of cancel

4. Subscription details
   → List your price IDs to allow upgrades/downgrades between

5. Save and get your portal configuration ID
   (looks like: bpc_1234567890)

Step 2: The Portal Session API Route

This is the only code you write — create a portal session and redirect:

// app/api/billing/portal/route.ts
// Works in: ShipFast, T3 Stack, Makerkit, Supastarter — any Next.js boilerplate

import Stripe from 'stripe';
import { auth } from '@/lib/auth';  // Your boilerplate's auth helper
import { db } from '@/lib/db';

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

export async function POST(req: Request) {
  // 1. Get authenticated user:
  const session = await auth();
  if (!session?.user) {
    return new Response('Unauthorized', { status: 401 });
  }

  // 2. Get their Stripe customer ID from your database:
  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: { stripeCustomerId: true },
  });

  if (!user?.stripeCustomerId) {
    return new Response('No billing account found', { status: 404 });
  }

  // 3. Create the portal session:
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
    // Optional: use a specific portal configuration
    // configuration: process.env.STRIPE_PORTAL_CONFIG_ID,
  });

  // 4. Redirect to the Stripe-hosted portal:
  return Response.redirect(portalSession.url);
}

That's it for the core route. The entire portal UI is hosted by Stripe — you don't build anything else for the portal itself.


Step 3: The "Manage Billing" Button

// components/BillingSection.tsx
'use client';

export function BillingSection({
  plan,
  nextBillingDate,
  amount,
}: {
  plan: string;
  nextBillingDate: string;
  amount: number;
}) {
  const handleManageBilling = async () => {
    // POST to your API route, which redirects to Stripe portal:
    const res = await fetch('/api/billing/portal', { method: 'POST' });

    if (res.redirected) {
      window.location.href = res.url;
    }
  };

  return (
    <div className="rounded-lg border p-6">
      <h3 className="text-lg font-semibold">Billing</h3>
      <div className="mt-4 space-y-2">
        <div className="flex justify-between">
          <span className="text-gray-500">Current plan</span>
          <span className="font-medium capitalize">{plan}</span>
        </div>
        <div className="flex justify-between">
          <span className="text-gray-500">Next billing date</span>
          <span>{nextBillingDate}</span>
        </div>
        <div className="flex justify-between">
          <span className="text-gray-500">Amount</span>
          <span>${(amount / 100).toFixed(2)}/month</span>
        </div>
      </div>
      <button
        onClick={handleManageBilling}
        className="mt-6 w-full rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50"
      >
        Manage billing
      </button>
    </div>
  );
}

Or as a simple link (simpler approach):

// Direct form submit → redirect:
export function ManageBillingButton() {
  return (
    <form action="/api/billing/portal" method="POST">
      <button type="submit" className="btn-outline">
        Manage billing
      </button>
    </form>
  );
}

Step 4: Handle Webhooks (Critical)

When a customer makes changes in the portal, Stripe sends webhooks. You must handle these to keep your database in sync:

// app/api/webhooks/stripe/route.ts
// (Most boilerplates already have this — add cases for portal events)

import Stripe from 'stripe';
import { db } from '@/lib/db';

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 = req.headers.get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
  } catch (err) {
    return new Response('Webhook signature verification failed', { status: 400 });
  }

  switch (event.type) {
    // ✅ Subscription upgraded or downgraded:
    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription;
      await db.subscription.update({
        where: { stripeSubscriptionId: subscription.id },
        data: {
          status: subscription.status,
          stripePriceId: subscription.items.data[0].price.id,
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
          cancelAtPeriodEnd: subscription.cancel_at_period_end,
        },
      });

      // Update user's plan based on new price:
      const newPlan = getPlanFromPriceId(subscription.items.data[0].price.id);
      await db.user.update({
        where: { stripeCustomerId: subscription.customer as string },
        data: { plan: newPlan },
      });
      break;
    }

    // ✅ Subscription canceled (from portal or programmatically):
    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      await db.subscription.update({
        where: { stripeSubscriptionId: subscription.id },
        data: { status: 'canceled' },
      });
      await db.user.update({
        where: { stripeCustomerId: subscription.customer as string },
        data: { plan: 'free' },
      });
      break;
    }

    // ✅ Payment method updated (no DB change needed, just log):
    case 'payment_method.attached': {
      console.log('Payment method attached:', event.data.object.id);
      break;
    }

    // ✅ Invoice paid (good to track):
    case 'invoice.payment_succeeded': {
      const invoice = event.data.object as Stripe.Invoice;
      await db.invoice.create({
        data: {
          stripeInvoiceId: invoice.id,
          stripeCustomerId: invoice.customer as string,
          amount: invoice.amount_paid,
          status: 'paid',
          paidAt: new Date(invoice.status_transitions.paid_at! * 1000),
        },
      });
      break;
    }
  }

  return new Response('OK', { status: 200 });
}

function getPlanFromPriceId(priceId: string): string {
  const planMap: Record<string, string> = {
    [process.env.STRIPE_PRICE_PRO_MONTHLY!]: 'pro',
    [process.env.STRIPE_PRICE_PRO_ANNUAL!]: 'pro',
    [process.env.STRIPE_PRICE_ENTERPRISE!]: 'enterprise',
  };
  return planMap[priceId] ?? 'free';
}

Boilerplate-Specific Integration

ShipFast

ShipFast includes Stripe but the portal route isn't always pre-built:

// Add to ShipFast's existing /api/stripe/ directory:
// app/api/stripe/portal/route.ts

import { getServerSession } from 'next-auth';  // or Supabase auth
import { authOptions } from '@/libs/next-auth';
import Stripe from 'stripe';
import connectMongo from '@/libs/mongoose';
import User from '@/models/User';

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

export async function POST() {
  const session = await getServerSession(authOptions);
  await connectMongo();

  const user = await User.findById(session?.user?.id);
  if (!user?.customerId) {
    return new Response('No billing account', { status: 404 });
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.customerId,    // ShipFast uses 'customerId' field
    return_url: `${process.env.NEXTAUTH_URL}/dashboard`,
  });

  return Response.redirect(portalSession.url);
}

T3 Stack

// app/api/billing/portal/route.ts
import { auth } from '@/server/auth';  // T3 Stack NextAuth
import { db } from '@/server/db';
import Stripe from 'stripe';

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

export async function POST() {
  const session = await auth();
  if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });

  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: { stripeCustomerId: true },
  });

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user!.stripeCustomerId!,
    return_url: `${process.env.NEXTAUTH_URL}/dashboard/billing`,
  });

  return Response.redirect(portalSession.url);
}

Supastarter

Supastarter already has this — look in:

app/[locale]/(app)/[organization]/settings/billing/page.tsx

The billing page has a "Manage Subscription" button that calls:

/api/billing/portal (Supastarter's built-in route)

Advanced: Pre-populate the Portal at a Specific Flow

Instead of showing the portal homepage, deep-link to a specific section:

// Deep-link to "Update Payment Method" directly:
const portalSession = await stripe.billingPortal.sessions.create({
  customer: user.stripeCustomerId,
  return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
  flow_data: {
    type: 'payment_method_update',  // Skip portal homepage
  },
});

// Deep-link to subscription cancellation:
const portalSession = await stripe.billingPortal.sessions.create({
  customer: user.stripeCustomerId,
  return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  flow_data: {
    type: 'subscription_cancel',
    subscription_cancel: {
      subscription: user.stripeSubscriptionId,
    },
  },
});

Use case: if you want a "Cancel subscription" button that goes directly to the cancellation flow (with Stripe's cancellation survey and retention prompts).


Testing the Portal

# Install Stripe CLI:
brew install stripe/stripe-cli/stripe

# Listen for webhooks locally:
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Test a subscription update:
stripe trigger customer.subscription.updated

# Test cancellation:
stripe trigger customer.subscription.deleted

Test the full portal flow:

  1. Create a test customer in Stripe Test Mode
  2. Subscribe them to a plan
  3. Hit your /api/billing/portal endpoint
  4. You'll be redirected to a test portal
  5. Try upgrading, canceling — verify webhooks fire and your DB updates

Find SaaS boilerplates with billing pre-configured at StarterPick.

Comments