Suscripciones
Receta
Crea productos y precios de suscripción en Stripe, luego usa Checkout Sessions o PaymentIntents para suscribir clientes. Gestiona el ciclo de vida de la suscripción con el Customer Portal y los webhooks.
Crea una suscripción mediante una Checkout Session:
// app/actions/subscribe.ts
"use server";
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
export async function createSubscriptionCheckout(priceId: string) {
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
subscription_data: {
trial_period_days: 14,
},
});
redirect(session.url!);
}O crea una suscripción directamente mediante la API:
// app/actions/subscribe-direct.ts
"use server";
import { stripe } from "@/lib/stripe";
export async function createSubscription(
customerId: string,
priceId: string
) {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: "default_incomplete",
payment_settings: {
save_default_payment_method: "on_subscription",
},
expand: ["latest_invoice.payment_intent"],
});
const invoice = subscription.latest_invoice as Stripe.Invoice;
const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent;
return {
subscriptionId: subscription.id,
clientSecret: paymentIntent.client_secret!,
};
}Ejemplo funcional
// app/pricing/page.tsx
import { stripe } from "@/lib/stripe";
import { createSubscriptionCheckout } from "@/app/actions/subscribe";
interface PricingTier {
name: string;
priceId: string;
price: number;
interval: string;
features: string[];
popular?: boolean;
}
const tiers: PricingTier[] = [
{
name: "Starter",
priceId: "price_starter_monthly",
price: 9,
interval: "month",
features: ["5 proyectos", "1 GB de almacenamiento", "Soporte por email"],
},
{
name: "Pro",
priceId: "price_pro_monthly",
price: 29,
interval: "month",
features: ["Proyectos ilimitados", "10 GB de almacenamiento", "Soporte prioritario", "Acceso a la API"],
popular: true,
},
{
name: "Enterprise",
priceId: "price_enterprise_monthly",
price: 99,
interval: "month",
features: [
"Todo ilimitado",
"100 GB de almacenamiento",
"Soporte dedicado",
"SSO",
"Integraciones personalizadas",
],
},
];
export default function PricingPage() {
return (
<div className="max-w-5xl mx-auto py-16 px-4">
<h1 className="text-4xl font-bold text-center mb-4">Precios</h1>
<p className="text-gray-600 text-center mb-12">
Comienza con una prueba gratuita de 14 días. No se requiere tarjeta de crédito.
</p>
<div className="grid md:grid-cols-3 gap-8">
{tiers.map((tier) => (
<div
key={tier.name}
className={`rounded-xl border p-8 ${
tier.popular
? "border-blue-500 ring-2 ring-blue-500 relative"
: "border-gray-200"
}`}
>
{tier.popular && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-blue-500 text-white text-xs px-3 py-1 rounded-full">
Más popular
</span>
)}
<h2 className="text-xl font-bold">{tier.name}</h2>
<p className="mt-4">
<span className="text-4xl font-bold">${tier.price}</span>
<span className="text-gray-500">/{tier.interval}</span>
</p>
<ul className="mt-6 space-y-3">
{tier.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<span className="text-green-500">✓</span>
{feature}
</li>
))}
</ul>
<form
action={createSubscriptionCheckout.bind(null, tier.priceId)}
className="mt-8"
>
<button
type="submit"
className={`w-full py-3 rounded-lg font-medium ${
tier.popular
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
Iniciar prueba gratuita
</button>
</form>
</div>
))}
</div>
</div>
);
}Profundización
Cómo funciona
- Las suscripciones de Stripe se construyen sobre tres objetos: Product (lo que vendes), Price (cuánto y con qué frecuencia) y Subscription (el plan activo de un cliente).
- Los Products y Prices se pueden crear en el Stripe Dashboard o mediante la API. Cada Price tiene un
interval(day, week, month, year) y unamount. - Cuando usas Checkout con
mode: "subscription", Stripe crea el Customer, la Subscription y gestiona el primer pago automáticamente. - Cuando usas la API directamente con
payment_behavior: "default_incomplete", la suscripción comienza en estadoincompletehasta que se confirme el primer pago en el cliente. - Los estados de la suscripción fluyen así:
trialing(si hay prueba configurada), luegoactive,past_due(pago fallido),canceledounpaid. - Los periodos de prueba retrasan el primer cobro. Durante una prueba, el estado de la suscripción es
trialingy no se genera ninguna factura hasta que termina la prueba.
Variaciones
Precios anuales frente a mensuales:
// Crea ambos precios para el mismo producto
const monthlyPrice = await stripe.prices.create({
product: "prod_xxx",
unit_amount: 2900,
currency: "usd",
recurring: { interval: "month" },
});
const annualPrice = await stripe.prices.create({
product: "prod_xxx",
unit_amount: 29000, // ~$241/mes — descuento anual
currency: "usd",
recurring: { interval: "year" },
});Cancelar una suscripción:
// Cancelar al final del periodo de facturación
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
// Cancelar inmediatamente
await stripe.subscriptions.cancel(subscriptionId);Actualizar suscripción (cambiar de plan):
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscription.items.data[0].id,
price: newPriceId,
},
],
proration_behavior: "create_prorations",
});Notas de TypeScript
stripe.subscriptions.createdevuelvePromise<Stripe.Subscription>.- Cuando usas
expand, los campos expandidos cambian de tipo. Haz un cast explícito después de la expansión. - El estado de la suscripción es un tipo unión:
"active" | "past_due" | "canceled" | "incomplete" | "incomplete_expired" | "trialing" | "unpaid" | "paused".
import type Stripe from "stripe";
function isActive(subscription: Stripe.Subscription): boolean {
return subscription.status === "active" || subscription.status === "trialing";
}Errores comunes
- Nunca concedas acceso basándote únicamente en una redirección exitosa de Checkout. Verifica siempre el estado de la suscripción mediante webhooks (
customer.subscription.created,invoice.paid). - Cuando usas
payment_behavior: "default_incomplete", debes confirmar el PaymentIntent en el cliente; de lo contrario, la suscripción permaneceincompletey expira después de 23 horas. - El prorrateo ocurre por defecto al cambiar de plan a mitad de ciclo. Establece
proration_behavior: "none"para desactivarlo. cancel_at_period_end: trueno cancela de inmediato. La suscripción permaneceactivehasta que termine el periodo actual. Escuchacustomer.subscription.deletedpara revocar el acceso.- Los periodos de prueba creados mediante Checkout requieren
payment_method_collection: "if_required"si no quieres solicitar una tarjeta por adelantado. El valor predeterminado sí la solicita. - Stripe crea una factura de $0 al inicio de la prueba. Esto es normal y esperado.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Sesión de Checkout (modo subscription) | Código mínimo, lo gestiona todo | Personalización limitada de la UI |
| Suscripción creada por API + PaymentElement | Control total de la UI, checkout en la app | Configuración más compleja |
| Payment Links | Configuración de suscripción sin código | Sin control programático |
| Customer Portal para cambios de plan | UI de gestión alojada por Stripe | Sensación menos integrada |
FAQs
¿Cuáles son los tres objetos principales de Stripe que componen una suscripción?
- Product — lo que vendes (p. ej., "Pro Plan")
- Price — cuánto y con qué frecuencia (p. ej., $29/mes)
- Subscription — la asociación activa de un cliente con un Price
¿Cuál es el ciclo de vida del estado de una suscripción?
trialing (si hay prueba configurada) -> active -> past_due (pago fallido) -> canceled o unpaid. La unión completa es: "active" | "past_due" | "canceled" | "incomplete" | "incomplete_expired" | "trialing" | "unpaid" | "paused".
¿Qué hace payment_behavior: "default_incomplete" al crear una suscripción mediante la API?
Inicia la suscripción en estado incomplete, lo que requiere que el cliente confirme el primer PaymentIntent. Si no se confirma en un plazo de 23 horas, la suscripción expira automáticamente.
¿Cómo cancelas una suscripción al final del periodo de facturación actual frente a inmediatamente?
// Fin de periodo (la suscripción permanece active hasta que termine el periodo)
await stripe.subscriptions.update(subId, {
cancel_at_period_end: true,
});
// Inmediatamente
await stripe.subscriptions.cancel(subId);Error común: ¿por qué nunca debes conceder acceso basándote únicamente en una redirección exitosa de Checkout?
Los usuarios pueden navegar manualmente a tu URL de éxito. Verifica siempre el estado de la suscripción mediante webhooks (customer.subscription.created, invoice.paid) o recuperando la sesión/suscripción en el servidor.
¿Qué ocurre con el prorrateo cuando un usuario cambia de plan a mitad de ciclo?
Por defecto, Stripe crea un crédito prorrateado por el tiempo restante del plan anterior y cobra la diferencia del nuevo plan. Establece proration_behavior: "none" para desactivarlo.
¿Cómo creas precios mensuales y anuales para el mismo producto?
await stripe.prices.create({
product: "prod_xxx",
unit_amount: 2900,
currency: "usd",
recurring: { interval: "month" },
});
await stripe.prices.create({
product: "prod_xxx",
unit_amount: 29000,
currency: "usd",
recurring: { interval: "year" },
});Error común: ¿cancel_at_period_end cancela la suscripción de inmediato?
No. La suscripción permanece active hasta que termine el periodo de facturación actual. El usuario conserva el acceso durante ese tiempo. Escucha customer.subscription.deleted para revocar el acceso cuando finalmente se cancele.
¿Qué ocurre durante un periodo de prueba gratuita?
- El estado de la suscripción es
trialing - No se realiza ningún cobro hasta que termina la prueba
- Stripe crea una factura de $0 al inicio de la prueba (esto es normal)
- Por defecto, se solicita una tarjeta por adelantado; usa
payment_method_collection: "if_required"para omitir la recogida de tarjeta
TypeScript: ¿cómo compruebas si una suscripción está activa actualmente?
import type Stripe from "stripe";
function isActive(sub: Stripe.Subscription): boolean {
return sub.status === "active" || sub.status === "trialing";
}TypeScript: ¿por qué necesitas hacer cast de los campos expandidos después de la recuperación?
Cuando usas expand: ["latest_invoice.payment_intent"], los objetos expandidos están tipados como su cadena de ID por defecto. Debes hacerles cast a los tipos reales:
const invoice = subscription.latest_invoice as Stripe.Invoice;
const pi = invoice.payment_intent as Stripe.PaymentIntent;