Vercel Logo

Subscription Actions

Users need control over their subscriptions - updating payment methods, viewing invoices, cancelling, or changing plans. Stripe's Customer Portal handles all of this on a hosted page, so you don't need to build these features yourself. You just redirect users there.

Outcome

Add a "Manage Subscription" button that redirects users to the Stripe Customer Portal.

Fast Track

  1. Implement createStripePortal() Server Action in utils/stripe/server.ts
  2. Create components/subscription-actions.tsx with the button
  3. Add the component to the subscription page

Hands-on Exercise 2.5

Add subscription management via Stripe Portal:

Requirements:

  1. Implement createStripePortal() Server Action to create portal sessions
  2. Create a client component with "Manage Subscription" button
  3. Redirect to Stripe Portal URL on click
  4. Show loading state during portal creation
  5. Add the component to the subscription page

Implementation hints:

  • Use stripe.billingPortal.sessions.create() to create a portal session
  • The portal needs the Stripe customer ID
  • Use router.push() to redirect to the portal URL
  • Configure your portal settings in Stripe dashboard

Try It

  1. Configure portal in Stripe:

  2. Start dev server:

    pnpm dev
  3. Navigate to subscription:

  4. Click "Manage Subscription":

    • You should be redirected to Stripe's Customer Portal
    • The portal shows billing history, payment methods, and cancel option
  5. Return to app:

    • Use the portal's "Return to..." link
    • You should land back on your subscription page

Commit

git add -A
git commit -m "feat(billing): add subscription portal access"

Done-When

  • createStripePortal() Server Action creates portal sessions
  • "Manage Subscription" button appears for active subscriptions
  • Clicking button redirects to Stripe Customer Portal
  • Loading state shows while creating session
  • Portal shows billing management options
  • Return link brings user back to app

Solution

Step 1: Implement Portal Server Action

Add createStripePortal() to utils/stripe/server.ts:

utils/stripe/server.ts
"use server";
 
import { stripe } from "./config";
import { createSupabaseClient } from "@/utils/supabase/server";
import { createOrRetrieveCustomer } from "@/utils/supabase/admin";
 
function getURL(path: string = "") {
  let url =
    process.env.NEXT_PUBLIC_SITE_URL ??
    process.env.VERCEL_URL ??
    "http://localhost:3000";
 
  url = url.startsWith("http") ? url : `https://${url}`;
  url = url.endsWith("/") ? url.slice(0, -1) : url;
 
  return path ? `${url}${path}` : url;
}
 
// ... checkoutWithStripe from lesson 2.3 ...
 
export async function createStripePortal(
  currentPath: string = "/protected/subscription"
): Promise<string> {
  try {
    const supabase = await createSupabaseClient();
    const {
      data: { user },
    } = await supabase.auth.getUser();
 
    if (!user) {
      throw new Error("Could not get user session.");
    }
 
    const customer = await createOrRetrieveCustomer({
      uuid: user.id,
      email: user.email || "",
    });
 
    const { url } = await stripe.billingPortal.sessions.create({
      customer,
      return_url: getURL(currentPath),
    });
 
    if (!url) {
      throw new Error("Could not create billing portal");
    }
 
    return url;
  } catch (error) {
    console.error("Error creating portal:", error);
    throw error;
  }
}

The portal session:

  • customer - The Stripe customer ID (required)
  • return_url - Where users land after leaving the portal

Step 2: Create Subscription Actions Component

Create components/subscription-actions.tsx:

components/subscription-actions.tsx
"use client";
 
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { createStripePortal } from "@/utils/stripe/server";
import { SubscriptionWithPrice } from "@/utils/supabase/queries";
import { useState } from "react";
import { useRouter } from "next/navigation";
 
export default function SubscriptionActions({
  subscription,
}: {
  subscription: SubscriptionWithPrice;
}) {
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();
 
  async function handleManageSubscription() {
    setIsLoading(true);
    try {
      const portalUrl = await createStripePortal();
      router.push(portalUrl);
    } catch (error) {
      console.error("Error opening portal:", error);
      setIsLoading(false);
    }
  }
 
  return (
    <div className="flex gap-2">
      <Button
        onClick={handleManageSubscription}
        disabled={isLoading}
        variant="outline"
        className="flex-1"
      >
        <Spinner variant="primary" isLoading={isLoading} />
        {isLoading ? "Loading..." : "Manage Subscription"}
      </Button>
    </div>
  );
}

Key patterns:

  • Client component - Uses useState and event handlers
  • Server Action call - createStripePortal() runs on the server
  • Redirect - router.push() navigates to the portal URL
  • Loading state - Shows spinner while creating session

Step 3: Update Subscription Page

Update app/protected/subscription/page.tsx to include the actions:

app/protected/subscription/page.tsx
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { createSupabaseClient } from "@/utils/supabase/server";
import { getSubscription } from "@/utils/supabase/queries";
import SubscriptionActions from "@/components/subscription-actions";
 
export default async function Page() {
  const supabase = await createSupabaseClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
 
  const subscription = await getSubscription(supabase);
 
  const formatDate = (dateString: string) => {
    return new Date(dateString).toLocaleDateString("en-US", {
      year: "numeric",
      month: "long",
      day: "numeric",
    });
  };
 
  const formatPrice = (amount: number | null, currency: string) => {
    if (!amount) return "N/A";
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: currency || "usd",
      minimumFractionDigits: 0,
    }).format(amount / 100);
  };
 
  return (
    <div className="space-y-8">
      <div className="flex items-center justify-between">
        <div className="flex flex-col">
          <h1 className="text-2xl font-medium">My Membership</h1>
          <p className="text-muted-foreground mt-2">
            Manage your guild membership
          </p>
        </div>
      </div>
 
      <div className="space-y-6">
        {!subscription ? (
          <Card className="p-6">
            <h2 className="font-medium">No Active Membership</h2>
            <p className="text-muted-foreground mt-2">
              You haven't joined a membership tier yet. Visit the Membership
              page to choose a tier and unlock guild benefits.
            </p>
            <Button className="mt-4" asChild>
              <Link href="/protected/pricing">View Membership Tiers</Link>
            </Button>
          </Card>
        ) : (
          <Card className="p-6 space-y-4">
            {/* ... existing subscription details ... */}
 
            <SubscriptionActions subscription={subscription} />
          </Card>
        )}
      </div>
 
      <p className="text-sm text-muted-foreground">
        Signed in as: {user?.email}
      </p>
    </div>
  );
}

How the Portal Works

User clicks "Manage Subscription"
    ↓
handleManageSubscription()
    ↓
createStripePortal() Server Action
    ↓
createOrRetrieveCustomer() → Get Stripe customer ID
    ↓
stripe.billingPortal.sessions.create()
    ↓
Returns { url: "https://billing.stripe.com/..." }
    ↓
router.push(url)
    ↓
Stripe Customer Portal
    ↓
User manages billing
    ↓
Click "Return to..."
    ↓
Redirect to return_url (/protected/subscription)

Stripe Portal Features

Configure what users can do in Stripe Dashboard → Customer Portal:

FeatureDescription
Update payment methodAdd/remove cards
View invoicesDownload PDF invoices
Cancel subscriptionCancel at period end
Switch plansUpgrade/downgrade
Update billing infoChange address/email

Enable only the features you want users to access.

File Structure After This Lesson

utils/stripe/
├── config.ts             ← Server Stripe client
├── client.ts             ← Browser Stripe loader
└── server.ts             ← Updated: + createStripePortal

components/
├── pricing-card.tsx      ← Checkout flow
└── subscription-actions.tsx ← New: portal button

Section Complete

You've now built a complete Stripe integration:

  • Lesson 2.1: Configured Stripe SDK for server and browser
  • Lesson 2.2: Built pricing page with product tiers
  • Lesson 2.3: Implemented Stripe Checkout flow
  • Lesson 2.4: Created subscription management page
  • Lesson 2.5: Added Customer Portal access

Next up in Section 3: You'll learn about entitlements - how to gate features based on subscription status.

Troubleshooting

"Could not get user session" error:

  • User must be signed in before accessing the portal
  • Verify the Supabase session is valid
  • Check cookies are being sent with the request

Portal shows "No active subscriptions":

  • Verify the user has a subscription in Stripe
  • Check that the customer ID mapping is correct
  • Ensure the subscription was created for this customer

"Return to" link goes to wrong URL:

  • Check return_url in createStripePortal()
  • Verify NEXT_PUBLIC_SITE_URL is set correctly in production

Portal doesn't show expected options:

  • Configure the portal in Stripe Dashboard
  • Some features (like switching plans) require additional setup
  • Test mode and live mode have separate configurations