Next.js e-commerce en la práctica: guía completa de carrito y pago con Stripe

Revisión número 27 del Webhook de Stripe: el usuario dice que ya le cobraron, pero el pedido sigue en «pendiente de pago». En test todo iba bien; ¿por qué en producción se rompe?
La primera vez que hice un proyecto e-commerce con Next.js, pensé que lo más difícil sería la interfaz y los estilos. Al meterme de lleno, el carrito, el pago y el flujo de pedidos escondían trampas en cada paso. Redux me parecía pesado, Context API lento, la documentación de Stripe densa y los Webhooks un misterio.
Lo peor: la mayoría de tutoriales cubren solo el carrito o solo el pago; pocos unen el flujo completo. Seguro te preguntas: «¿qué librería de estado elijo?», «¿para qué sirve el Webhook?», «¿cómo sincronizo el estado del pedido con el pago?».
En este artículo quiero aplanar esas trampas de una vez. Usaremos Zustand para el carrito (ligero y práctico), Stripe para el pago (estándar internacional) y Webhooks para los pedidos (la única vía fiable). Cada paso incluye código completo, listo para copiar y ejecutar.
¿Recuerdas la satisfacción de ver algo funcionar de verdad? Si sigues esta guía, la tendrás.
¿Por qué Zustand para gestionar el carrito?
Elección de gestión de estado en 2025: deja de darle vueltas
Elegir librería de estado puede volverte loco. La documentación de Redux es un tocho, Context API aparece en todos los debates de rendimiento y Zustand parece demasiado nuevo. Yo también oscilé entre las tres hasta ver unos datos que me convencieron.
Desde 2021, Zustand es una de las librerías de estado de React que más rápido crece en estrellas. En 2025, su enfoque está probado: funcional, basado en hooks, API clara. Y su curva de aprendizaje es suave, sin la montaña de conceptos de Redux (actions, reducers, dispatch, middleware…).
¿Cómo elegir? Te lo resumo:
- Proyectos pequeños (<10 páginas): Context API basta; no te compliques
- Proyectos medianos (10-50 páginas): Zustand, ligero y suficiente
- Proyectos grandes (50+ páginas, varios equipos): Redux Toolkit, ecosistema maduro
El carrito encaja bien con Zustand porque necesita compartir estado entre componentes (listado, icono del carrito, checkout), persistencia (no perder datos al refrescar) y buen rendimiento (actualizar solo lo necesario). Zustand lo cubre con la mitad de código que Redux.
Código del carrito con Zustand
Basta de teoría; al código. Instala dependencias:
npm install zustand
Crea el Store del carrito (/store/cartStore.js):
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useCartStore = create(
persist(
(set, get) => ({
// Estado
items: [], // [{ id, name, price, quantity, image }]
// Propiedades calculadas
get total() {
return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
get count() {
return get().items.reduce((sum, item) => sum + item.quantity, 0)
},
// Métodos
addItem: (product) => set((state) => {
const existing = state.items.find(item => item.id === product.id)
if (existing) {
// Ya existe, incrementar cantidad
return {
items: state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
} else {
// Producto nuevo
return { items: [...state.items, { ...product, quantity: 1 }] }
}
}),
removeItem: (productId) => set((state) => ({
items: state.items.filter(item => item.id !== productId)
})),
updateQuantity: (productId, quantity) => set((state) => ({
items: state.items.map(item =>
item.id === productId ? { ...item, quantity } : item
)
})),
clearCart: () => set({ items: [] })
}),
{
name: 'shopping-cart', // clave de localStorage
}
)
)
La lógica es simple: items guarda productos, total y count se calculan, y los métodos gestionan altas, bajas y cambios. El middleware persist guarda en localStorage; al refrescar no pierdes nada.
En componentes es igual de sencillo:
import { useCartStore } from '@/store/cartStore'
function ProductCard({ product }) {
const addItem = useCartStore(state => state.addItem)
return (
<button onClick={() => addItem(product)}>
Añadir al carrito
</button>
)
}
function CartIcon() {
const count = useCartStore(state => state.count)
return <div>Carrito ({count})</div>
}
Fíjate en useCartStore(state => state.addItem): es un selector. Solo se suscribe a addItem y no re-renderiza por otros cambios del carrito. Ese es el secreto del rendimiento de Zustand: suscripciones precisas.
Si vienes de Redux (useSelector, useDispatch), Zustand te parecerá mucho más directo: sin action types ni reducers, métodos en el Store y listo.
¿Y si ya usas Redux? No hace falta cambiar. Redux Toolkit también va muy bien; el proyecto open source C-Shopping usa Redux Toolkit + RTK Query. Para proyectos nuevos, yo recomiendo Zustand: menos curva y más velocidad.
Flujo completo de integración con Stripe
Entiende primero el flujo de pago de Stripe
La primera vez que leí la documentación de Stripe, me preguntaba: ¿qué es Checkout Session? ¿Payment Intent? ¿Por qué redirigir a una página de Stripe? ¿No puedo cobrar en mi sitio?
Mirando atrás, el flujo es claro:
- Frontend: el usuario pulsa «Ir al pago»; tu API crea Checkout Session
- Backend: crea la Session y devuelve session.id
- Frontend: con session.id, Stripe.js redirige a la página de pago alojada
- Usuario: introduce la tarjeta y completa el pago
- Stripe: tras el éxito, envía un Webhook a tu backend
- Backend: recibe el Webhook, crea el pedido, descuenta stock y envía email
- Stripe: redirige al usuario a tu sitio (success_url)
Lo crucial: nunca proceses el éxito del pago en el frontend. El usuario puede cerrar el navegador, perder la red o no completar la redirección. La única vía fiable es el Webhook; lo veremos en detalle.
Crear Stripe Checkout Session
Instala dependencias:
npm install stripe @stripe/stripe-js
Configura variables de entorno (.env.local):
STRIPE_SECRET_KEY=sk_test_xxxxx # Backend; nunca en el frontend
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx # Frontend
STRIPE_WEBHOOK_SECRET=whsec_xxxxx # Verificación de firma del Webhook
Crea la ruta API (/pages/api/create-checkout.js):
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
try {
const { items } = req.body // Datos del carrito
// Crear line_items (formato requerido por Stripe)
const lineItems = items.map(item => ({
price_data: {
currency: 'usd',
product_data: {
name: item.name,
images: [item.image],
},
unit_amount: Math.round(item.price * 100), // Stripe usa centavos
},
quantity: item.quantity,
}))
// Crear Checkout Session
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: lineItems,
mode: 'payment', // Pago único (suscripción: 'subscription')
success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/cart`,
metadata: {
// Datos personalizados; el Webhook puede leerlos
userId: req.user?.id || 'guest',
},
})
res.status(200).json({ sessionId: session.id })
} catch (err) {
console.error('Error al crear Checkout Session:', err)
res.status(500).json({ error: err.message })
}
}
Detalles importantes:
unit_amountse multiplica por 100: Stripe usa centavos (99,99 USD = 9999){CHECKOUT_SESSION_ID}ensuccess_urles un placeholder que Stripe sustituyemetadataguarda datos de negocio (userId, notas) accesibles en el Webhook
Llamada al Checkout desde el frontend
En la página de pago (/pages/checkout.js):
import { loadStripe } from '@stripe/stripe-js'
import { useCartStore } from '@/store/cartStore'
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
export default function CheckoutPage() {
const { items, total } = useCartStore()
const handleCheckout = async () => {
try {
// Llamar API para crear Session
const response = await fetch('/api/create-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
})
const { sessionId } = await response.json()
// Redirigir a la página de pago de Stripe
const stripe = await stripePromise
const { error } = await stripe.redirectToCheckout({ sessionId })
if (error) {
console.error('Error al redirigir al pago:', error)
alert(error.message)
}
} catch (err) {
console.error('Error al iniciar el pago:', err)
alert('Pago fallido, inténtalo de nuevo')
}
}
return (
<div>
<h1>Finalizar compra</h1>
{items.map(item => (
<div key={item.id}>
{item.name} x {item.quantity} = ${item.price * item.quantity}
</div>
))}
<div>Total: ${total}</div>
<button onClick={handleCheckout}>Ir al pago</button>
</div>
)
}
Tras pulsar «Ir al pago», el usuario va a la página alojada por Stripe: formulario, validación de tarjeta y antifraude sin que tú lo implementes.
¿Personalizar el estilo? Sí: colores, logo y fuentes. El layout base es de Stripe. Para control total del UI están Stripe Elements, pero es más complejo; no lo recomiendo al empezar.
Redirección tras el pago
Stripe redirige a tu success_url. Ahí puedes mostrar el pedido:
// /pages/success.js
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
export default function SuccessPage() {
const router = useRouter()
const { session_id } = router.query
const [order, setOrder] = useState(null)
useEffect(() => {
if (session_id) {
// Obtener pedido del backend
fetch(`/api/order?session_id=${session_id}`)
.then(res => res.json())
.then(data => setOrder(data))
}
}, [session_id])
if (!order) return <div>Cargando...</div>
return (
<div>
<h1>¡Pago exitoso!</h1>
<p>Número de pedido: {order.id}</p>
<p>Importe: ${order.total}</p>
</div>
)
}
Recuerda: esta página es solo informativa. La creación real del pedido va en el Webhook. Vamos con eso.
Webhook: pedidos y sincronización de estado
¿Por qué importan tanto los Webhooks?
La primera vez pensé que volver a la página success bastaba y metí ahí toda la lógica. En pruebas, usuarios cerraban el navegador tras pagar: sin pedido y sin saber si el cobro quedó colgado.
La documentación oficial de Stripe lo deja claro: el Webhook es la única forma fiable de procesar pedidos.
- La redirección no es fiable: cierre del navegador, red caída, botón «completar» ignorado
- Seguridad: crear pedidos, descontar stock o enviar no puede depender del frontend
- Recomendación de Stripe: la lógica crítica va en el Webhook
Un Webhook es Stripe llamando a tu servidor: «este pago se completó» o «esta suscripción se canceló». Tú reaccionas en consecuencia.
Crear el endpoint Webhook
En Next.js, /pages/api/stripe-webhook.js:
import Stripe from 'stripe'
import { buffer } from 'micro'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
// Clave: desactivar el parseo por defecto del body
export const config = {
api: {
bodyParser: false,
},
}
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).send('Method not allowed')
}
const buf = await buffer(req)
const sig = req.headers['stripe-signature']
let event
try {
// Verificar firma del Webhook (¡muy importante!)
event = stripe.webhooks.constructEvent(buf, sig, webhookSecret)
} catch (err) {
console.error('Fallo en verificación de firma del Webhook:', err.message)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// Manejar tipos de evento
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutSessionCompleted(event.data.object)
break
case 'payment_intent.succeeded':
await handlePaymentIntentSucceeded(event.data.object)
break
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object)
break
default:
console.log(`Tipo de evento no manejado: ${event.type}`)
}
res.status(200).json({ received: true })
}
async function handleCheckoutSessionCompleted(session) {
console.log('¡Pago exitoso!', session.id)
const userId = session.metadata.userId
const sessionId = session.id
const total = session.amount_total / 100 // Volver a dólares
// Idempotencia: ¿ya existe el pedido?
const existingOrder = await db.order.findUnique({
where: { stripeSessionId: sessionId }
})
if (existingOrder) {
console.log('Pedido ya existe, omitiendo creación')
return
}
const order = await db.order.create({
data: {
userId,
stripeSessionId: sessionId,
status: 'paid',
total,
// ... otros campos
}
})
await updateInventory(order.items)
await sendOrderConfirmationEmail(userId, order)
console.log('Pedido creado correctamente:', order.id)
}
async function handlePaymentIntentSucceeded(paymentIntent) {
console.log('Pago confirmado:', paymentIntent.id)
}
async function handleInvoicePaymentFailed(invoice) {
console.log('Pago fallido:', invoice.id)
// Email de aviso, suspender servicio, etc.
}
Puntos clave:
- Desactiva bodyParser: Stripe necesita el body raw para la firma; si Next.js lo parsea antes, falla la verificación
- Verifica la firma:
constructEventconfirma que la petición viene de Stripe - Idempotencia: Stripe puede reenviar eventos;
stripeSessionIdúnico evita pedidos duplicados
Probar Webhooks en local
Stripe no llama a localhost; usa Stripe CLI:
# Mac
brew install stripe/stripe-cli/stripe
# Windows (Scoop)
scoop install stripe
# O descarga oficial
# https://stripe.com/docs/stripe-cli
Inicia sesión y escucha:
stripe login
stripe listen --forward-to localhost:3000/api/stripe-webhook
Copia el secret temporal (whsec_xxxxx) a .env.local:
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
En otra terminal, dispara un evento de prueba:
stripe trigger checkout.session.completed
Verás logs en CLI y en Next.js; ya puedes depurar la creación de pedidos.
Aquí me atascé mucho rato con fallos de firma: no había desactivado bodyParser. No olvides el export const config.
Gestión del estado del pedido
Flujo típico:
Pendiente de pago → Pagado → Preparando → Enviado → Completado
↓
Cancelado / Reembolsado
En base de datos, enum:
// schema.prisma
model Order {
id String @id @default(cuid())
stripeSessionId String @unique // Idempotencia
userId String
status OrderStatus @default(PENDING)
total Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum OrderStatus {
PENDING // Pendiente de pago
PAID // Pagado
PREPARING // Preparando envío
SHIPPED // Enviado
COMPLETED // Completado
CANCELLED // Cancelado
REFUNDED // Reembolsado
}
Con checkout.session.completed, pasa a PAID. Envío y cierre los gestiona tu backoffice.
Manejo de errores
El Webhook puede fallar (BD caída, servicio externo, etc.). Stripe reintenta, pero conviene registrar fallos:
async function handleCheckoutSessionCompleted(session) {
try {
// Lógica de negocio
} catch (error) {
console.error('Error al procesar pedido:', error)
await logError({
type: 'webhook_error',
event: 'checkout.session.completed',
sessionId: session.id,
error: error.message,
})
throw error // Stripe reintentará
}
}
Si falla, Stripe reintenta durante 3 días. En el Dashboard puedes ver Webhooks fallidos y reenviarlos.
Flujo completo de pedido en la práctica
Con todas las piezas, así encaja un pedido de punta a punta.
Ruta completa del usuario
- Página de producto: «Añadir al carrito»; Zustand actualiza el contador del icono
- Carrito: revisa cantidades y pulsa «Finalizar compra»
- Checkout: resumen y «Ir al pago»
- Frontend: llama a
/api/create-checkoutcon los items - Backend: crea Session y devuelve sessionId
- Frontend: redirige a Stripe
- Usuario: tarjeta y «Pay»
- Stripe: cobra y envía Webhook a
/api/stripe-webhook - Webhook: verifica firma → crea pedido → descuenta stock → envía email
- Stripe: redirige a
/success?session_id=xxx - Frontend: success llama a
/api/order?session_id=xxxy muestra detalles
Parece largo, pero cada paso es claro. El paso 9 debe ocurrir en el Webhook, no en el 11.
Diseño de base de datos
model Order {
id String @id @default(cuid())
stripeSessionId String @unique
userId String
status OrderStatus @default(PENDING)
total Float
items OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String
quantity Int
price Float // Precio al momento del pedido
order Order @relation(fields: [orderId], references: [id])
product Product @relation(fields: [productId], references: [id])
}
OrderItem.price guarda el precio en el momento del pedido, no el actual del producto. Si sube el precio después, el histórico no cambia.
Casos límite
1. ¿Stock insuficiente?
Comprueba antes de crear la Session:
// /pages/api/create-checkout.js
const { items } = req.body
for (const item of items) {
const product = await db.product.findUnique({ where: { id: item.id } })
if (product.stock < item.quantity) {
return res.status(400).json({ error: `${product.name} sin stock suficiente` })
}
}
// Stock OK, seguir con la Session...
2. ¿Pago OK pero Webhook falló?
Stripe reintenta 3 días. También puedes reenviar desde el Dashboard o un job que detecte sesiones pagadas sin pedido.
3. ¿Pagó pero no hay stock al enviar?
Vuelve a comprobar stock antes de enviar; contacta para reembolso o cambio.
Despliegue en producción
Que funcione en test no basta. Varios detalles evitan sustos.
Variables de entorno
Las claves de producción no son las de test:
# .env.production
STRIPE_SECRET_KEY=sk_live_xxxxx # live, no test
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx # Secret de producción
En Vercel u otra plataforma, configúralas en el panel. Nunca subas la Secret Key a Git.
Endpoint Webhook
En test usas Stripe CLI; en producción configura en el Dashboard:
- Stripe Dashboard
- Developers → Webhooks
- Add endpoint
- URL:
https://yourdomain.com/api/stripe-webhook - Eventos:
checkout.session.completed,payment_intent.succeeded, etc. - Copia el Signing secret a las variables de entorno
La primera vez olvidé esto: en producción no llegaba ningún Webhook y los pedidos no se creaban.
Checklist de seguridad
Antes de publicar:
- ✅ Toda la lógica de pago en el backend (el frontend solo redirige)
- ✅ Webhook con verificación de firma (
constructEvent) - ✅ Monto del pago = monto del pedido (anti-manipulación)
- ✅ Idempotencia (
stripeSessionIdúnico) - ✅ Logs de pagos (Sentry, Datadog, etc.)
- ✅ Alertas (fallos de Webhook, tasa de éxito)
El tercer punto importa aunque el precio se fije al crear la Session: en el Webhook vuelve a validar.
Monitoreo y alertas
// /pages/api/stripe-webhook.js
import * as Sentry from '@sentry/nextjs'
export default async function handler(req, res) {
try {
// ... lógica del Webhook
} catch (error) {
Sentry.captureException(error, {
tags: {
type: 'stripe_webhook',
event: event.type,
},
})
throw error
}
}
Métricas a vigilar:
- Tasa de fallo del Webhook (alerta si >5%)
- Tasa de éxito de pagos (caídas bruscas = Stripe o config)
- Tiempo de creación de pedido (>3 s, investigar)
Resumen: de cero a producción
Pasos clave:
- Carrito: Zustand (ligero) o Redux Toolkit (grandes), con persist
- Pago: Checkout Session y página alojada de Stripe
- Pedidos: Webhook para crear, descontar stock y enviar email
- Producción: variables, endpoint Webhook y monitoreo
Tres principios:
- El pago vive en el backend: el frontend no es de fiar
- El Webhook es la fuente fiable: la redirección del usuario no lo es
- Seguridad primero: firma, anti-replay, logs
Si es tu primer e-commerce, prueba todo en entorno test de Stripe. Tarjeta: 4242 4242 4242 4242 (fecha y CVV cualquiera). Cuando esté sólido, pasa a live.
Recursos para profundizar:
- Documentación oficial de Stripe: https://stripe.com/docs
- Tutorial Next.js + Stripe: guía 2025 de Pedro Alonso (busca «Stripe Next.js 15 complete guide»)
- Referencia open source: C-Shopping (Redux Toolkit + Stripe)
Ojalá esto te ahorre tropiezos. Cuando veas el pedido crearse solo, el stock bajar bien y llegar el email de confirmación, la sensación compensa el esfuerzo. ¡Ánimo!
Flujo completo de carrito e-commerce Next.js y pago con Stripe
Pasos detallados para construir un carrito y sistema de pago desde cero, incluyendo gestión de estado, integración de pago y procesamiento de pedidos
⏱️ Estimated time: 2 hr
- 1
Step 1: Instalar dependencias y configurar el carrito con Zustand
Instala la librería de gestión de estado Zustand:
• npm install zustand
Crea el Store del carrito (/store/cartStore.js):
• Define un array items para almacenar productos
• Añade propiedades calculadas total y count
• Implementa addItem, removeItem, updateQuantity, clearCart
• Usa el middleware persist para guardar en localStorage
Configuración clave:
• persist persiste automáticamente; no pierdes datos al refrescar
• Usa selectores (useCartStore(state => state.addItem)) para evitar re-renderizados innecesarios
Casos de uso: proyectos pequeños y medianos (10-50 páginas) que necesitan gestión de estado ligera - 2
Step 2: Crear la API de Stripe Checkout Session
Instala dependencias de Stripe:
• npm install stripe @stripe/stripe-js
Configura variables de entorno (.env.local):
• STRIPE_SECRET_KEY=sk_test_xxxxx (backend, no exponer)
• NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx (frontend)
• STRIPE_WEBHOOK_SECRET=whsec_xxxxx (verificación de firma del Webhook)
Crea la ruta API (/pages/api/create-checkout.js):
• Recibe los items del carrito
• Convierte al formato line_items de Stripe (unit_amount debe multiplicarse por 100)
• Crea checkout.sessions (configura success_url y cancel_url)
• Guarda datos personalizados en metadata (userId, etc.)
• Devuelve sessionId al frontend
Detalles clave:
• Stripe usa centavos; multiplica el precio por 100
• success_url usa el placeholder {CHECKOUT_SESSION_ID}
• metadata almacena datos de negocio accesibles en el Webhook - 3
Step 3: Llamada al Checkout de Stripe desde el frontend
Implementa la página de pago (/pages/checkout.js):
• Usa loadStripe para cargar Stripe.js
• Llama a /api/create-checkout para crear la Session
• Usa stripe.redirectToCheckout() para redirigir a la página de pago
Manejo de errores:
• catch para errores de red
• Verifica el error devuelto por stripe.redirectToCheckout
• Muestra mensajes amigables al usuario
Sobre la página de pago:
• Stripe aloja la página; no necesitas escribir el formulario
• Maneja validación de tarjeta y detección de fraude automáticamente
• Puedes personalizar colores, logo y fuentes
Importante:
• Nunca proceses la lógica de pago exitoso en el frontend
• El usuario puede cerrar el navegador o no pulsar completar
• La creación real del pedido debe ocurrir en el Webhook - 4
Step 4: Configurar el endpoint Webhook para procesar pedidos
Crea la API Webhook (/pages/api/stripe-webhook.js):
Configuración obligatoria:
• export const config = { api: { bodyParser: false } } (desactivar parseo del body)
• Usa buffer(req) para obtener el cuerpo raw
• stripe.webhooks.constructEvent() para verificar la firma
Tipos de eventos:
• checkout.session.completed: pago exitoso, crear pedido
• payment_intent.succeeded: confirmar recepción del pago
• invoice.payment_failed: fallo de pago de suscripción
Idempotencia:
• Verifica si stripeSessionId ya existe
• Añade índice unique en la base de datos
• Evita pedidos duplicados por reintentos del Webhook
Lógica de negocio:
• Crear registro de pedido (status: 'paid')
• Descontar inventario (updateInventory)
• Enviar email de confirmación (sendOrderConfirmationEmail)
• Registrar logs y errores
Prueba local:
• stripe login
• stripe listen --forward-to localhost:3000/api/stripe-webhook
• stripe trigger checkout.session.completed
Crítico:
• Debes desactivar bodyParser o fallará la verificación de firma
• Debes verificar la firma para evitar peticiones falsas
• Stripe reintenta Webhooks fallidos durante 3 días - 5
Step 5: Despliegue en producción y configuración de seguridad
Variables de entorno:
• Usa claves de producción (sk_live_xxxxx y pk_live_xxxxx)
• Configúralas en la plataforma (Vercel/Netlify)
• Nunca subas la Secret Key a Git
Stripe Dashboard:
• Developers → Webhooks
• Añade endpoint de producción (https://yourdomain.com/api/stripe-webhook)
• Selecciona eventos (checkout.session.completed, etc.)
• Copia el Signing secret a las variables de entorno
Checklist de seguridad:
• ✅ Toda la lógica de pago en el backend
• ✅ Webhook verifica firma
• ✅ Validar que el monto del pago coincide con el pedido
• ✅ Idempotencia (índice único en stripeSessionId)
• ✅ Registrar todos los logs de pago
• ✅ Alertas de monitoreo (tasa de fallo Webhook >5%)
Métricas:
• Tasa de fallo del Webhook
• Tasa de éxito de pagos
• Tiempo de creación de pedidos (>3s requiere investigación)
Monitoreo:
• Sentry/LogRocket para errores
• Reglas de alerta
• Revisar logs de Webhook en Stripe Dashboard
Pruebas:
• Tarjeta de prueba 4242 4242 4242 4242
• Flujo completo (carrito → pago → Webhook → pedido)
• Escenarios de fallo (stock insuficiente, Webhook fallido, etc.)
FAQ
¿Cómo elijo entre Redux y Zustand? ¿Cuál conviene para mi proyecto?
• Proyectos pequeños (<10 páginas): Context API basta; no necesitas librería extra
• Proyectos medianos (10-50 páginas): Zustand encaja mejor — ligero, curva de aprendizaje baja, menos código
• Proyectos grandes (50+ páginas, varios equipos): Redux Toolkit — herramientas maduras, depuración potente, comunidad consolidada
Escenarios concretos:
• Proyecto nuevo, iteración rápida: Zustand, rápido de aprender y de entregar
• Proyecto ya en Redux: no hace falta cambiar; Redux Toolkit funciona muy bien
• Equipo sin experiencia en gestión de estado: Zustand tiene curva más suave
Para carritos recomiendo Zustand: compartir entre componentes, persistencia y rendimiento.
¿Por qué no puedo procesar la lógica de pago exitoso en el frontend?
Fiabilidad:
• El usuario puede cerrar el navegador tras pagar
• Una caída de red puede impedir la redirección
• Puede no pulsar completar a propósito
Seguridad:
• El código frontend se puede manipular
• Crear pedidos o descontar stock no puede exponerse en el cliente
• No puedes evitar que alguien falsifique un estado de pago exitoso
Enfoque correcto:
• Toda la lógica crítica en el Webhook
• Stripe notifica directamente a tu backend (sin pasar por el navegador)
• El Webhook tiene verificación de firma
• Recomendación oficial de Stripe: el Webhook es la única fuente fiable para pedidos
La página success del frontend solo muestra información; no ejecuta lógica de negocio.
La verificación de firma del Webhook falla siempre — ¿qué hago?
Causa más común (90%):
• bodyParser de Next.js no está desactivado
• Solución: añade export const config = { api: { bodyParser: false } } en la ruta API
Otras causas:
• Webhook Secret mal configurado (revisa STRIPE_WEBHOOK_SECRET en .env.local)
• Clave de entorno incorrecta (test vs producción)
• El body fue modificado por middleware global
Pasos de depuración:
1. Confirma bodyParser: false
2. Imprime req.headers['stripe-signature'] para verificar que existe
3. Prueba con Stripe CLI: stripe listen --forward-to localhost:3000/api/stripe-webhook
4. Revisa el error detallado en la salida del CLI
5. Confirma que usas el webhook secret temporal del CLI
En local:
• Debes usar Stripe CLI para reenviar
• El CLI da un secret temporal (whsec_xxxxx)
• Cada reinicio del CLI genera un secret nuevo; actualiza .env.local
¿Cómo evito que llamadas duplicadas del Webhook creen varios pedidos?
A nivel de base de datos:
• Índice unique en stripeSessionId
• Ejemplo Prisma: stripeSessionId String @unique
• La base de datos rechaza inserciones duplicadas
A nivel de código:
• Consulta si el pedido ya existe antes de crear
• Usa findUnique({ where: { stripeSessionId } })
• Si existe, devuelve sin crear otro
Código de ejemplo:
```javascript
const existingOrder = await db.order.findUnique({
where: { stripeSessionId: sessionId }
})
if (existingOrder) {
console.log('Pedido ya existe, omitiendo creación')
return
}
// Solo crear si no existe
const order = await db.order.create({ ... })
```
Por qué hace falta idempotencia:
• Stripe puede reenviar Webhooks (red, reintentos)
• Tu código debe manejar llamadas repetidas con seguridad
• Evita varios pedidos y descuentos de stock duplicados
Otras recomendaciones:
• Registra cada llamada al Webhook
• Monitoriza la frecuencia de duplicados
• Configura alertas
Tras desplegar en producción no se crean pedidos — ¿cómo lo investigo?
1. ¿Llega el Webhook?
• Stripe Dashboard → Developers → Webhooks
• Revisa historial y estado (éxito/fallo)
• Sin registros = problema de configuración del endpoint
2. Configuración del endpoint:
• ¿URL correcta? (https://yourdomain.com/api/stripe-webhook)
• ¿Evento checkout.session.completed seleccionado?
• ¿Endpoint activado?
3. Variables de entorno:
• ¿STRIPE_WEBHOOK_SECRET correcto?
• ¿Secret de producción (no de test)?
• ¿Configurado en Vercel/Netlify?
4. Código del endpoint:
• ¿bodyParser desactivado?
• ¿Verificación de firma correcta?
• ¿Hay logs de error?
5. Logs de la aplicación:
• Vercel Logs/CloudWatch, etc.
• Busca stack traces
• ¿Se ejecuta el handler del Webhook?
6. Prueba manual:
• Webhook fallido en Stripe Dashboard → Resend
• Observa éxito o mensaje de error
Errores frecuentes:
• Olvidar añadir el endpoint en producción
• Usar secret de test en producción
• Firewall de la plataforma bloqueando a Stripe
Tras corregir:
• Prueba el flujo completo con tarjeta de test
• Confirma creación de pedido, descuento de stock y envío de email
14 min de lectura · Publicado el: 7 ene 2026 · Actualizado el: 21 ago 2026
Guía completa de Next.js
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Pruebas E2E en Next.js: guía práctica de automatización con Playwright
Experiencia completa de pruebas E2E automatizadas, desde pruebas manuales hasta Playwright: configuración, Page Object Model, pruebas de API e integración CI/CD.
Parte 36 de 51
Siguiente
Guía completa de subida de archivos en Next.js: carga directa con URL pre-firmada en S3/Qiniu Cloud
Aprende a usar URLs pre-firmadas para subir archivos directamente a S3 o Qiniu Cloud desde Next.js, superar el límite de 4 MB, soportar archivos de hasta 5 GB, con ejemplos de código completos, optimización de rendimiento y mejores prácticas para producción.
Parte 38 de 51



Comentarios
Inicia sesión con GitHub para dejar un comentario