Sesiones de Stripe Checkout
Receta
Crea una Checkout Session en el servidor y luego redirige al cliente a la página de checkout alojada de Stripe. Stripe se encarga de toda la UI de pago, el cumplimiento PCI y la visualización de métodos de pago.
Crea una Server Action para generar la sesión:
// app/actions/checkout.ts
"use server";
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
export async function createCheckoutSession(priceId: string) {
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
redirect(session.url!);
}O usa un Route Handler:
// app/api/checkout/route.ts
import { stripe } from "@/lib/stripe";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const { priceId } = await request.json();
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return NextResponse.json({ url: session.url });
}Ejemplo funcional
// app/buy/page.tsx
import { createCheckoutSession } from "@/app/actions/checkout";
export default function BuyPage() {
return (
<div className="max-w-md mx-auto p-8">
<h1 className="text-2xl font-bold mb-4">Curso Premium</h1>
<p className="text-gray-600 mb-6">
Compra única para acceso de por vida.
</p>
<form action={createCheckoutSession.bind(null, "price_xxx123")}>
<button
type="submit"
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700"
>
Comprar por $49
</button>
</form>
</div>
);
}// app/success/page.tsx
import { stripe } from "@/lib/stripe";
interface SuccessPageProps {
searchParams: Promise<{ session_id?: string }>;
}
export default async function SuccessPage({ searchParams }: SuccessPageProps) {
const { session_id } = await searchParams;
if (!session_id) {
return <p>Sesión no válida.</p>;
}
const session = await stripe.checkout.sessions.retrieve(session_id, {
expand: ["line_items", "customer"],
});
return (
<div className="max-w-md mx-auto p-8 text-center">
<h1 className="text-2xl font-bold mb-4">¡Pago exitoso!</h1>
<p className="text-gray-600">
Gracias por tu compra. Tu pago de{" "}
{(session.amount_total! / 100).toFixed(2)}{" "}
{session.currency?.toUpperCase()} ha sido recibido.
</p>
<p className="mt-4 text-sm text-gray-500">
ID de sesión: {session.id}
</p>
</div>
);
}Profundización
Cómo funciona
stripe.checkout.sessions.creategenera una sesión en los servidores de Stripe y devuelve una URL. Al redirigir al cliente a esa URL, se abre la página de checkout alojada por Stripe.{CHECKOUT_SESSION_ID}es una variable de plantilla que Stripe reemplaza con el ID real de la sesión al redirigir de vuelta a tusuccess_url.- El parámetro
modedetermina el tipo de pago:"payment"para pagos únicos,"subscription"para recurrentes o"setup"para guardar una tarjeta sin cobrar. line_itemspuede referenciar objetos Price existentes (creados en el Dashboard) o usarprice_datainline para precios dinámicos.- Stripe Checkout admite más de 40 métodos de pago, gestiona SCA/3D Secure automáticamente y ofrece una UI optimizada para móvil sin configuración adicional.
Variaciones
Datos de precio inline (sin objeto Price precreado):
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: "Widget personalizado",
description: "Un widget único",
},
unit_amount: 2999, // $29.99 en centavos
},
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/shop`,
});Asociar a un cliente existente:
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: "cus_xxx",
line_items: [{ price: "price_xxx", quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});Varios line items:
line_items: [
{ price: "price_basic", quantity: 2 },
{ price: "price_addon", quantity: 1 },
],Notas de TypeScript
stripe.checkout.sessions.createdevuelvePromise<Stripe.Checkout.Session>.- La propiedad
session.urlestá tipada comostring | null. Esnullsolo en el modo de checkout embebido, así que la aserción!es segura para flujos basados en redirección. - Usa
Stripe.Checkout.SessionCreateParamspara tipar objetos de configuración si los construyes dinámicamente.
import type Stripe from "stripe";
const params: Stripe.Checkout.SessionCreateParams = {
mode: "payment",
line_items: [{ price: priceId, quantity: 1 }],
success_url: "...",
cancel_url: "...",
};Errores comunes
- Nunca confíes solo en la página de éxito para confirmar el pago. Verifica siempre mediante webhooks o recuperando la sesión en el servidor. Un usuario podría navegar manualmente a tu URL de éxito.
session.urlexpira después de 24 horas. No la guardes para usarla más tarde.amount_totalestá en la unidad más pequeña de la moneda (centavos para USD). Divídelo entre 100 antes de mostrarlo.- Si usas
redirect()de Next.js en una Server Action, lanza internamente un errorNEXT_REDIRECT. Es el comportamiento esperado: no lo envuelvas en un try/catch que capture el error. - Stripe Checkout no permite personalización completa de la UI. Si necesitas un checkout totalmente personalizado con tu marca, usa PaymentIntent con PaymentElement.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Stripe Checkout (redirección) | Código mínimo, cumple PCI, admite más de 40 métodos de pago | Personalización limitada de la UI |
| Embedded Checkout | Permanece en tu sitio mediante iframe | Opciones de estilo aún limitadas |
| PaymentIntent + PaymentElement | Control total de la UI | Más código, más responsabilidad |
| Payment Links | Sin código | Sin control programático |
FAQs
¿Cuál es la diferencia entre usar una Server Action y un Route Handler para crear una Checkout Session?
- Una Server Action puede llamarse directamente desde una acción de formulario o código del cliente y usa
redirect()para enviar al usuario a Stripe - Un Route Handler devuelve la URL de la sesión como JSON, lo que requiere que el cliente gestione la redirección manualmente
- Las Server Actions son más simples para flujos basados en formularios; los Route Handlers son mejores para uso estilo API
¿Qué hace la variable de plantilla {CHECKOUT_SESSION_ID} en success_url?
Stripe reemplaza {CHECKOUT_SESSION_ID} con el ID real de la sesión al redirigir al cliente de vuelta a tu sitio. Luego puedes recuperar la sesión en el servidor para verificar los detalles del pago.
¿Cuáles son los tres valores posibles del parámetro mode?
"payment"— pago único"subscription"— facturación recurrente"setup"— guardar una tarjeta sin cobrarla
Error común: ¿por qué nunca debes confiar solo en la página de éxito para confirmar el pago?
Un usuario podría navegar manualmente a tu URL de éxito sin pagar. Verifica siempre el pago mediante webhooks (checkout.session.completed) o recuperando la sesión en el servidor y comprobando su payment_status.
¿Cómo usas price_data inline en lugar de un Price ID precreado?
line_items: [{
price_data: {
currency: "usd",
product_data: { name: "Custom Widget" },
unit_amount: 2999, // $29.99 en centavos
},
quantity: 1,
}]¿Por qué amount_total debe dividirse entre 100 antes de mostrarlo?
Stripe almacena los importes en la unidad más pequeña de la moneda (centavos para USD). Así, 2999 significa $29.99. Divide siempre entre 100 para mostrar dólares.
Error común: ¿qué ocurre si envuelves redirect() en un try/catch dentro de una Server Action?
redirect() lanza internamente un error NEXT_REDIRECT. Si tu bloque catch lo captura, la redirección nunca ocurre. Llama a redirect() fuera del try/catch o relanza explícitamente los errores de redirección.
¿Cómo asocias una Checkout Session a un cliente existente de Stripe?
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: "cus_xxx",
line_items: [{ price: "price_xxx", quantity: 1 }],
success_url: "...",
cancel_url: "...",
});¿Cuánto tiempo es válida session.url?
La URL de la Checkout Session expira después de 24 horas. Genera siempre una sesión nueva cuando el usuario inicia el checkout; nunca guardes la URL para usarla más tarde.
TypeScript: ¿cómo tipas un objeto de configuración dinámico de Checkout Session?
import type Stripe from "stripe";
const params: Stripe.Checkout.SessionCreateParams = {
mode: "payment",
line_items: [{ price: priceId, quantity: 1 }],
success_url: "...",
cancel_url: "...",
};TypeScript: ¿por qué session.url está tipada como string | null?
Es null solo cuando usas el modo de checkout embebido (donde no se necesita URL de redirección). En flujos basados en redirección, siempre es un string, lo que hace segura la aserción no nula !.
¿Cuándo debes usar Stripe Checkout frente a PaymentIntent con PaymentElement?
- Usa Checkout cuando quieras código mínimo, UI alojada y soporte para más de 40 métodos de pago sin configuración adicional
- Usa PaymentIntent + PaymentElement cuando necesites una experiencia de checkout totalmente personalizada dentro de la app
- Checkout no permite personalización completa de la UI