React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

stripepricingproductstiersmonthlyannual

Building a Pricing Page

Recipe

Fetch products and prices from Stripe at build time, display pricing tiers with feature comparisons, and let users toggle between monthly and annual billing. Handle free tiers and highlight the recommended plan.

Fetch prices from Stripe:

// lib/pricing.ts
import { stripe } from "@/lib/stripe";
import type Stripe from "stripe";
 
export interface PricingTier {
  productId: string;
  productName: string;
  description: string | null;
  monthlyPrice: Stripe.Price | null;
  annualPrice: Stripe.Price | null;
  features: string[];
  highlighted: boolean;
  order: number;
}
 
export async function getPricingTiers(): Promise<PricingTier[]> {
  const products = await stripe.products.list({
    active: true,
    expand: ["data.default_price"],
  });
 
  const prices = await stripe.prices.list({
    active: true,
    type: "recurring",
    expand: ["data.product"],
  });
 
  const tiers: PricingTier[] = products.data
    .filter((p) => p.metadata.show_on_pricing === "true")
    .map((product) => {
      const productPrices = prices.data.filter(
        (p) => (p.product as Stripe.Product).id === product.id
      );
 
      return {
        productId: product.id,
        productName: product.name,
        description: product.description,
        monthlyPrice:
          productPrices.find((p) => p.recurring?.interval === "month") ?? null,
        annualPrice:
          productPrices.find((p) => p.recurring?.interval === "year") ?? null,
        features: JSON.parse(product.metadata.features ?? "[]") as string[],
        highlighted: product.metadata.highlighted === "true",
        order: parseInt(product.metadata.order ?? "0", 10),
      };
    })
    .sort((a, b) => a.order - b.order);
 
  return tiers;
}

Working Example

// app/pricing/page.tsx
import { getPricingTiers } from "@/lib/pricing";
import { PricingCards } from "./pricing-cards";
 
export const revalidate = 3600; // Revalidate every hour
 
export default async function PricingPage() {
  const tiers = await getPricingTiers();
 
  return (
    <div className="max-w-6xl mx-auto py-20 px-4">
      <div className="text-center mb-16">
        <h1 className="text-4xl font-bold mb-4">
          Simple, transparent pricing
        </h1>
        <p className="text-xl text-gray-600">
          Start free. Upgrade when you are ready.
        </p>
      </div>
 
      <PricingCards tiers={tiers} />
    </div>
  );
}
// app/pricing/pricing-cards.tsx
"use client";
 
import { useState } from "react";
import type { PricingTier } from "@/lib/pricing";
import { createSubscriptionCheckout } from "@/app/actions/subscribe";
 
function formatPrice(amount: number | null, interval: string): string {
  if (amount === null) return "Custom";
  const dollars = amount / 100;
  if (interval === "year") {
    return `$${Math.round(dollars / 12)}`;
  }
  return `$${dollars}`;
}
 
export function PricingCards({ tiers }: { tiers: PricingTier[] }) {
  const [annual, setAnnual] = useState(false);
  const [loadingTier, setLoadingTier] = useState<string | null>(null);
 
  const freeTier: PricingTier = {
    productId: "free",
    productName: "Free",
    description: "For individuals getting started",
    monthlyPrice: null,
    annualPrice: null,
    features: ["1 project", "100 MB storage", "Community support"],
    highlighted: false,
    order: 0,
  };
 
  const allTiers = [freeTier, ...tiers];
 
  async function handleSubscribe(tier: PricingTier) {
    const price = annual ? tier.annualPrice : tier.monthlyPrice;
    if (!price) return;
 
    setLoadingTier(tier.productId);
    try {
      await createSubscriptionCheckout(price.id);
    } catch {
      setLoadingTier(null);
    }
  }
 
  return (
    <div>
      {/* Billing toggle */}
      <div className="flex items-center justify-center gap-4 mb-12">
        <span
          className={`text-sm font-medium ${
            !annual ? "text-gray-900" : "text-gray-500"
          }`}
        >
          Monthly
        </span>
        <button
          onClick={() => setAnnual(!annual)}
          className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
            annual ? "bg-blue-600" : "bg-gray-300"
          }`}
          aria-label="Toggle annual billing"
        >
          <span
            className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
              annual ? "translate-x-6" : "translate-x-1"
            }`}
          />
        </button>
        <span
          className={`text-sm font-medium ${
            annual ? "text-gray-900" : "text-gray-500"
          }`}
        >
          Annual
          <span className="ml-1 text-green-600 text-xs font-bold">
            Save 20%
          </span>
        </span>
      </div>
 
      {/* Pricing cards */}
      <div className="grid md:grid-cols-4 gap-6">
        {allTiers.map((tier) => {
          const price = annual ? tier.annualPrice : tier.monthlyPrice;
          const isFree = tier.productId === "free";
          const isLoading = loadingTier === tier.productId;
 
          return (
            <div
              key={tier.productId}
              className={`rounded-2xl border p-8 flex flex-col ${
                tier.highlighted
                  ? "border-blue-500 ring-2 ring-blue-500 relative scale-105"
                  : "border-gray-200"
              }`}
            >
              {tier.highlighted && (
                <span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-xs font-bold px-4 py-1 rounded-full">
                  Recommended
                </span>
              )}
 
              <h3 className="text-lg font-bold">{tier.productName}</h3>
              {tier.description && (
                <p className="text-gray-500 text-sm mt-1">
                  {tier.description}
                </p>
              )}
 
              <div className="mt-6 mb-8">
                {isFree ? (
                  <span className="text-4xl font-bold">$0</span>
                ) : price ? (
                  <>
                    <span className="text-4xl font-bold">
                      {formatPrice(price.unit_amount, annual ? "year" : "month")}
                    </span>
                    <span className="text-gray-500 text-sm">/month</span>
                    {annual && (
                      <p className="text-xs text-gray-500 mt-1">
                        Billed annually at ${(price.unit_amount! / 100).toFixed(0)}/year
                      </p>
                    )}
                  </>
                ) : (
                  <span className="text-4xl font-bold">Custom</span>
                )}
              </div>
 
              <ul className="space-y-3 mb-8 flex-1">
                {tier.features.map((feature) => (
                  <li key={feature} className="flex items-start gap-2 text-sm">
                    <span className="text-green-500 mt-0.5">&#10003;</span>
                    <span>{feature}</span>
                  </li>
                ))}
              </ul>
 
              {isFree ? (
                <a
                  href="/signup"
                  className="block text-center bg-gray-100 text-gray-800 py-3 rounded-lg font-medium hover:bg-gray-200 transition-colors"
                >
                  Get Started
                </a>
              ) : price ? (
                <button
                  onClick={() => handleSubscribe(tier)}
                  disabled={isLoading}
                  className={`w-full py-3 rounded-lg font-medium transition-colors ${
                    tier.highlighted
                      ? "bg-blue-600 text-white hover:bg-blue-700"
                      : "bg-gray-900 text-white hover:bg-gray-800"
                  } disabled:opacity-50`}
                >
                  {isLoading ? "Redirecting..." : "Subscribe"}
                </button>
              ) : (
                <a
                  href="/contact"
                  className="block text-center border border-gray-300 py-3 rounded-lg font-medium hover:bg-gray-50 transition-colors"
                >
                  Contact Sales
                </a>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Feature comparison table:

// app/pricing/feature-table.tsx
interface FeatureRow {
  feature: string;
  free: string | boolean;
  pro: string | boolean;
  business: string | boolean;
  enterprise: string | boolean;
}
 
const features: FeatureRow[] = [
  { feature: "Projects", free: "1", pro: "10", business: "Unlimited", enterprise: "Unlimited" },
  { feature: "Storage", free: "100 MB", pro: "10 GB", business: "100 GB", enterprise: "Unlimited" },
  { feature: "API Access", free: false, pro: true, business: true, enterprise: true },
  { feature: "Custom Domain", free: false, pro: false, business: true, enterprise: true },
  { feature: "SSO", free: false, pro: false, business: false, enterprise: true },
  { feature: "Priority Support", free: false, pro: true, business: true, enterprise: true },
];
 
export function FeatureTable() {
  return (
    <div className="mt-20">
      <h2 className="text-2xl font-bold text-center mb-8">
        Compare Features
      </h2>
      <div className="overflow-x-auto">
        <table className="w-full text-sm">
          <thead>
            <tr className="border-b">
              <th className="text-left py-4 px-4">Feature</th>
              <th className="text-center py-4 px-4">Free</th>
              <th className="text-center py-4 px-4">Pro</th>
              <th className="text-center py-4 px-4">Business</th>
              <th className="text-center py-4 px-4">Enterprise</th>
            </tr>
          </thead>
          <tbody>
            {features.map((row) => (
              <tr key={row.feature} className="border-b">
                <td className="py-4 px-4 font-medium">{row.feature}</td>
                {(["free", "pro", "business", "enterprise"] as const).map(
                  (plan) => (
                    <td key={plan} className="text-center py-4 px-4">
                      {typeof row[plan] === "boolean" ? (
                        row[plan] ? (
                          <span className="text-green-500">&#10003;</span>
                        ) : (
                          <span className="text-gray-300">&mdash;</span>
                        )
                      ) : (
                        row[plan]
                      )}
                    </td>
                  )
                )}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

Deep Dive

How It Works

  • Products and Prices are fetched from Stripe at request time (or at build time with ISR). This keeps your pricing page in sync with your Stripe Dashboard without hardcoding values.
  • Product metadata stores UI-specific fields like features (JSON array), highlighted, order, and show_on_pricing. This keeps product configuration centralized in Stripe.
  • The monthly/annual toggle switches between two Price objects attached to the same Product. Annual prices typically offer a discount.
  • revalidate = 3600 uses Next.js ISR to cache the page for one hour. Price changes in Stripe appear within that window without a redeploy.
  • The free tier is handled as a special case with no Stripe Price. It links to signup rather than checkout.

Variations

Static generation with generateStaticParams:

// Fetch prices at build time only
export const dynamic = "force-static";
export const revalidate = false;

On-demand revalidation when prices change (via webhook):

// app/api/webhooks/stripe/route.ts
import { revalidatePath } from "next/cache";
 
// Inside webhook handler:
case "price.updated":
case "product.updated":
  revalidatePath("/pricing");
  break;

Per-country pricing:

export async function getPricingTiers(country: string) {
  const prices = await stripe.prices.list({
    active: true,
    currency: country === "GB" ? "gbp" : country === "EU" ? "eur" : "usd",
  });
  // ... map to tiers
}

TypeScript Notes

  • Stripe.Product has a metadata property typed as Record<string, string>. Parse metadata values explicitly.
  • Stripe.Price has unit_amount typed as number | null. It is null for metered or tiered pricing. Always null-check before displaying.
import type Stripe from "stripe";
 
function formatAmount(price: Stripe.Price): string {
  if (price.unit_amount === null) return "Contact us";
  return `$${(price.unit_amount / 100).toFixed(2)}`;
}

Gotchas

  • Stripe API calls in server components run on every request by default. Use revalidate or cache to avoid excessive API calls and slow page loads.
  • Product metadata values are always strings. Parse JSON fields with JSON.parse and numbers with parseInt.
  • The annual price unit_amount is the total annual amount, not the monthly equivalent. Divide by 12 for display purposes.
  • If a product has no active price for the selected interval, handle it gracefully (show "Contact Sales" or hide the tier).
  • Stripe API list endpoints return a maximum of 100 items by default. If you have more than 100 prices, use pagination with auto_paging_each or increase the limit.
  • Do not call stripe.products.list on every render in a client component. Fetch data in a server component or Server Action and pass it as props.

Alternatives

ApproachProsCons
Fetch from Stripe APIAlways in sync with DashboardAPI latency, rate limits
Hardcoded pricing dataFastest page load, no API callsMust update code when prices change
CMS-managed pricingNon-developer-friendly updatesExtra system to maintain
Stripe Pricing Table (embedded)Zero code, maintained by StripeVery limited customization

FAQs

Why fetch products and prices from the Stripe API instead of hardcoding them?
  • Prices stay in sync with your Stripe Dashboard without code changes.
  • Product metadata (features, order, highlighted) can be managed by non-developers in Stripe.
  • No redeployment needed when prices change -- ISR picks up updates within the revalidation window.
How does the monthly/annual billing toggle work?
  • Each Stripe Product has two Price objects: one with interval: "month" and one with interval: "year".
  • The annual state toggles which Price is displayed and used for checkout.
  • Annual prices are divided by 12 for the per-month display using formatPrice.
What role does product metadata play in the pricing page?
  • show_on_pricing controls whether a product appears on the page.
  • features is a JSON array of feature strings parsed with JSON.parse.
  • highlighted marks the recommended plan, and order controls display sort order.
How does the free tier work without a Stripe Price?
  • The free tier is a hardcoded PricingTier object with productId: "free" and null prices.
  • It renders a "Get Started" link to /signup instead of a subscribe button.
  • It is prepended to the allTiers array so it always appears first.
What does revalidate = 3600 do on the pricing page?
  • It enables Incremental Static Regeneration (ISR) with a one-hour cache window.
  • The page is served from cache and revalidated in the background after 3600 seconds.
  • Price changes in Stripe appear within one hour without a redeploy.
How can you trigger on-demand revalidation when prices change?
// Inside your Stripe webhook handler:
case "price.updated":
case "product.updated":
  revalidatePath("/pricing");
  break;
  • This immediately invalidates the cached pricing page when Stripe sends a webhook.
Gotcha: What happens if unit_amount is null and you try to display it?
  • unit_amount is null for metered or tiered pricing models.
  • Displaying it without a null check causes a runtime error.
  • Always check: if (price.unit_amount === null) return "Contact us";
Gotcha: What happens if you have more than 100 products or prices in Stripe?
  • Stripe API list endpoints return a maximum of 100 items by default.
  • You must use pagination (auto_paging_each) or increase the limit parameter.
  • Without this, some products or prices will silently be missing from your page.
How is the PricingTier interface typed in TypeScript?
export interface PricingTier {
  productId: string;
  productName: string;
  description: string | null;
  monthlyPrice: Stripe.Price | null;
  annualPrice: Stripe.Price | null;
  features: string[];
  highlighted: boolean;
  order: number;
}
  • Both price fields are Stripe.Price | null to handle missing intervals.
  • description is string | null because Stripe products may lack descriptions.
How should you type a helper function that formats a Stripe.Price amount?
import type Stripe from "stripe";
 
function formatAmount(price: Stripe.Price): string {
  if (price.unit_amount === null) return "Contact us";
  return `$${(price.unit_amount / 100).toFixed(2)}`;
}
  • Always null-check unit_amount since it is typed as number | null.
Why should you avoid calling stripe.products.list in a client component?
  • Client components run in the browser and cannot safely access your Stripe secret key.
  • Fetch data in a server component or Server Action and pass it as props.
  • This also avoids redundant API calls on every render.
How does the FeatureTable component handle boolean vs string feature values?
  • The FeatureRow type uses string | boolean for each plan column.
  • Booleans render as a checkmark or dash icon.
  • Strings render as-is (e.g., "10 GB", "Unlimited").