---
title: "Subscription Actions"
description: "Implement subscription management actions by redirecting users to Stripe's Customer Portal for billing self-service."
canonical_url: "https://vercel.com/academy/subscription-store/subscription-actions"
md_url: "https://vercel.com/academy/subscription-store/subscription-actions.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-11T14:17:45.623Z"
content_type: "lesson"
course: "subscription-store"
course_title: "Launch a Subscription Store with Vercel and Stripe"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Subscription Actions

# 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:**
   - Go to [Stripe Dashboard → Settings → Customer Portal](https://dashboard.stripe.com/test/settings/billing/portal)
   - Enable the features you want (cancel, update payment, etc.)
   - Save configuration

2. **Start dev server:**
   ```bash
   pnpm dev
   ```

3. **Navigate to subscription:**
   - Sign in with an account that has a subscription
   - Go to <http://localhost:3000/protected/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

```bash
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`:

```typescript title="utils/stripe/server.ts" {32-54}
"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`:

```typescript title="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:

```typescript title="app/protected/subscription/page.tsx" {6,65}
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](https://dashboard.stripe.com/test/settings/billing/portal):

| Feature               | Description           |
| --------------------- | --------------------- |
| Update payment method | Add/remove cards      |
| View invoices         | Download PDF invoices |
| Cancel subscription   | Cancel at period end  |
| Switch plans          | Upgrade/downgrade     |
| Update billing info   | Change 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


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
