Construcción de una página de precios
Receta
Obtén productos y precios de Stripe en tiempo de compilación, muestra niveles de precios con comparaciones de funcionalidades y permite a los usuarios alternar entre facturación mensual y anual. Gestiona niveles gratuitos y destaca el plan recomendado.
Obtén precios de 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;
}Ejemplo funcional
// app/pricing/page.tsx
import { getPricingTiers } from "@/lib/pricing";
import { PricingCards } from "./pricing-cards";
export const revalidate = 3600; // Revalidar cada hora
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">
Precios simples y transparentes
</h1>
<p className="text-xl text-gray-600">
Empieza gratis. Mejora tu plan cuando estés listo.
</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 "Personalizado";
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: "Gratis",
description: "Para personas que empiezan",
monthlyPrice: null,
annualPrice: null,
features: ["1 proyecto", "100 MB de almacenamiento", "Soporte de la comunidad"],
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>
{/* Interruptor de facturación */}
<div className="flex items-center justify-center gap-4 mb-12">
<span
className={`text-sm font-medium ${
!annual ? "text-gray-900" : "text-gray-500"
}`}
>
Mensual
</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="Alternar facturación anual"
>
<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"
}`}
>
Anual
<span className="ml-1 text-green-600 text-xs font-bold">
Ahorra 20%
</span>
</span>
</div>
{/* Tarjetas de precios */}
<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">
Recomendado
</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">/mes</span>
{annual && (
<p className="text-xs text-gray-500 mt-1">
Facturado anualmente a ${(price.unit_amount! / 100).toFixed(0)}/año
</p>
)}
</>
) : (
<span className="text-4xl font-bold">Personalizado</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"
>
Empezar
</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 ? "Redirigiendo..." : "Suscribirse"}
</button>
) : (
<a
href="/contact"
className="block text-center border border-gray-300 py-3 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
Contactar ventas
</a>
)}
</div>
);
})}
</div>
</div>
);
}Tabla de comparación de funcionalidades:
// 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: "Proyectos", free: "1", pro: "10", business: "Ilimitado", enterprise: "Ilimitado" },
{ feature: "Almacenamiento", free: "100 MB", pro: "10 GB", business: "100 GB", enterprise: "Ilimitado" },
{ feature: "Acceso a la API", free: false, pro: true, business: true, enterprise: true },
{ feature: "Dominio personalizado", free: false, pro: false, business: true, enterprise: true },
{ feature: "SSO", free: false, pro: false, business: false, enterprise: true },
{ feature: "Soporte prioritario", 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">
Comparar funcionalidades
</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">Funcionalidad</th>
<th className="text-center py-4 px-4">Gratis</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>
);
}Profundización
Cómo funciona
- Los Products y Prices se obtienen de Stripe en tiempo de solicitud (o en tiempo de compilación con ISR). Esto mantiene tu página de precios sincronizada con tu Stripe Dashboard sin codificar valores manualmente.
- Los metadatos del producto almacenan campos específicos de la UI como
features(array JSON),highlighted,orderyshow_on_pricing. Esto centraliza la configuración del producto en Stripe. - El interruptor mensual/anual alterna entre dos objetos Price asociados al mismo Product. Los precios anuales suelen ofrecer un descuento.
revalidate = 3600usa ISR de Next.js para cachear la página durante una hora. Los cambios de precio en Stripe aparecen dentro de esa ventana sin un nuevo despliegue.- El nivel gratuito se gestiona como un caso especial sin Price de Stripe. Enlaza al registro en lugar del checkout.
Variaciones
Generación estática con generateStaticParams:
// Obtener precios solo en tiempo de compilación
export const dynamic = "force-static";
export const revalidate = false;Revalidación bajo demanda cuando cambian los precios (mediante webhook):
// app/api/webhooks/stripe/route.ts
import { revalidatePath } from "next/cache";
// Dentro del manejador de webhook:
case "price.updated":
case "product.updated":
revalidatePath("/pricing");
break;Precios por país:
export async function getPricingTiers(country: string) {
const prices = await stripe.prices.list({
active: true,
currency: country === "GB" ? "gbp" : country === "EU" ? "eur" : "usd",
});
// ... mapear a niveles
}Notas de TypeScript
Stripe.Producttiene una propiedadmetadatatipada comoRecord<string, string>. Analiza los valores de metadatos de forma explícita.Stripe.Pricetieneunit_amounttipado comonumber | null. Es null para precios medidos o por niveles. Comprueba siempre null antes de mostrarlo.
import type Stripe from "stripe";
function formatAmount(price: Stripe.Price): string {
if (price.unit_amount === null) return "Contáctanos";
return `$${(price.unit_amount / 100).toFixed(2)}`;
}Errores comunes
- Las llamadas a la API de Stripe en server components se ejecutan en cada solicitud por defecto. Usa
revalidateocachepara evitar llamadas excesivas a la API y cargas lentas de la página. - Los valores de metadatos del producto son siempre cadenas. Analiza los campos JSON con
JSON.parsey los números conparseInt. - El
unit_amountdel precio anual es el importe anual total, no el equivalente mensual. Divídelo entre 12 para mostrarlo. - Si un producto no tiene un precio activo para el intervalo seleccionado, gestiónalo con elegancia (muestra "Contactar ventas" u oculta el nivel).
- Los endpoints de listado de la API de Stripe devuelven un máximo de 100 elementos por defecto. Si tienes más de 100 precios, usa paginación con
auto_paging_eacho aumenta ellimit. - No llames a
stripe.products.listen cada renderizado en un client component. Obtén los datos en un server component o Server Action y pásalos como props.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Obtener desde la API de Stripe | Siempre sincronizado con el Dashboard | Latencia de la API, límites de tasa |
| Datos de precios codificados | Carga de página más rápida, sin llamadas a la API | Debes actualizar el código cuando cambien los precios |
| Precios gestionados por CMS | Actualizaciones amigables para no desarrolladores | Sistema adicional que mantener |
| Stripe Pricing Table (embebida) | Cero código, mantenida por Stripe | Personalización muy limitada |
FAQs
¿Por qué obtener productos y precios desde la API de Stripe en lugar de codificarlos?
- Los precios permanecen sincronizados con tu Stripe Dashboard sin cambios en el código.
- Los metadatos del producto (funcionalidades, orden, destacado) pueden gestionarlos personas no desarrolladoras en Stripe.
- No se necesita un nuevo despliegue cuando cambian los precios: ISR recoge las actualizaciones dentro de la ventana de revalidación.
¿Cómo funciona el interruptor de facturación mensual/anual?
- Cada Product de Stripe tiene dos objetos Price: uno con
interval: "month"y otro coninterval: "year". - El state
annualalterna qué Price se muestra y se usa para el checkout. - Los precios anuales se dividen entre 12 para la visualización por mes usando
formatPrice.
¿Qué papel desempeñan los metadatos del producto en la página de precios?
show_on_pricingcontrola si un producto aparece en la página.featureses un array JSON de cadenas de funcionalidades analizado conJSON.parse.highlightedmarca el plan recomendado, yordercontrola el orden de visualización.
¿Cómo funciona el nivel gratuito sin un Price de Stripe?
- El nivel gratuito es un objeto
PricingTiercodificado conproductId: "free"y precios null. - Renderiza un enlace "Empezar" a
/signupen lugar de un botón de suscripción. - Se antepone al array
allTierspara que siempre aparezca primero.
¿Qué hace revalidate = 3600 en la página de precios?
- Habilita Incremental Static Regeneration (ISR) con una ventana de caché de una hora.
- La página se sirve desde la caché y se revalida en segundo plano después de 3600 segundos.
- Los cambios de precio en Stripe aparecen en una hora sin un nuevo despliegue.
¿Cómo puedes activar la revalidación bajo demanda cuando cambian los precios?
// Dentro de tu manejador de webhook de Stripe:
case "price.updated":
case "product.updated":
revalidatePath("/pricing");
break;- Esto invalida inmediatamente la página de precios en caché cuando Stripe envía un webhook.
Trampa: ¿Qué ocurre si unit_amount es null e intentas mostrarlo?
unit_amountes null para modelos de precios medidos o por niveles.- Mostrarlo sin una comprobación de null provoca un error en tiempo de ejecución.
- Comprueba siempre:
if (price.unit_amount === null) return "Contáctanos";
Trampa: ¿Qué ocurre si tienes más de 100 productos o precios en Stripe?
- Los endpoints de listado de la API de Stripe devuelven un máximo de 100 elementos por defecto.
- Debes usar paginación (
auto_paging_each) o aumentar el parámetrolimit. - Sin esto, algunos productos o precios faltarán silenciosamente en tu página.
¿Cómo está tipada la interfaz PricingTier en 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;
}- Ambos campos de precio son
Stripe.Price | nullpara gestionar intervalos ausentes. descriptionesstring | nullporque los productos de Stripe pueden carecer de descripciones.
¿Cómo debes tipar una función auxiliar que formatea el importe de un Stripe.Price?
import type Stripe from "stripe";
function formatAmount(price: Stripe.Price): string {
if (price.unit_amount === null) return "Contáctanos";
return `$${(price.unit_amount / 100).toFixed(2)}`;
}- Comprueba siempre null en
unit_amount, ya que está tipado comonumber | null.
¿Por qué debes evitar llamar a stripe.products.list en un client component?
- Los client components se ejecutan en el navegador y no pueden acceder de forma segura a tu clave secreta de Stripe.
- Obtén los datos en un server component o Server Action y pásalos como props.
- Esto también evita llamadas redundantes a la API en cada renderizado.
¿Cómo gestiona el componente FeatureTable los valores de funcionalidad booleanos frente a los de cadena?
- El tipo
FeatureRowusastring | booleanpara cada columna de plan. - Los booleanos se renderizan como un icono de marca de verificación o un guion.
- Las cadenas se renderizan tal cual (p. ej., "10 GB", "Ilimitado").