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
- Create
app/protected/subscription/page.tsx - Fetch subscription with
getSubscription()fromutils/supabase/queries.ts - Display subscription card with status indicator
Hands-on Exercise 2.4
Build a subscription management page:
Requirements:
- Create subscription page at
/protected/subscription - Fetch subscription using
getSubscription()(implemented in lesson 2.2) - Display subscription card with:
- Product name
- Price per interval
- Status indicator (active, trialing, cancelling)
- Current billing period dates
- Add loading state skeleton
- 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_endfor "cancelling" state
Try It
-
Ensure you have a subscription:
- Complete a test checkout from lesson 2.3
- Or create one via the Stripe dashboard
-
Visit subscription page:
- Go to http://localhost:3000/protected/subscription
- You should see your active subscription
-
Verify display:
- Product name shows correctly
- Price shows as
$X.XX/month - Status indicator is green for active
- Billing period dates display
-
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:
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.NumberFormatfor currency display - Status badge - Visual indicator of subscription state
Subscription Status States
| Status | Visual | Meaning |
|---|---|---|
active | Green badge | Normal active subscription |
trialing | Blue badge | In free trial period |
past_due | Yellow badge | Payment failed, grace period |
canceled | Not shown | Subscription 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.completedevent - Query the
subscriptionstable directly in Supabase - The subscription status must be
activeortrialing
Price shows "N/A":
- Check that
subscription.prices.unit_amountexists - 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
Was this helpful?