Pagos con Stripe
Configura pagos únicos con Stripe Checkout
Pagos con Stripe
Configura pagos end-to-end con Stripe Checkout. Al terminar tendrás:
- Productos y precios configurados en Stripe
- Boton de checkout que redirige a Stripe
- Webhook que procesa pagos completados
- Registro de suscripcion en la base de datos
Prerequisito: haber completado Página privada (autenticación y rutas protegidas funcionando).
Paso 1: Crea tu cuenta de Stripe
Registrate en Stripe
- Ve a dashboard.stripe.com y crea una cuenta
- Activa el modo test (toggle arriba a la derecha)
- No necesitas verificar tu identidad para testear
Obten tus claves API
- Ve a Developers > API keys
- Copia la Publishable key (
pk_test_...) - Copia la Secret key (
sk_test_...)
La Secret key solo se muestra una vez. Guardala en un lugar seguro.
Paso 2: Crea productos y precios
En el dashboard de Stripe, ve a Products > Add product.
Crea dos productos (o los que necesites):
Producto 1: Starter
- Nombre:
Starter - Precio:
19.00 EUR/mes(recurring monthly)
Producto 2: Pro
- Nombre:
Pro - Precio:
49.00 EUR/mes(recurring monthly)
Si prefieres pagos únicos (one-time) en lugar de suscripciones, selecciona One time al crear el precio. El boilerplate soporta ambos modos automáticamente.
Copia el Price ID de cada producto (empieza con price_). Lo encuentras en la página del producto, sección Pricing.
Paso 3: Configura las variables de entorno
Añade las claves y Price IDs a tu .env:
# Stripe
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Price IDs
STRIPE_PRICE_ID_STARTER_MONTHLY=price_...
STRIPE_PRICE_ID_STARTER_ANNUAL=price_...
STRIPE_PRICE_ID_PRO_MONTHLY=price_...
STRIPE_PRICE_ID_PRO_ANNUAL=price_...Paso 4: Entiende la configuración de Stripe
Servidor: lib/stripe.ts
El cliente de Stripe en el servidor usa lazy initialization para evitar errores cuando las variables de entorno no estan configuradas:
import Stripe from "stripe";
let _stripe: Stripe | null = null;
export function getStripeServer(): Stripe {
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
if (!_stripe) {
_stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
}
return _stripe;
}
// Proxy para backward compatibility
export const stripe = new Proxy({} as Stripe, {
get(_target, prop) {
return getStripeServer()[prop as keyof Stripe];
},
});Cliente: lib/stripe-client.ts
El cliente de Stripe en el frontend carga Stripe.js para redirecciónes:
import { loadStripe } from "@stripe/stripe-js";
let stripePromise: ReturnType<typeof loadStripe> | null = null;
export function getStripe() {
if (!stripePromise) {
stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
}
return stripePromise;
}Planes: config/stripe.ts
Los planes se definen en config/stripe.ts con sus Price IDs:
export const pricingPlans: PricingPlan[] = [
{
id: "starter",
name: "Starter",
icon: Zap,
description: "Para proyectos que estan empezando",
pricing: {
monthly: {
price: 19,
priceId: process.env.STRIPE_PRICE_ID_STARTER_MONTHLY || "",
},
annual: {
price: 15,
priceId: process.env.STRIPE_PRICE_ID_STARTER_ANNUAL || "",
},
},
currency: "EUR",
cta: "Empezar ahora",
features: ["Hasta 1.000 usuarios", "Pagos con Stripe", /* ... */],
},
// ... plan Pro
];
export const stripeConfig = {
plans: pricingPlans,
successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL}/#pricing`,
portalReturnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
};Cambia los nombres, precios y features para adaptarlos a tu producto.
Paso 5: El endpoint de Checkout
El endpoint app/api/stripe/checkout/route.ts crea la sesión de checkout:
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { stripeConfig } from "@/config/stripe";
import { resolveCheckoutMode } from "@/lib/stripe-events";
export async function POST(request: NextRequest) {
// 1. Verificar autenticación
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
return NextResponse.json(
{ message: "No autorizado. Inicia sesión primero." },
{ status: 401 }
);
}
const { priceId, planName, couponId } = await request.json();
// 2. Obtener o crear Stripe Customer
let stripeCustomerId: string | undefined;
const subscription = await db.subscription.findUnique({
where: { userId: session.user.id },
});
if (subscription?.stripeCustomerId) {
stripeCustomerId = subscription.stripeCustomerId;
} else {
const customer = await stripe.customers.create({
email: session.user.email,
name: session.user.name,
metadata: { userId: session.user.id },
});
stripeCustomerId = customer.id;
await db.subscription.upsert({
where: { userId: session.user.id },
update: { stripeCustomerId: customer.id },
create: {
userId: session.user.id,
stripeCustomerId: customer.id,
},
});
}
// 3. Determinar si es pago único o suscripcion
const price = await stripe.prices.retrieve(priceId);
const mode = resolveCheckoutMode(price.type);
// 4. Crear sesión de checkout
const checkoutSession = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode,
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: stripeConfig.successUrl,
cancel_url: stripeConfig.cancelUrl,
metadata: {
userId: session.user.id,
planName: planName || "",
},
tax_id_collection: { enabled: true },
customer_update: { name: "auto", address: "auto" },
...(couponId
? { discounts: [{ coupon: couponId }] }
: { allow_promotion_codes: true }),
});
return NextResponse.json({ url: checkoutSession.url });
}Flujo del checkout:
- Verifica que el usuario está autenticado
- Busca o crea un Stripe Customer vinculado al usuario
- Determina el modo (
paymentpara pago unico,subscriptionpara recurrente) - Crea la sesión de Stripe Checkout con el Price ID
- Devuelve la URL de checkout para redirigir al usuario
Paso 6: Llama al checkout desde el frontend
Crea un boton que inicie el proceso de pago:
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
interface CheckoutButtonProps {
priceId: string;
planName: string;
children: React.ReactNode;
}
export function CheckoutButton({ priceId, planName, children }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false);
async function handleCheckout() {
setLoading(true);
try {
const res = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId, planName }),
});
const data = await res.json();
if (data.url) {
// Redirigir a Stripe Checkout
window.location.href = data.url;
} else {
console.error("Error:", data.message);
}
} catch (error) {
console.error("Error al crear checkout:", error);
} finally {
setLoading(false);
}
}
return (
<Button onClick={handleCheckout} disabled={loading} className="w-full">
{loading ? "Redirigiendo..." : children}
</Button>
);
}Usa el componente en tu página de pricing:
<CheckoutButton
priceId={process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO || ""}
planName="Pro"
>
Obtener Pro
</CheckoutButton>El priceId debe ser un Price ID valido de Stripe (empieza con price_). Si usas variables de entorno en el cliente, deben tener el prefijo NEXT_PUBLIC_.
Paso 7: Configura el Webhook
El webhook procesa los eventos de Stripe (pagos completados, suscripciones actualizadas, etc.).
Instala Stripe CLI para desarrollo local
# macOS
brew install stripe/stripe-cli/stripe
# O descarga desde https://stripe.com/docs/stripe-cliInicia sesión:
stripe loginReenviar eventos a tu servidor local
stripe listen --forward-to localhost:3000/api/stripe/webhooksEsto te dara un webhook secret (whsec_...). Copialo a tu .env:
STRIPE_WEBHOOK_SECRET=whsec_...Deja stripe listen corriendo en una terminal separada mientras desarrollas.
Que hace el webhook
El archivo app/api/stripe/webhooks/route.ts maneja estos eventos:
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
// Verificar firma del webhook
const event = stripe.webhooks.constructEvent(
body, signature!, webhookSecret
);
switch (event.type) {
case "checkout.session.completed":
// Pago completado -> Activar plan del usuario
await handleCheckoutCompleted(session);
break;
case "customer.subscription.updated":
// Suscripcion actualizada -> Sincronizar estado
await handleSubscriptionUpdate(subscription);
break;
case "customer.subscription.deleted":
// Suscripcion cancelada -> Marcar como cancelado
await handleSubscriptionDeleted(subscription);
break;
case "invoice.paid":
// Factura pagada -> Reactivar si estaba pendiente
await handleInvoicePaid(invoice);
break;
case "invoice.payment_failed":
// Pago fallido -> Marcar como past_due
await handlePaymentFailed(invoice);
break;
}
return NextResponse.json({ received: true });
}Cuando se completa un checkout, handleCheckoutCompleted guarda el plan en la tabla subscriptions:
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
const planName = session.metadata?.planName;
await db.subscription.upsert({
where: { userId },
update: {
plan: planName,
status: "active",
stripePriceId: priceId,
},
create: {
userId,
stripeCustomerId: session.customer as string,
plan: planName,
status: "active",
stripePriceId: priceId,
},
});
}Paso 8: Prueba el flujo completo
Arranca los servicios
En una terminal:
npm run devEn otra terminal:
stripe listen --forward-to localhost:3000/api/stripe/webhooksRealiza un pago de prueba
- Inicia sesión en tu app (Google OAuth o email/password)
- Como
requirePaymentestá activado por defecto, verás la página de pricing en lugar del dashboard - Elige un plan y haz click en el botón de checkout
- En la página de Stripe, usa la tarjeta de prueba:
Numero: 4242 4242 4242 4242
Fecha: Cualquier fecha futura (ej: 12/34)
CVC: Cualquier 3 digitos (ej: 123)
Nombre: Cualquier nombre- Completa el pago
- Deberías ser redirigido a
/dashboard?success=truey ahora ver el dashboard completo con sidebar
Si requirePayment está en true (por defecto), el usuario ve pricing al hacer login y solo accede al dashboard después de pagar. Los admins se saltan el paywall. Puedes cambiar esto en config/site.ts.
Verifica en la base de datos
npx prisma studioNavega a la tabla subscriptions. Deberías ver:
| Campo | Valor esperado |
|---|---|
userId | Tu ID de usuario |
stripeCustomerId | cus_... |
plan | starter o pro |
status | active |
stripePriceId | price_... |
Verifica los logs de Stripe CLI
En la terminal donde corre stripe listen, deberías ver:
2024-01-01 12:00:00 --> checkout.session.completed [evt_...]
2024-01-01 12:00:00 <-- [200] POST http://localhost:3000/api/stripe/webhooksSi ves [200], el webhook se proceso correctamente.
Paso 9: Webhook en producción
Cuando despliegues a producción, configura el webhook en el dashboard de Stripe:
Crea el endpoint en Stripe
- Ve a Developers > Webhooks > Add endpoint
- URL:
https://tu-dominio.com/api/stripe/webhooks - Eventos a escuchar:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
- Click Add endpoint
Copia el signing secret
En la página del endpoint, click en Reveal para ver el signing secret. Actualizalo en tus variables de entorno de producción (Vercel):
STRIPE_WEBHOOK_SECRET=whsec_... # El nuevo secret de producciónEl STRIPE_WEBHOOK_SECRET de desarrollo (Stripe CLI) y el de producción (Stripe Dashboard) son diferentes. Asegúrate de usar el correcto en cada entorno.
Modelo de datos: Subscription
La tabla subscriptions en tu base de datos tiene está estructura:
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
plan String? // "starter" | "pro"
status String @default("inactive")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("subscriptions")
}Los estados posibles del campo status:
| Status | Significado |
|---|---|
active | Pago completado, acceso completo |
inactive | Sin suscripcion o pago pendiente |
canceled | Suscripcion cancelada |
past_due | Pago fallido, pendiente de reintento |
Verificar el plan del usuario
Para comprobar si un usuario tiene un plan activo, consulta la tabla subscriptions:
import { db } from "@/lib/db";
async function hasActivePlan(userId: string): boolean {
const subscription = await db.subscription.findUnique({
where: { userId },
});
return subscription?.status === "active";
}
async function getUserPlan(userId: string): string | null {
const subscription = await db.subscription.findUnique({
where: { userId },
select: { plan: true, status: true },
});
if (subscription?.status !== "active") return null;
return subscription.plan;
}Usa esto en tus páginas o API routes para controlar el acceso por plan.
Errores comunes
"No signatures found matching the expected signature"
- El
STRIPE_WEBHOOK_SECRETno coincide con el entorno - En desarrollo, usa el secret que te da
stripe listen - En producción, usa el secret del dashboard de Stripe
- Reinicia el servidor despues de cambiar el secret
Se crean multiples Stripe Customers para el mismo usuario
- El endpoint verifica primero si ya existe un
stripeCustomerIden la tablasubscriptions - Si hay registros huerfanos, limpia la tabla con Prisma Studio
- Verifica que el
userIdes consistente
"No such price: price_xxx"
- Verifica que el Price ID existe en tu dashboard de Stripe
- Asegúrate de que usas claves del mismo entorno (test con test, live con live)
- Los Price IDs de test y live son diferentes
El pago se completo en Stripe pero no se refleja en la base de datos
- Verifica que
stripe listenestá corriendo (desarrollo) - Revisa los logs del webhook en Stripe Dashboard > Webhooks > Events
- Confirma que
userIdestá en el metadata del checkout session - Revisa los logs del servidor para errores en el handler del webhook
Resumen
Has configurado:
- Cuenta de Stripe con productos y precios
- Variables de entorno para claves API y Price IDs
- Endpoint de checkout que crea sesiónes de Stripe
- Boton de checkout en el frontend
- Webhook que procesa pagos y actualiza la base de datos
- Testing con Stripe CLI y tarjeta de prueba
Siguientes pasos
- Configura el Portal de cliente de Stripe para que los usuarios gestiónen sus suscripciones
- Personaliza los emails transaccionales de confirmación de pago
- Consulta la guia de Deploy en Vercel para configurar las variables de producción