//
Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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;
}// 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>
);
}features (array JSON), highlighted, order y show_on_pricing. Esto centraliza la configuración del producto en Stripe.revalidate = 3600 usa 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.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
}Stripe.Product tiene una propiedad metadata tipada como Record<string, string>. Analiza los valores de metadatos de forma explícita.Stripe.Price tiene unit_amount tipado como number | 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)}`;
}revalidate o cache para evitar llamadas excesivas a la API y cargas lentas de la página.JSON.parse y los números con parseInt.unit_amount del precio anual es el importe anual total, no el equivalente mensual. Divídelo entre 12 para mostrarlo.auto_paging_each o aumenta el limit.stripe.products.list en cada renderizado en un client component. Obtén los datos en un server component o Server Action y pásalos como props.| 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 |
interval: "month" y otro con interval: "year".annual alterna qué Price se muestra y se usa para el checkout.formatPrice.show_on_pricing controla si un producto aparece en la página.features es un array JSON de cadenas de funcionalidades analizado con JSON.parse.highlighted marca el plan recomendado, y order controla el orden de visualización.PricingTier codificado con productId: "free" y precios null./signup en lugar de un botón de suscripción.allTiers para que siempre aparezca primero.// Dentro de tu manejador de webhook de Stripe:
case "price.updated":
case "product.updated":
revalidatePath("/pricing");
break;unit_amount es null para modelos de precios medidos o por niveles.if (price.unit_amount === null) return "Contáctanos";auto_paging_each) o aumentar el parámetro limit.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 para gestionar intervalos ausentes.description es string | null porque los productos de Stripe pueden carecer de descripciones.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)}`;
}unit_amount, ya que está tipado como number | null.FeatureRow usa string | boolean para cada columna de plan.Revisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥