//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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;
}// 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>
);
}features (JSON array), highlighted, order, and show_on_pricing. This keeps product configuration centralized in Stripe.revalidate = 3600 uses Next.js ISR to cache the page for one hour. Price changes in Stripe appear within that window without a redeploy.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
}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)}`;
}revalidate or cache to avoid excessive API calls and slow page loads.JSON.parse and numbers with parseInt.unit_amount is the total annual amount, not the monthly equivalent. Divide by 12 for display purposes.auto_paging_each or increase the limit.stripe.products.list on every render in a client component. Fetch data in a server component or Server Action and pass it as props.
| 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 |
interval: "month" and one with interval: "year".annual state toggles which Price is displayed and used for checkout.formatPrice.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.PricingTier object with productId: "free" and null prices./signup instead of a subscribe button.allTiers array so it always appears first.// Inside your Stripe webhook handler:
case "price.updated":
case "product.updated":
revalidatePath("/pricing");
break;unit_amount is null for metered or tiered pricing models.if (price.unit_amount === null) return "Contact us";auto_paging_each) or increase the limit parameter.export interface PricingTier {
productId: string;
productName: string;
description: string | null;
monthlyPrice: Stripe.Price | null;
annualPrice: Stripe.Price | null;
features: string[];
highlighted: boolean;
order: number;
}Stripe.Price | null to handle missing intervals.description is string | null because Stripe products may lack descriptions.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)}`;
}unit_amount since it is typed as number | null.FeatureRow type uses string | boolean for each plan column.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥