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, but you'll query them from Supabase for fast access.
Outcome
Build a pricing page that displays subscription tiers fetched from Supabase, with current plan indicator.
Fast Track
- Create products and prices in the Stripe dashboard
- 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:
- Create at least 2 products in Stripe dashboard (e.g., Ranger, Elder)
- Add monthly prices for each product
- 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:
- Products sync from Stripe to Supabase via webhooks (already set up)
- 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
-
Create products in Stripe:
- Go to your Stripe dashboard
- Click Add product
- Add "Ranger" with description and $9/month price
- Add "Elder" with description and $29/month price
-
Trigger webhook sync:
- Products sync automatically if webhooks are configured
- For local dev, you may need to run
stripe listen(covered in advanced section)
-
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
- At least 2 products created in Stripe dashboard
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: Create Products in Stripe
- Go to your Stripe dashboard in test mode
- Click Add product
- Create two products:
Ranger Tier:
- Name: Ranger
- Description: Mushroom database, seasonal guides, safe vs deadly comparisons
- Add price: $9.00/month (recurring)
Elder Tier:
- Name: Elder
- Description: Full archive, medicinal uses, offline field guide, submit your own finds
- Add price: $29.00/month (recurring)
Step 2: 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 3: 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 4: 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
└── client.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
↓
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
- Check that prices are added to each product
- Ensure webhooks are syncing (check Supabase tables directly)
- For local dev, run
stripe listen --forward-to localhost:3000/api/webhooks
Products exist in Stripe but not in Supabase:
- Webhooks may not be configured for your endpoint
- Check the Stripe dashboard under Developers → Webhooks
- Manually trigger sync by updating the product in Stripe
Prices show as null:
- Make sure
unit_amountis set on each price in Stripe - Check the price type is "recurring" for subscriptions
Advanced: Local Webhook Testing
For local development, use the Stripe CLI to forward webhooks:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks
# Copy the webhook signing secret and add to .env.local
STRIPE_WEBHOOK_SECRET=whsec_...This forwards Stripe events to your local webhook handler.
Was this helpful?