Vercel Logo

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:

    pnpm dev
  3. Visit pricing page:

  4. Verify display:

    • Product names display correctly
    • Prices show as $9.00 and $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 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:

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:

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 ValueDisplay 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 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

Was this helpful?

supported.