---
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-04-11T10:56:55.538Z"
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, 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

1. Create products and prices in the Stripe dashboard
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. Create at least 2 products in Stripe dashboard (e.g., Ranger, Elder)
2. Add monthly prices for each product
3. Implement `getProducts()` to query products with prices from Supabase
4. Implement `getSubscription()` to check the user's current plan
5. Create a pricing page at `/protected/pricing`
6. Display pricing cards with name, description, and price
7. 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_amount` by 100 (Stripe stores cents)
- Check `price_id` against the user's subscription to show "Current Plan"

## Try It

1. **Create products in Stripe:**
   - Go to your [Stripe dashboard](https://dashboard.stripe.com/test/products)
   - Click **Add product**
   - Add "Ranger" with description and $9/month price
   - Add "Elder" with description and $29/month price

2. **Trigger webhook sync:**
   - Products sync automatically if webhooks are configured
   - For local dev, you may need to run `stripe listen` (covered in advanced section)

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

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

5. **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

- [ ] At least 2 products created in Stripe dashboard
- [ ] `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: Create Products in Stripe

1. Go to your [Stripe dashboard](https://dashboard.stripe.com/test/products) in test mode
2. Click **Add product**
3. 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:

```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 3: 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 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:

```typescript
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_amount` is 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:

```bash
# 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.


---

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