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, 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
    • 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:

    pnpm dev
  4. Visit pricing page:

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

  • 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 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:

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:

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 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
  • 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:

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