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">✓</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">✓</span>
) : (
<span className="text-gray-300">—</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, andshow_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 = 3600uses 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.Producthas ametadataproperty typed asRecord<string, string>. Parse metadata values explicitly.Stripe.Pricehasunit_amounttyped asnumber | 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
revalidateorcacheto avoid excessive API calls and slow page loads. - Product metadata values are always strings. Parse JSON fields with
JSON.parseand numbers withparseInt. - The annual price
unit_amountis 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_eachor increase thelimit. - Do not call
stripe.products.liston every render in a client component. Fetch data in a server component or Server Action and pass it as props.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Fetch from Stripe API | Always in sync with Dashboard | API latency, rate limits |
| Hardcoded pricing data | Fastest page load, no API calls | Must update code when prices change |
| CMS-managed pricing | Non-developer-friendly updates | Extra system to maintain |
| Stripe Pricing Table (embedded) | Zero code, maintained by Stripe | Very 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 withinterval: "year". - The
annualstate 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_pricingcontrols whether a product appears on the page.featuresis a JSON array of feature strings parsed withJSON.parse.highlightedmarks the recommended plan, andordercontrols display sort order.
How does the free tier work without a Stripe Price?
- The free tier is a hardcoded
PricingTierobject withproductId: "free"and null prices. - It renders a "Get Started" link to
/signupinstead of a subscribe button. - It is prepended to the
allTiersarray 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_amountis 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 thelimitparameter. - 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 | nullto handle missing intervals. descriptionisstring | nullbecause 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_amountsince it is typed asnumber | 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
FeatureRowtype usesstring | booleanfor each plan column. - Booleans render as a checkmark or dash icon.
- Strings render as-is (e.g., "10 GB", "Unlimited").