Vibestacks LogoVibestacks
IntegrationsStripe & Payments

Billing Portal

Let users manage their own subscriptions, update payment methods, and view invoices using Stripe's hosted billing portal.

Stripe's Billing Portal lets your users self-manage their subscriptions without you building custom UI. Users can update payment methods, change plans, cancel, and view invoices.

What Users Can Do

The billing portal allows users to:

  • ✅ Update payment method (card, bank account)
  • ✅ View and download invoices
  • ✅ Cancel subscription
  • ✅ View subscription details
  • ✅ Update billing information

Hosted by Stripe

The billing portal is hosted entirely by Stripe. You just redirect users there - no UI to build or maintain.

Opening the Portal

Use the subscription.billingPortal() method to redirect users:

components/billing-button.tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { subscription } from "@/lib/auth-client";
import { stripeConfig } from "@/config/app";

export function ManageBillingButton() {
  const [isLoading, setIsLoading] = useState(false);

  const handleClick = async () => {
    if (!subscription) return;
    
    setIsLoading(true);
    
    const { error } = await subscription.billingPortal({
      returnUrl: stripeConfig.urls.billingReturn,
      disableRedirect: false,
    });

    if (error) {
      console.error(error);
      setIsLoading(false);
    }
    
    // User is redirected to Stripe
  };

  return (
    <Button onClick={handleClick} disabled={isLoading}>
      {isLoading ? "Loading..." : "Manage Billing"}
    </Button>
  );
}

Parameters

OptionTypeDescription
returnUrlstringWhere to redirect after leaving portal
localestringPortal language (e.g., "en", "de", "fr")
disableRedirectbooleanIf true, returns URL instead of redirecting

Return URL

Configure where users return after using the portal:

config/app.ts
export const stripeConfig = {
  urls: {
    billingReturn: "/dashboard/billing",
  },
  // ...
};

Billing Dashboard Page

Create a billing page that shows subscription status and portal access:

app/(dashboard)/dashboard/billing/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { ManageBillingButton } from "@/components/billing-button";
import { getPlan, formatPrice } from "@/config/app";

export default async function BillingPage() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    redirect("/sign-in");
  }

  // Get user's subscription from your database
  const subscription = await getSubscription(session.user.id);
  const plan = subscription ? getPlan(subscription.plan) : getPlan("free");

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">Billing</h1>

      <div className="rounded-lg border p-6">
        <h2 className="text-lg font-semibold">Current Plan</h2>
        
        <div className="mt-4 flex items-center justify-between">
          <div>
            <p className="text-2xl font-bold capitalize">{plan?.name}</p>
            <p className="text-muted-foreground">
              {plan?.priceId 
                ? `${formatPrice(plan.displayPrice.monthly)}/month`
                : "Free forever"
              }
            </p>
          </div>
          
          {subscription && (
            <ManageBillingButton />
          )}
        </div>

        {subscription?.status === "trialing" && (
          <p className="mt-4 text-sm text-blue-600">
            Trial ends on {new Date(subscription.trialEnd!).toLocaleDateString()}
          </p>
        )}

        {subscription?.cancelAtPeriodEnd && (
          <p className="mt-4 text-sm text-amber-600">
            Cancels on {new Date(subscription.periodEnd!).toLocaleDateString()}
          </p>
        )}
      </div>

      {!subscription && (
        <div className="rounded-lg border border-dashed p-6 text-center">
          <p className="text-muted-foreground">
            You're on the free plan.
          </p>
          <a href="/pricing" className="mt-2 inline-block text-primary underline">
            Upgrade to unlock more features
          </a>
        </div>
      )}
    </div>
  );
}

Customizing the Portal

Configure the portal appearance in Stripe Dashboard:

  1. Go to Settings → Billing → Customer portal
  2. Configure which features are enabled
  3. Set your business information
  4. Add your logo and brand colors

Portal Settings

SettingDescription
Invoice historyLet customers view past invoices
Customer informationAllow updating billing details
Payment methodsAllow adding/removing payment methods
SubscriptionsAllow canceling, switching plans
Cancellation reasonsCollect feedback on cancellation

Subscription Actions via Portal

Users can perform these actions in the portal:

Cancel Subscription

The portal handles cancellation gracefully:

  • Shows cancellation effective date
  • Optionally collects cancellation reason
  • Allows immediate or end-of-period cancellation

Restore Canceled Subscription

If a user cancels but it hasn't ended yet, they can restore it from the portal.

Update Payment Method

Users can:

  • Add new cards
  • Set default payment method
  • Remove old payment methods

Alternative: In-App Actions

If you prefer handling some actions in your app instead of the portal:

Cancel in App

"use client";

import { subscription } from "@/lib/auth-client";
import { stripeConfig } from "@/config/app";

async function handleCancel() {
  const { error } = await subscription.cancel({
    returnUrl: stripeConfig.urls.billingReturn,
  });
  
  if (error) {
    console.error(error);
  }
  // Redirects to Stripe portal's cancellation flow
}

Restore in App

"use client";

import { subscription } from "@/lib/auth-client";

async function handleRestore() {
  const { error } = await subscription.restore();
  
  if (error) {
    console.error(error);
    return;
  }
  
  // Subscription restored!
  window.location.reload();
}

Success/Cancel Handling

Handle return from the portal:

app/(dashboard)/dashboard/billing/page.tsx
import { Suspense } from "react";

function BillingNotifications({ searchParams }) {
  const success = searchParams?.success;
  const canceled = searchParams?.canceled;

  return (
    <>
      {success && (
        <div className="rounded-lg bg-green-50 p-4 text-green-800">
          Your billing has been updated successfully!
        </div>
      )}
      {canceled && (
        <div className="rounded-lg bg-amber-50 p-4 text-amber-800">
          Billing update was canceled.
        </div>
      )}
    </>
  );
}

export default function BillingPage({ searchParams }) {
  return (
    <div>
      <Suspense fallback={null}>
        <BillingNotifications searchParams={searchParams} />
      </Suspense>
      {/* Rest of billing page */}
    </div>
  );
}

Testing the Portal

  1. Create a test subscription using a test card
  2. Click "Manage Billing" to open the portal
  3. Try updating payment method, viewing invoices, canceling
  4. Verify you're redirected back correctly

Test Mode Portal

In test mode, the portal works the same as production but won't process real payments.

Common Issues

"No customer portal configuration"

You haven't configured the portal in Stripe Dashboard. Go to Settings → Billing → Customer portal.

User Not Redirected Back

Check that returnUrl is a valid absolute URL or relative path that exists in your app.

Can't Cancel Subscription

Enable "Cancel subscriptions" in the portal settings in Stripe Dashboard.

Next Steps

Now you have a complete Stripe integration with:

  • ✅ Subscription plans
  • ✅ Checkout flow
  • ✅ Trial periods
  • ✅ Webhook handling
  • ✅ Lifecycle emails
  • ✅ Self-service billing portal

Consider adding:

  • Usage-based billing for metered features
  • Team/organization billing
  • Multiple subscriptions per user