Volver a las notas

Checkout seguro en Next.js: el precio no viene del navegador

6 de septiembre de 2026·
nextjsstripeseguridadserveractions

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 usas price_data, el unit_amount sale 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: -1 o un id de un producto retirado.
  • redirect() fuera del try/catch. Funciona lanzando una excepción interna: si lo envuelves en un try { } 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 un userId que mande el cliente.
  • Secretos en el servidor. STRIPE_SECRET_KEY sin el prefijo NEXT_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 (discounts o allow_promotion_codes en la sesión), nunca calculados en el cliente.

Fuentes