Stripe con Server Actions
Receta
Usa Next.js Server Actions para gestionar todas las operaciones de Stripe en el servidor. Combínalas con useActionState para flujos de pago basados en formularios y Zod para la validación de entradas.
Crea una Checkout Session mediante Server Action:
// app/actions/checkout.ts
"use server";
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
import { z } from "zod";
const CheckoutSchema = z.object({
priceId: z.string().startsWith("price_"),
quantity: z.coerce.number().int().min(1).max(99),
});
export async function createCheckoutAction(
_prevState: { error?: string } | null,
formData: FormData
) {
const parsed = CheckoutSchema.safeParse({
priceId: formData.get("priceId"),
quantity: formData.get("quantity"),
});
if (!parsed.success) {
return { error: parsed.error.errors[0]?.message ?? "Entrada no válida" };
}
const { priceId, quantity } = parsed.data;
try {
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: priceId, quantity }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/shop`,
});
redirect(session.url!);
} catch (err) {
// Relanza los errores de redirect (Next.js los usa internamente)
if (err instanceof Error && err.message === "NEXT_REDIRECT") throw err;
return { error: "No se pudo crear la sesión de checkout. Inténtalo de nuevo." };
}
}Crea un PaymentIntent mediante Server Action:
// app/actions/payment-intent.ts
"use server";
import { stripe } from "@/lib/stripe";
import { z } from "zod";
const PaymentSchema = z.object({
amount: z.coerce.number().int().min(50).max(99999999), // El mínimo de Stripe es 50 centavos
currency: z.enum(["usd", "eur", "gbp"]).default("usd"),
});
export async function createPaymentIntentAction(
_prevState: { clientSecret?: string; error?: string } | null,
formData: FormData
) {
const parsed = PaymentSchema.safeParse({
amount: formData.get("amount"),
currency: formData.get("currency"),
});
if (!parsed.success) {
return { error: parsed.error.errors[0]?.message ?? "Entrada no válida" };
}
try {
const paymentIntent = await stripe.paymentIntents.create({
amount: parsed.data.amount,
currency: parsed.data.currency,
automatic_payment_methods: { enabled: true },
});
return { clientSecret: paymentIntent.client_secret! };
} catch {
return { error: "No se pudo inicializar el pago." };
}
}Gestiona suscripciones mediante Server Action:
// app/actions/subscription.ts
"use server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function cancelSubscriptionAction() {
const session = await auth();
if (!session?.user?.id) return { error: "No autenticado" };
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeSubscriptionId: true },
});
if (!user?.stripeSubscriptionId) {
return { error: "No hay suscripción activa" };
}
try {
await stripe.subscriptions.update(user.stripeSubscriptionId, {
cancel_at_period_end: true,
});
revalidatePath("/account");
return { success: true };
} catch {
return { error: "No se pudo cancelar la suscripción" };
}
}
export async function resumeSubscriptionAction() {
const session = await auth();
if (!session?.user?.id) return { error: "No autenticado" };
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeSubscriptionId: true },
});
if (!user?.stripeSubscriptionId) {
return { error: "No se encontró suscripción" };
}
try {
await stripe.subscriptions.update(user.stripeSubscriptionId, {
cancel_at_period_end: false,
});
revalidatePath("/account");
return { success: true };
} catch {
return { error: "No se pudo reanudar la suscripción" };
}
}Ejemplo funcional
Flujo de checkout completo usando Server Actions y useActionState:
// app/shop/product-card.tsx
"use client";
import { useActionState } from "react";
import { createCheckoutAction } from "@/app/actions/checkout";
interface ProductCardProps {
name: string;
description: string;
priceId: string;
priceDisplay: string;
}
export function ProductCard({
name,
description,
priceId,
priceDisplay,
}: ProductCardProps) {
const [state, formAction, isPending] = useActionState(
createCheckoutAction,
null
);
return (
<div className="border rounded-xl p-6 space-y-4">
<h3 className="text-lg font-bold">{name}</h3>
<p className="text-gray-600">{description}</p>
<p className="text-2xl font-bold">{priceDisplay}</p>
<form action={formAction}>
<input type="hidden" name="priceId" value={priceId} />
<input type="hidden" name="quantity" value="1" />
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-3 rounded-lg
hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isPending ? "Redirigiendo al checkout..." : "Comprar ahora"}
</button>
</form>
{state?.error && (
<p className="text-red-500 text-sm">{state.error}</p>
)}
</div>
);
}// app/shop/page.tsx
import { ProductCard } from "./product-card";
const products = [
{
name: "Kit inicial",
description: "Todo lo que necesitas para empezar.",
priceId: "price_starter",
priceDisplay: "$19",
},
{
name: "Paquete Pro",
description: "Herramientas avanzadas para profesionales.",
priceId: "price_pro",
priceDisplay: "$49",
},
{
name: "Suite Enterprise",
description: "Solución empresarial con todas las funciones.",
priceId: "price_enterprise",
priceDisplay: "$199",
},
];
export default function ShopPage() {
return (
<div className="max-w-4xl mx-auto py-16 px-4">
<h1 className="text-3xl font-bold mb-8">Tienda</h1>
<div className="grid md:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard key={product.priceId} {...product} />
))}
</div>
</div>
);
}Gestión de suscripciones con useActionState:
// components/subscription-controls.tsx
"use client";
import { useActionState } from "react";
import {
cancelSubscriptionAction,
resumeSubscriptionAction,
} from "@/app/actions/subscription";
interface SubscriptionControlsProps {
cancelAtPeriodEnd: boolean;
currentPeriodEnd: string;
}
export function SubscriptionControls({
cancelAtPeriodEnd,
currentPeriodEnd,
}: SubscriptionControlsProps) {
const [cancelState, cancelAction, isCanceling] = useActionState(
cancelSubscriptionAction,
null
);
const [resumeState, resumeAction, isResuming] = useActionState(
resumeSubscriptionAction,
null
);
if (cancelAtPeriodEnd) {
return (
<div className="space-y-4">
<p className="text-yellow-700 bg-yellow-50 p-4 rounded-lg">
Tu suscripción está programada para cancelarse el{" "}
{new Date(currentPeriodEnd).toLocaleDateString()}.
</p>
<form action={resumeAction}>
<button
type="submit"
disabled={isResuming}
className="bg-green-600 text-white px-4 py-2 rounded-lg"
>
{isResuming ? "Reanudando..." : "Reanudar suscripción"}
</button>
</form>
{resumeState?.error && (
<p className="text-red-500 text-sm">{resumeState.error}</p>
)}
</div>
);
}
return (
<div className="space-y-4">
<form action={cancelAction}>
<button
type="submit"
disabled={isCanceling}
className="bg-red-600 text-white px-4 py-2 rounded-lg"
>
{isCanceling ? "Cancelando..." : "Cancelar suscripción"}
</button>
</form>
{cancelState?.error && (
<p className="text-red-500 text-sm">{cancelState.error}</p>
)}
</div>
);
}Profundización
Cómo funciona
- Las Server Actions se ejecutan exclusivamente en el servidor. La clave secreta de Stripe nunca se expone al cliente, y todas las llamadas a la API ocurren en un contexto de servidor seguro.
useActionState(React 19) gestiona el ciclo de vida del formulario: rastrea el estado pendiente, pasa el resultado anterior a la action y provoca un re-renderizado cuando la action termina.- La firma de la action
(prevState, formData) => Promise<State>recibe el valor de retorno anterior y los datos del formulario enviado. Esto permite mostrar errores de forma progresiva sin gestión de state en el cliente. - La validación con Zod en Server Actions proporciona un análisis de entradas con seguridad de tipos. Si la validación falla, devuelve el error como state en lugar de lanzar una excepción.
redirect()en una Server Action lanza un error interno especial. Si envuelves la llamada a Stripe en try/catch, debes relanzar los errores de redirect o la redirección se tragará en silencio.revalidatePathprovoca una nueva obtención de Server Components en la ruta especificada, manteniendo la UI sincronizada después de las mutaciones.
Variaciones
Server Action con contexto de usuario autenticado:
"use server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export async function subscribeAction(priceId: string) {
const session = await auth();
if (!session?.user) redirect("/login");
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
customer_email: session.user.email!,
line_items: [{ price: priceId, quantity: 1 }],
metadata: { userId: session.user.id },
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
redirect(checkoutSession.url!);
}Combinar Server Action con Stripe.js en el cliente:
"use client";
import { useEffect, useState } from "react";
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe-client";
import { createPaymentIntentAction } from "@/app/actions/payment-intent";
import { CheckoutForm } from "./checkout-form";
export function PaymentWrapper({ amount }: { amount: number }) {
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const formData = new FormData();
formData.set("amount", String(amount));
formData.set("currency", "usd");
createPaymentIntentAction(null, formData).then((result) => {
if (result?.clientSecret) {
setClientSecret(result.clientSecret);
} else if (result?.error) {
setError(result.error);
}
});
}, [amount]);
if (error) return <p className="text-red-500">{error}</p>;
if (!clientSecret) return <div>Inicializando pago...</div>;
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm amount={amount} />
</Elements>
);
}Notas de TypeScript
useActionStatees genérico:useActionState<State>(action, initialState). El tipo de state se infiere del tipo de retorno de la action.- Define tipos de retorno explícitos en Server Actions para mayor claridad.
- Usa uniones discriminadas para los resultados de action y hacer que los estados de éxito y error sean type-safe.
type ActionResult =
| { success: true; data: string }
| { success: false; error: string }
| null;
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
// ...
}Errores comunes
redirect()lanza internamente un errorNEXT_REDIRECT. Si tu Server Action tiene un try/catch alrededor de la llamada a Stripe y el redirect, debes relanzar los errores que sean de redirect. El enfoque más simple es llamar aredirectfuera del bloque try/catch.- Las Server Actions son solicitudes POST por debajo. Tienen un límite de tamaño de cuerpo predeterminado. Esto está bien para operaciones de Stripe, pero tenlo en cuenta si envías payloads grandes.
useActionStatereemplaza al antiguouseFormStatedereact-dom. UsauseActionStatedereacten React 19.- El valor
isPendingdeuseActionStateestruedesde el momento en que se envía el formulario hasta que la action termina (incluido cualquier redirect). Úsalo para deshabilitar botones y mostrar estados de carga. - Las Server Actions pueden llamarse directamente (no solo desde formularios). Puedes llamarlas desde
useEffect, manejadores de eventos u otro código del cliente pasandoFormDatamanualmente. - Valida siempre las entradas en el servidor con Zod aunque también valides en el cliente. La validación del lado del cliente puede omitirse.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Server Actions + useActionState | Mejora progresiva, estado pendiente incorporado | Requiere React 19 |
| Route Handlers (POST) | Funciona con cualquier cliente, API estilo REST | Más boilerplate, sin integración de formularios incorporada |
| Mutaciones tRPC | Seguridad de tipos de extremo a extremo, gran DX | Dependencia adicional |
| Server Actions sin useActionState | Más simple para flujos solo de redirect | Sin estado de error o pendiente incorporado |
FAQs
¿Por qué usar Server Actions en lugar de Route Handlers para operaciones de Stripe?
- Las Server Actions se integran directamente con formularios de React y
useActionStatepara estados pendientes incorporados. - Admiten mejora progresiva: los formularios funcionan incluso antes de que cargue JavaScript.
- Menos boilerplate que crear archivos de rutas API separados.
¿Cómo gestiona useActionState el ciclo de vida del formulario de checkout?
const [state, formAction, isPending] = useActionState(
createCheckoutAction,
null
);statealmacena el resultado anterior de la action (mensajes de error o datos de éxito).isPendinges true desde el envío del formulario hasta que la action termina.- La action recibe
(prevState, formData)y devuelve el nuevo state.
¿Por qué es importante la validación con Zod en Server Actions aunque valides en el cliente?
- La validación del lado del cliente puede omitirse modificando la solicitud directamente.
- Las Server Actions son solicitudes POST: cualquiera puede llamarlas con datos arbitrarios.
- Zod proporciona un análisis con seguridad de tipos que devuelve errores estructurados en caso de fallo.
Error común: ¿Qué ocurre si capturas un error de redirect dentro de una Server Action?
redirect()lanza internamente un error especialNEXT_REDIRECT.- Si tu try/catch lo traga, la redirección se pierde en silencio y el usuario permanece en la página.
- Debes relanzar los errores de redirect o llamar a
redirectfuera del bloque try/catch.
Error común: ¿Cuál es la diferencia entre useActionState y el antiguo useFormState?
useActionStatese importa dereact(React 19) y reemplaza auseFormStatedereact-dom.- Añade el valor de retorno
isPendingqueuseFormStateno proporcionaba. - Usar
useFormStateen React 19 quedará obsoleto.
¿Cómo usa el flujo de cancelación de suscripción cancel_at_period_end?
- Establecer
cancel_at_period_end: truepermite que el usuario conserve el acceso hasta que termine su periodo de facturación actual. - El usuario puede reanudar estableciéndolo de nuevo en
falseantes de que termine el periodo. revalidatePath("/account")actualiza la UI para reflejar el cambio.
¿Se pueden llamar Server Actions fuera de un elemento form?
- Sí, puedes llamarlas desde
useEffect, manejadores de eventos u otro código del cliente. - Pasa un objeto
FormDatamanualmente al llamarlas fuera de un contexto de formulario. - Esto es útil para combinar Server Actions con Stripe.js en el cliente.
¿Cómo combinas una Server Action con Stripe Elements en el cliente?
- Llama a
createPaymentIntentActionpara obtener unclientSecretdel servidor. - Pasa el
clientSecretal proveedorElementscomo opción. - El
CheckoutFormdel lado del cliente usa entonces Stripe.js para confirmar el pago.
¿Cómo debes tipificar el valor de retorno de una Server Action usando uniones discriminadas?
type ActionResult =
| { success: true; data: string }
| { success: false; error: string }
| null;
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
// ...
}- El state inicial
nulllo gestiona el segundo argumento deuseActionState. - TypeScript acota el tipo cuando compruebas
result.success.
¿Cómo valida CheckoutSchema el formato de priceId?
const CheckoutSchema = z.object({
priceId: z.string().startsWith("price_"),
quantity: z.coerce.number().int().min(1).max(99),
});startsWith("price_")garantiza que solo se acepten IDs de precio válidos de Stripe.z.coerce.number()convierte la cadena de FormData en un número antes de la validación.
¿Qué hace revalidatePath después de una mutación de suscripción?
- Le indica a Next.js que vuelva a obtener los Server Components en la ruta especificada.
- Tras cancelar o reanudar una suscripción, la página
/accountrefleja el estado actualizado. - Sin ello, el usuario vería datos obsoletos hasta la próxima carga completa de la página.
¿Por qué ProductCard usa inputs ocultos del formulario para priceId y quantity?
- Los inputs ocultos incluyen los datos en el FormData enviado a la Server Action.
- Esto evita pasar valores sensibles a través del state de JavaScript en el cliente.
- El formulario funciona con mejora progresiva: se envía incluso sin JS.