Vercel Logo

Subscription Management Page

After checkout, users land on the subscription page. They need to see what they're paying for, when it renews, and whether it's active. This page becomes the hub for managing their billing relationship with your app.

Outcome

Build a subscription management page that displays the user's active subscription with plan details, price, and status.

Fast Track

  1. Create app/protected/subscription/page.tsx
  2. Fetch subscription with getSubscription() from utils/supabase/queries.ts
  3. Display subscription card with status indicator

Hands-on Exercise 2.4

Build a subscription management page:

Requirements:

  1. Create subscription page at /protected/subscription
  2. Fetch subscription using getSubscription() (implemented in lesson 2.2)
  3. Display subscription card with:
    • Product name
    • Price per interval
    • Status indicator (active, trialing, cancelling)
    • Current billing period dates
  4. Add loading state skeleton
  5. Handle empty state (no subscription)

Implementation hints:

  • Use the Server Component pattern with Supabase client
  • getSubscription() returns subscription with nested price and product
  • Format dates using toLocaleDateString()
  • Check cancel_at_period_end for "cancelling" state

Try It

  1. Ensure you have a subscription:

    • Complete a test checkout from lesson 2.3
    • Or create one via the Stripe dashboard
  2. Visit subscription page:

  3. Verify display:

    • Product name shows correctly
    • Price shows as $X.XX/month
    • Status indicator is green for active
    • Billing period dates display
  4. Test loading state:

    • Refresh the page
    • Loading skeleton should appear briefly

Commit

git add -A
git commit -m "feat(billing): add subscription management page"

Done-When

  • Subscription page renders at /protected/subscription
  • Active subscription displays with details
  • Product name and price shown
  • Status indicator shows correct state
  • Billing period dates formatted correctly
  • Loading skeleton displays while fetching
  • Empty state handles no subscription

Solution

Step 1: Create Loading State

The loading state is already in the starter at app/protected/subscription/loading.tsx.

Step 2: Create Subscription Page

Update app/protected/subscription/page.tsx:

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";
 
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">
            <div className="flex items-center justify-between">
              <h2 className="font-medium text-lg">
                {subscription.prices?.products?.name || "Subscription"}
              </h2>
              <span
                className={`px-2 py-1 text-xs rounded-full ${
                  subscription.status === "active"
                    ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
                    : subscription.status === "trialing"
                      ? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
                      : "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
                }`}
              >
                {subscription.status}
              </span>
            </div>
 
            <div className="text-sm text-muted-foreground space-y-1">
              <p>
                <strong>Price:</strong>{" "}
                {formatPrice(
                  subscription.prices?.unit_amount,
                  subscription.prices?.currency
                )}
                /{subscription.prices?.interval}
              </p>
              <p>
                <strong>Current period:</strong>{" "}
                {formatDate(subscription.current_period_start)} -{" "}
                {formatDate(subscription.current_period_end)}
              </p>
              {subscription.cancel_at_period_end && (
                <p className="text-yellow-600 dark:text-yellow-400">
                  Your subscription will cancel at the end of the current
                  billing period.
                </p>
              )}
            </div>
 
            {/* Subscription actions will be added in lesson 2.5 */}
          </Card>
        )}
      </div>
 
      <p className="text-sm text-muted-foreground">
        Signed in as: {user?.email}
      </p>
    </div>
  );
}

Key patterns:

  • Server Component - Fetches data directly without client-side state
  • getSubscription() - Reuses the query from lesson 2.2
  • Date formatting - toLocaleDateString() for human-readable dates
  • Price formatting - Intl.NumberFormat for currency display
  • Status badge - Visual indicator of subscription state

Subscription Status States

StatusVisualMeaning
activeGreen badgeNormal active subscription
trialingBlue badgeIn free trial period
past_dueYellow badgePayment failed, grace period
canceledNot shownSubscription ended

The cancel_at_period_end flag indicates the user has cancelled but still has access until the period ends.

File Structure After This Lesson

app/protected/
├── page.tsx              ← Account page
├── layout.tsx            ← Protected layout
├── pricing/
│   ├── page.tsx          ← Pricing page
│   └── loading.tsx       ← Loading skeleton
└── subscription/
    ├── page.tsx          ← Updated: subscription page
    └── loading.tsx       ← Loading skeleton (in starter)

How Data Flows

SubscriptionPage (Server Component)
    ↓
createSupabaseClient()
    ↓
getSubscription(supabase)
    ↓
Supabase query: subscriptions + prices + products
    ↓
Returns SubscriptionWithPrice | null
    ↓
Render subscription details or empty state

The subscription includes nested price and product data through Supabase joins.

Troubleshooting

"No Active Membership" when you should have one:

  • Verify the checkout completed successfully in Stripe dashboard
  • Check that webhooks processed the checkout.session.completed event
  • Query the subscriptions table directly in Supabase
  • The subscription status must be active or trialing

Price shows "N/A":

  • Check that subscription.prices.unit_amount exists
  • Verify the price was synced from Stripe via webhook

Dates show as "Invalid Date":

  • Ensure date fields are ISO strings in the database
  • Check the webhook is storing dates correctly

Status badge shows wrong color:

  • Verify the subscription status in Supabase matches expected values
  • Check the conditional rendering logic for your status