---
title: "Pricing Page with Plans"
description: "Build a pricing page that fetches subscription tiers from Supabase and displays them with pricing cards."
canonical_url: "https://vercel.com/academy/subscription-store/pricing-page-with-plans"
md_url: "https://vercel.com/academy/subscription-store/pricing-page-with-plans.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-11-06T19:33:45.490Z"
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>

# Pricing Page with Plans

# 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

1. Confirm the Ranger and Elder tiers from 2.1 are in Supabase
2. Implement `getProducts()` in `utils/supabase/queries.ts`
3. Create `app/protected/pricing/page.tsx` to display plans

## Hands-on Exercise 2.2

Build a pricing page with subscription tiers:

**Requirements:**

1. Use the Ranger and Elder products you created in 2.1
2. Implement `getProducts()` to query products with prices from Supabase
3. Implement `getSubscription()` to check the user's current plan
4. Create a pricing page at `/protected/pricing`
5. Display pricing cards with name, description, and price
6. Indicate current plan if user is subscribed

**Implementation hints:**

- The webhook from 2.1 keeps the `products` and `prices` tables in sync with Stripe
- Use Supabase joins to fetch products with their prices in one query
- Format prices by dividing `unit_amount` by 100 (Stripe stores cents)
- Check `price_id` against the user's subscription to show "Current Plan"

## Try It

1. **Keep the sync running:** leave `stripe listen --forward-to localhost:3000/api/webhooks` going from 2.1, so any change you make in Stripe reaches Supabase.

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

3. **Visit pricing page:**
   - Sign in to your app
   - Navigate to <http://localhost:3000/protected/pricing>
   - You should see both pricing tiers

4. **Verify display:**
   - Product names display correctly
   - Prices show as `$9.00` and `$29.00`
   - Cards have "Select Plan" buttons

## Commit

```bash
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 Supabase
- [ ] `getSubscription()` 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:

```typescript title="utils/supabase/queries.ts"
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`:

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

```typescript
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 `products` and `prices` tables in Supabase. If they're empty, the sync from 2.1 didn't run
- Make sure `stripe listen --forward-to localhost:3000/api/webhooks` is running

**Products exist in Stripe but not in Supabase:**

- They were created while `stripe listen` wasn't running, so the events never reached the app
- Start `stripe listen`, then edit each product's description and each price's description. Stripe sends `product.updated` and `price.updated`, and the webhook copies both over

**Prices show as null:**

- Make sure `unit_amount` is set on each price in Stripe
- Check the price type is "recurring" for subscriptions


---

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