Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

Build a pricing page

Render a pricing page from products.list — formatting minor units, monthly/yearly toggles, caching the catalog, and linking to checkout.

A pricing page needs three things from the billing provider: the plans, their prices, and the identifier a checkout accepts. products.list returns all three in one call.

Fetch the catalog

import { createClient } from 'revenue-sdk';
import { polar } from 'revenue-sdk/polar';

const client = createClient({
  provider: polar({ accessToken: process.env.POLAR_ACCESS_TOKEN! }),
});

// listAll follows cursors for you; a catalog is small enough to walk fully.
const products = [];
for await (const product of client.products.listAll({ limit: 100 })) {
  products.push(product);
}

Shape it for the UI

Flatten products into the rows you actually render. checkoutRef is the only identifier the checkout call accepts — carry it through, never Product.id.

import type { Price, Product } from 'revenue-sdk';

interface PlanRow {
  productId: string;
  name: string;
  description?: string;
  checkoutRef: string;
  price: string;
  interval?: Price['interval'];
  trialDays?: number;
}

function formatMoney(amount: number, currency: string): string {
  return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount / 100);
}

function toRows(products: Product[]): PlanRow[] {
  const rows: PlanRow[] = [];
  for (const product of products) {
    for (const price of product.prices) {
      rows.push({
        productId: product.id,
        name: product.name,
        description: product.description,
        checkoutRef: price.checkoutRef,
        price:
          price.model === 'fixed' && price.amount !== null
            ? formatMoney(price.amount, price.currency)
            : 'Contact us',
        interval: price.interval,
        trialDays: price.trialDays,
      });
    }
  }
  return rows;
}

Monthly / yearly toggle

Across all five providers a monthly plan and a yearly plan are separate purchasable units, so the toggle is a filter on interval, not a parameter:

const monthly = rows.filter((row) => row.interval === 'month');
const yearly = rows.filter((row) => row.interval === 'year');

const savings = (monthlyAmount: number, yearlyAmount: number): number =>
  Math.round((1 - yearlyAmount / (monthlyAmount * 12)) * 100);

If your catalog names them ambiguously, group by a metadata key or by a naming convention you control — the SDK deliberately doesn’t guess which monthly plan pairs with which yearly one.

export function Pricing({ rows }: { rows: PlanRow[] }) {
  return (
    <div className="grid">
      {rows.map((row) => (
        <article key={row.checkoutRef}>
          <h3>{row.name}</h3>
          <p>{row.description}</p>
          <p>
            <strong>{row.price}</strong>
            {row.interval ? ` / ${row.interval}` : null}
          </p>
          {row.trialDays ? <p>{row.trialDays}-day free trial</p> : null}
          <form method="post" action="/api/checkout">
            <input type="hidden" name="checkoutRef" value={row.checkoutRef} />
            <button type="submit">Choose {row.name}</button>
          </form>
        </article>
      ))}
    </div>
  );
}

The form posts to your own route, which creates the checkout server-side — see the checkout flow guide. Never create checkouts from the browser: that would require shipping your API key.

Cache the catalog

Prices change rarely and the list costs several round-trips on some providers (Stripe fetches prices per product; Lemon Squeezy fetches a price model per variant). Cache it:

const CACHE_TTL_SECONDS = 300;

export async function getPricing(cache: KVNamespace): Promise<PlanRow[]> {
  const cached = await cache.get('pricing', 'json');
  if (cached) {
    return cached as PlanRow[];
  }

  const products = [];
  for await (const product of client.products.listAll({ limit: 100 })) {
    products.push(product);
  }
  const rows = toRows(products);

  await cache.put('pricing', JSON.stringify(rows), { expirationTtl: CACHE_TTL_SECONDS });
  return rows;
}

Handle a partly unsupported catalog

Real catalogs contain archived products, metered prices, and one-off products you don’t want on the pricing page. Filter deliberately:

const sellable = rows.filter((row) => row.interval !== undefined && row.price !== 'Contact us');

Archived products are already excluded by the SDK where the provider supports it (Polar, Stripe, Paddle, and Dodo Payments all list only active products).

Last updated on August 6, 2026

Was this page helpful?