Pricing Page with Plans
A pricing page is the gateway to revenue. Users need to see their options clearly - what each tier offers, how much it costs, and whether they're already subscribed. Products and prices live in Stripe, and the webhook from 2.1 copies them into Supabase, so the page can read them with one fast query.
Outcome
Build a pricing page that displays subscription tiers fetched from Supabase, with current plan indicator.
Fast Track
- Confirm the Ranger and Elder tiers from 2.1 are in Supabase
- Implement
getProducts()inutils/supabase/queries.ts - Create
app/protected/pricing/page.tsxto display plans
Hands-on Exercise 2.2
Build a pricing page with subscription tiers:
Requirements:
- Use the Ranger and Elder products you created in 2.1
- Implement
getProducts()to query products with prices from Supabase - Implement
getSubscription()to check the user's current plan - Create a pricing page at
/protected/pricing - Display pricing cards with name, description, and price
- Indicate current plan if user is subscribed
Implementation hints:
- The webhook from 2.1 keeps the
productsandpricestables in sync with Stripe - Use Supabase joins to fetch products with their prices in one query
- Format prices by dividing
unit_amountby 100 (Stripe stores cents) - Check
price_idagainst the user's subscription to show "Current Plan"
Try It
-
Keep the sync running: leave
stripe listen --forward-to localhost:3000/api/webhooksgoing from 2.1, so any change you make in Stripe reaches Supabase. -
Start dev server:
pnpm dev -
Visit pricing page:
- Sign in to your app
- Navigate to http://localhost:3000/protected/pricing
- You should see both pricing tiers
-
Verify display:
- Product names display correctly
- Prices show as
$9.00and$29.00 - Cards have "Select Plan" buttons
Commit
git add -A
git commit -m "feat(billing): add pricing page with subscription tiers"Done-When
- Ranger and Elder from 2.1 appear on the pricing page
getProducts()queries products with prices from SupabasegetSubscription()queries user's active subscription- Pricing page renders at
/protected/pricing - Prices display correctly (formatted from cents)
- Current plan indicator works for subscribed users
- Empty state handles no products gracefully
Solution
Step 1: Implement Product Queries
Update utils/supabase/queries.ts to implement the query functions:
import { SupabaseClient } from "@supabase/supabase-js";
import { cache } from "react";
// Type definitions for database queries
export type ProductWithPrices = {
id: string;
active: boolean;
name: string;
description: string | null;
image: string | null;
metadata: Record<string, string>;
prices: Price[];
};
export type Price = {
id: string;
product_id: string;
active: boolean;
description: string | null;
unit_amount: number | null;
currency: string;
type: "one_time" | "recurring";
interval: "day" | "week" | "month" | "year" | null;
interval_count: number | null;
trial_period_days: number | null;
metadata: Record<string, string>;
};
export type SubscriptionWithPrice = {
id: string;
user_id: string;
status: string;
metadata: Record<string, string>;
price_id: string;
quantity: number;
cancel_at_period_end: boolean;
created: string;
current_period_start: string;
current_period_end: string;
ended_at: string | null;
cancel_at: string | null;
canceled_at: string | null;
trial_start: string | null;
trial_end: string | null;
prices: Price & {
products: ProductWithPrices;
};
};
// Get all active products with their prices
export const getProducts = cache(async (supabase: SupabaseClient) => {
const { data: products } = await supabase
.from("products")
.select("*, prices(*)")
.eq("active", true)
.eq("prices.active", true)
.order("metadata->index")
.order("unit_amount", { referencedTable: "prices" });
return (products as ProductWithPrices[]) ?? [];
});
// Get user's active subscription with price and product details
export const getSubscription = cache(async (supabase: SupabaseClient) => {
const { data: subscription } = await supabase
.from("subscriptions")
.select("*, prices(*, products(*))")
.in("status", ["trialing", "active"])
.maybeSingle();
return subscription as SubscriptionWithPrice | null;
});
// Check if user has an active subscription
export const hasActiveSubscription = cache(async (supabase: SupabaseClient) => {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return false;
const { data: subscription } = await supabase
.from("subscriptions")
.select("id, status")
.eq("user_id", user.id)
.in("status", ["trialing", "active"])
.maybeSingle();
return !!subscription;
});Key patterns:
cache()- React's cache function deduplicates requests within a render- Supabase joins -
select("*, prices(*)")fetches related data in one query - Active filters - Only show active products and prices
Step 2: Create Pricing Page
Update app/protected/pricing/page.tsx:
import { createSupabaseClient } from "@/utils/supabase/server";
import { getProducts, getSubscription } from "@/utils/supabase/queries";
import PricingCard from "@/components/pricing-card";
export default async function PricingPage() {
const supabase = await createSupabaseClient();
// Fetch products and current subscription in parallel
const [products, subscription] = await Promise.all([
getProducts(supabase),
getSubscription(supabase),
]);
const currentPriceId = subscription?.price_id;
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-medium">Guild Membership Tiers</h1>
<p className="text-muted-foreground mt-2">
Choose the membership level that matches your foraging journey
</p>
</div>
{products.length === 0 ? (
<div className="text-center py-10">
<p className="text-muted-foreground">
No membership tiers available yet. Check back soon!
</p>
<p className="text-sm text-muted-foreground mt-2">
(Make sure products are created in Stripe and synced via webhook)
</p>
</div>
) : (
<div className="grid md:grid-cols-2 gap-6">
{products.map((product) => (
<PricingCard
key={product.id}
product={product}
isCurrentPlan={product.prices?.some(
(p) => p.id === currentPriceId
)}
/>
))}
</div>
)}
{subscription && (
<p className="text-sm text-muted-foreground text-center">
You are currently on the {subscription.prices?.products?.name} plan.
</p>
)}
</div>
);
}Step 3: Create Loading State
The loading state is already in the starter at app/protected/pricing/loading.tsx.
File Structure After This Lesson
app/protected/
├── page.tsx ← Account page from Section 1
├── layout.tsx ← Protected layout
└── pricing/
├── page.tsx ← Updated: pricing page
└── loading.tsx ← Loading skeleton (in starter)
utils/
├── supabase/
│ ├── queries.ts ← Updated: product/subscription queries
│ └── ...
└── stripe/
└── config.ts ← From lesson 2.1
components/
└── pricing-card.tsx ← In starter (will update in 2.3)
How Product Data Flows
Stripe Dashboard
↓
Create product/price
↓
Webhook fires (product.created, price.created)
↓
app/api/webhooks/route.ts ← From lesson 2.1
↓
upsertProductRecord() / upsertPriceRecord()
↓
Supabase products/prices tables
↓
getProducts() query
↓
Pricing page displays
Products are the source of truth in Stripe. Supabase mirrors them for fast queries.
Price Formatting
Stripe stores prices in the smallest currency unit (cents for USD):
| Stored Value | Display Value |
|---|---|
| 900 | $9.00 |
| 2900 | $29.00 |
| 9900 | $99.00 |
Always divide by 100 and use .toFixed(2) for proper formatting:
const priceString = `$${(price.unit_amount / 100).toFixed(2)}`;Troubleshooting
"No products found":
- Verify products exist in your Stripe dashboard, each with a price
- Check the
productsandpricestables in Supabase. If they're empty, the sync from 2.1 didn't run - Make sure
stripe listen --forward-to localhost:3000/api/webhooksis running
Products exist in Stripe but not in Supabase:
- They were created while
stripe listenwasn't running, so the events never reached the app - Start
stripe listen, then edit each product's description and each price's description. Stripe sendsproduct.updatedandprice.updated, and the webhook copies both over
Prices show as null:
- Make sure
unit_amountis set on each price in Stripe - Check the price type is "recurring" for subscriptions
Was this helpful?