Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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" };
}
}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>
);
}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.(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.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.revalidatePath provoca una nueva obtención de Server Components en la ruta especificada, manteniendo la UI sincronizada después de las mutaciones.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>
);
}useActionState es genérico: useActionState<State>(action, initialState). El tipo de state se infiere del tipo de retorno de la action.type ActionResult =
| { success: true; data: string }
| { success: false; error: string }
| null;
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
// ...
}redirect() lanza internamente un error NEXT_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 a redirect fuera del bloque try/catch.useActionState reemplaza al antiguo useFormState de react-dom. Usa useActionState de react en React 19.isPending de useActionState es true desde 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.useEffect, manejadores de eventos u otro código del cliente pasando FormData manualmente.| 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 |
useActionState para estados pendientes incorporados.const [state, formAction, isPending] = useActionState(
createCheckoutAction,
null
);state almacena el resultado anterior de la action (mensajes de error o datos de éxito).isPending es true desde el envío del formulario hasta que la action termina.(prevState, formData) y devuelve el nuevo state.redirect() lanza internamente un error especial NEXT_REDIRECT.redirect fuera del bloque try/catch.useActionState se importa de react (React 19) y reemplaza a useFormState de react-dom.isPending que useFormState no proporcionaba.useFormState en React 19 quedará obsoleto.cancel_at_period_end: true permite que el usuario conserve el acceso hasta que termine su periodo de facturación actual.false antes de que termine el periodo.revalidatePath("/account") actualiza la UI para reflejar el cambio.useEffect, manejadores de eventos u otro código del cliente.FormData manualmente al llamarlas fuera de un contexto de formulario.createPaymentIntentAction para obtener un clientSecret del servidor.clientSecret al proveedor Elements como opción.CheckoutForm del lado del cliente usa entonces Stripe.js para confirmar el pago.type ActionResult =
| { success: true; data: string }
| { success: false; error: string }
| null;
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
// ...
}null lo gestiona el segundo argumento de useActionState.result.success.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./account refleja el estado actualizado.Revisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥