Nota revisada y reescrita el 18 de octubre de 2026: la versión original tenía errores y datos sin fuente.
El fallo más caro de un checkout casero cabe en una línea: el carrito del navegador manda { productId, price } y el servidor cobra price. Cualquiera puede abrir DevTools, cambiar el precio a 1 y comprar por un céntimo. Next.js con Server Actions hace que esto sea más fácil de cometer, porque la llamada al servidor parece una llamada a una función normal.
Una Server Action es un endpoint público
Una función marcada con 'use server' se puede invocar con una petición POST desde cualquier sitio, no solo desde tu formulario. La documentación de Next.js lo dice explícitamente: trátalas como endpoints de API públicos, comprueba la autenticación y la autorización dentro de cada acción, y valida sus argumentos. Que el botón no se vea en la interfaz no protege nada.
El patrón: el cliente manda qué y cuánto; el servidor decide el precio
// app/checkout/actions.js
'use server';
import Stripe from 'stripe';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { getProductsByIds } from '@/lib/catalog';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const Cart = z.array(
z.object({
id: z.string().min(1),
quantity: z.number().int().min(1).max(20),
})
).min(1).max(50);
export async function createCheckout(rawCart) {
const cart = Cart.parse(rawCart); // forma y límites, o lanza un error
// Precios y disponibilidad desde TU base de datos, no desde el cliente
const products = await getProductsByIds(cart.map((i) => i.id));
const line_items = cart.map((item) => {
const p = products.find((x) => x.id === item.id && x.active);
if (!p) throw new Error('Producto no disponible');
return { price: p.stripePriceId, quantity: item.quantity };
});
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items,
success_url: `${process.env.SITE_URL}/gracias?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/carrito`,
});
redirect(session.url);
}
Las claves:
- Los importes no viajan desde el navegador. Lo mejor es usar
price(un Price creado en Stripe y guardado en tu catálogo). Si usasprice_data, elunit_amountsale de tu base de datos, nunca del argumento de la acción. - Validar la forma y los límites. Cantidades enteras y positivas, un máximo razonable y ids que existen y están activos. Sin esto, alguien puede mandar
quantity: -1o un id de un producto retirado. redirect()fuera del try/catch. Funciona lanzando una excepción interna: si lo envuelves en untry { } catch { }, el catch se la come y la redirección no ocurre.
La página de "gracias" no confirma el pago
success_url solo indica que el usuario volvió. No marques el pedido como pagado ahí: alguien puede abrir esa URL a mano, y los pagos asíncronos se confirman más tarde. El pedido se da por pagado cuando llega el webhook checkout.session.completed con payment_status: 'paid' (o checkout.session.async_payment_succeeded), verificado con su firma. En la página de gracias, como mucho, consulta la sesión en el servidor para enseñar un resumen.
Otras comprobaciones baratas
- Sesión de usuario en la acción, si el checkout requiere login: léela con
cookies()o tu librería de auth dentro de la acción, no confíes en unuserIdque mande el cliente. - Secretos en el servidor.
STRIPE_SECRET_KEYsin el prefijoNEXT_PUBLIC_: las variables con ese prefijo se meten en el JavaScript que descarga el navegador. import 'server-only'en los módulos que tocan la clave secreta o la base de datos: si alguien los importa desde un Client Component, el build falla en lugar de filtrar el código.- Cupones y descuentos aplicados en el servidor (
discountsoallow_promotion_codesen la sesión), nunca calculados en el cliente.