Next.js
Mutations y Server Actions
Explica cómo diseñar Server Actions seguras con validación, autenticación, autorización, transacciones, idempotencia, invalidación y resultados tipados.
- Última actualización
- Actualizada
- Nivel
- Aplicación
Next.js
Explica cómo diseñar Server Actions seguras con validación, autenticación, autorización, transacciones, idempotencia, invalidación y resultados tipados.
Una Server Action es una Server Function utilizada como entrada de mutación desde la interfaz. Vive en servidor, pero no es privada: el cliente recibe una referencia invocable. Debe tratar cada argumento como input no confiable y ejecutar validación, autenticación, autorización y persistencia antes de actualizar la UI.
El flujo seguro es:
UI intent
↓
Server Action reference
↓
parse and validate input
↓
authenticate actor
↓
authorize resource/action
↓
transaction / external side effect
↓
invalidate or update cache
↓
return result, refresh or redirectUna Action no es un RPC mágico que elimina HTTP ni seguridad. Next.js genera el transporte y React coordina el resultado con el árbol.
Función marcada con "use server" que puede ser invocada mediante el protocolo compatible.
Server Function pasada a action o formAction, o usada para una mutación originada por UI.
En la práctica los términos suelen mezclarse, pero “Action” enfatiza la interacción/mutación.
// app/actions/orders.ts
"use server";
export async function cancelOrder(formData: FormData) {
// ...
}Todas las funciones exportadas del archivo son Server Functions y deben ser async.
Es útil para Actions reutilizadas desde Client Components.
export default async function OrderPage({ params }: PageProps<"/orders/[orderId]">) {
const { orderId } = await params;
async function cancelOrder() {
"use server";
// orderId is captured as part of the encrypted closure contract.
}
return <form action={cancelOrder}>{/* ... */}</form>;
}La closure puede capturar valores, pero eso no autoriza el recurso. Un usuario puede intentar invocar la Action con una referencia válida bajo condiciones no esperadas; vuelve a cargar y verificar la entidad.
const raw = Object.fromEntries(formData);
const parsed = cancelOrderSchema.safeParse(raw);
if (!parsed.success) {
return {
status: "invalid" as const,
errors: parsed.error.flatten().fieldErrors,
};
}FormData puede contener:
Los atributos HTML mejoran UX, no protegen el servidor.
const session = await requireSession();Responde quién ejecuta la Action. No confíes en un userId enviado por el formulario para identificar al actor.
const order = await orderRepository.findAccessibleForUpdate({
orderId: input.orderId,
organizationId: session.organizationId,
userId: session.user.id,
});
if (!order) {
return { status: "notAllowed" as const };
}La autorización debe comprobar:
Ocultar el botón no impide invocar la Action.
if (order.status !== "pending") {
return {
status: "conflict" as const,
message: "El pedido ya no puede cancelarse.",
};
}Una Action puede recibir datos válidos y actor autorizado, pero la transición ser inválida. Modela el conflicto como resultado esperado.
const cancelled = await db.$transaction(async (transaction) => {
const current = await transaction.order.findUnique({ where: { id: order.id } });
if (!current || current.version !== input.version) {
throw new ConcurrentUpdateError();
}
return transaction.order.update({
where: { id: current.id },
data: {
status: "cancelled",
version: { increment: 1 },
},
});
});La transacción protege integridad. La caché se invalida después.
type CancelOrderState =
| { status: "idle" }
| { status: "invalid"; errors: Record<string, string[]> }
| { status: "notAllowed" }
| { status: "conflict"; message: string }
| { status: "success"; orderId: string };Uniones discriminadas evitan varios booleans incompatibles.
Errores inesperados se lanzan y registran; errores esperados se devuelven.
Después del commit:
updateTag(`order:${cancelled.id}`);
revalidateTag(`organization:${session.organizationId}:orders`, "max");Usa updateTag para que el actor vea el detalle actualizado y revalidateTag para colecciones que toleran stale breve.
redirect(`/orders/${cancelled.id}`);redirect interrumpe control mediante una excepción interna. Colócalo fuera del try/catch que maneja la transacción:
let orderId: string;
try {
orderId = await mutate();
} catch (error) {
return mapExpectedError(error);
}
redirect(`/orders/${orderId}`);Dentro de una Server Action puedes usar la API servidor refresh() para solicitar que el router cliente actualice su representación actual.
No reemplaza tags. Si el dato está cacheado, invalida primero.
<form action={createOrder}>
<input name="customerId" />
<button>Crear</button>
</form>Progressive enhancement permite enviar incluso antes de que el JavaScript cliente se cargue, especialmente desde Server Components.
"use client";
export function ArchiveButton({ action }: { action: () => Promise<void> }) {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => {
startTransition(async () => {
await action();
});
}}
disabled={isPending}
>
{isPending ? "Archivando…" : "Archivar"}
</button>
);
}Para una operación que naturalmente corresponde a un formulario, preferir <form action> aporta semántica y progressive enhancement.
Las Server Functions invocadas desde cliente se despachan y esperan una a una bajo la implementación actual. No las uses como API de fetching paralelo.
Si necesitas varias operaciones coordinadas, realiza trabajo paralelo dentro de una sola Action o utiliza un Route Handler/Server Component según el caso.
const updateUserWithId = updateUser.bind(null, user.id);
<form action={updateUserWithId}>...</form>El ID no aparece como hidden input, pero sigue siendo input no autorizado. La Action debe comprobar acceso.
Usa binding para valores de contexto cómodos, no para evitar validación.
Valores capturados por una Action inline se protegen mediante un mecanismo de cifrado/referencia y pueden depender del build. Esto reduce exposición accidental, pero no convierte los valores en permisos.
En despliegues multi-instance, las claves/configuración deben ser coherentes según la plataforma para que referencias generadas puedan resolverse.
No diseñes seguridad basándote en que una closure es difícil de leer.
Next.js valida el origen de requests de Actions bajo sus mecanismos, y permite configurar orígenes adicionales para proxies. Aun así:
SameSite.No asumas que todas las amenazas quedan cubiertas solo por usar Actions.
Un submit puede repetirse por:
Para crear pagos/pedidos:
const idempotencyKey = String(formData.get("idempotencyKey"));
const result = await paymentService.charge({
idempotencyKey,
amount,
});Guarda una restricción única y devuelve el resultado existente ante repetición.
Deshabilitar el botón reduce dobles clicks, pero no garantiza idempotencia.
Incluye versión:
<input type="hidden" name="version" value="4" />La base actualiza solo si coincide. Si otra persona editó primero, devuelve conflicto y permite recargar/combinar.
El hidden input es manipulable, pero la versión se usa como precondición, no como autoridad.
DB commit
→ enqueue job/outbox
→ external email/paymentEvita mantener una transacción abierta mientras esperas un proveedor lento. Usa outbox para garantizar que el evento se procese después del commit.
Una Action que envía correo y luego falla puede duplicarlo en retry. Usa IDs idempotentes.
FormData puede incluir File:
const file = formData.get("avatar");
if (!(file instanceof File)) return invalid;Valida:
Para archivos grandes, upload directo firmado a object storage suele ser mejor que atravesar la Action.
Devuelve state.
Registra y lanza hacia boundary.
No devuelvas error.message interno al cliente.
Registra:
No registres contraseñas, tokens o FormData completo.
"use server";
export async function createOrderAction(
_previous: CreateOrderState,
formData: FormData,
): Promise<CreateOrderState> {
const parsed = createOrderSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { status: "invalid", errors: parsed.error.flatten().fieldErrors };
}
const session = await requireSession();
const result = await orderService.create({
organizationId: session.organizationId,
actorId: session.user.id,
input: parsed.data,
});
updateTag(`organization:${session.organizationId}:orders`);
return {
status: "success",
orderId: result.id,
};
}La capa orderService controla transacción, stock e idempotencia. La Action adapta FormData y sesión.
No uses Actions como API pública ni Route Handlers como vuelta HTTP interna para cada form.
Son manipulables.
La Action es invocable sin ese flujo visual.
Regenera datos antiguos.
Captura control de flujo.
Filtra datos y aumenta payload.
Crea estado parcial.
Duplica pagos o registros.
El dispatch cliente no está diseñado para lecturas paralelas.
Prueba el servicio de dominio separado de Next.js y añade E2E al form completo.
Formularios con Server Actions conecta este pipeline con validación accesible, pending, useActionState y progressive enhancement.