React Server Actions: patrones, errores comunes y uso en producción
Las Server Actions devuelven formularios y mutaciones al servidor en Next.js: cómo usarlas bien, qué evitar y qué patrones escalan de verdad.

Las Server Actions llegaron al modelo estable de React con mucha promesa: permiten colocar la lógica de mutación junto a los componentes, evitar la ruta de API repetitiva y obtener mejora progresiva sin esfuerzo adicional. En la práctica, los equipos se topan con problemas reales: vacíos de validación, estado mezclado entre cliente y servidor, y acciones que terminan convertidas en bloques inmanejables. Este es un repaso realista de dónde brillan y dónde hacen falta barreras de seguridad.
Qué son realmente las Server Actions
Una Server Action es una función asíncrona marcada con "use server" que se ejecuta en el servidor pero puede invocarse desde el cliente, incluso desde la prop action de un formulario. Por debajo, el navegador envía una solicitud POST; React se encarga de la serialización.
"use server";
export async function createProject(formData: FormData) {
const name = formData.get("name") as string;
await db.project.create({ data: { name } });
revalidatePath("/projects");
}Esa simplicidad es real. Pero también puede llevarte a saltarte los patrones que mantienen seguro el código del servidor.
La validación es tu responsabilidad
El error más común: las Server Actions no validan la entrada de forma automática. Todo lo que llega desde FormData tiene el tipo string | File | null. Si te saltas la validación, estás dejando entrar en tu base de datos cualquier dato que el usuario decida enviar.
// ❌ Trusting FormData directly — no schema, no guardrails
export async function createProject(formData: FormData) {
const name = formData.get("name") as string;
await db.project.create({ data: { name } }); // name could be empty or malicious
}
// ✅ Parse and validate with Zod before touching the database
import { z } from "zod";
const schema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
});
export async function createProject(formData: FormData) {
const parsed = schema.safeParse({
name: formData.get("name"),
description: formData.get("description"),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
await db.project.create({ data: parsed.data });
revalidatePath("/projects");
return { success: true };
}Trata cada Server Action como un endpoint de API pública, porque eso es exactamente lo que es.
Autorización: no confíes en la UI
Las Server Actions no quedan protegidas por el simple hecho de estar "dentro" de un componente. Cualquier cliente puede enviar un POST directamente al endpoint de la acción. Verifica siempre la autorización dentro de la propia acción.
"use server";
import { getSession } from "@/lib/auth";
export async function deleteProject(projectId: string) {
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
// Verify the user actually owns this project
const project = await db.project.findUnique({ where: { id: projectId } });
if (project?.ownerId !== session.user.id) {
throw new Error("Forbidden");
}
await db.project.delete({ where: { id: projectId } });
revalidatePath("/projects");
}Es la misma regla que rige las rutas de API. El árbol de componentes no es un límite de seguridad.
Tipos de retorno para informar al cliente
Las Server Actions pueden devolver valores. Usa retornos tipados para informar al cliente: errores de validación, el estado de éxito o los IDs de los recursos creados.
type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record<string, string[]> };
export async function createProject(
formData: FormData
): Promise<ActionResult<{ id: string }>> {
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return {
success: false,
error: "Validation failed",
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
const project = await db.project.create({ data: parsed.data });
return { success: true, data: { id: project.id } };
}En el cliente, usa useActionState (React 19) o useFormState (Next.js 14) para vincular el valor de retorno al estado del componente.
"use client";
import { useActionState } from "react";
import { createProject } from "./actions";
export function CreateProjectForm() {
const [state, action, isPending] = useActionState(createProject, null);
return (
<form action={action}>
<input name="name" required />
{state?.fieldErrors?.name && (
<span role="alert">{state.fieldErrors.name[0]}</span>
)}
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Project"}
</button>
</form>
);
}Cómo organizar las acciones a escala
El instinto natural es colocar la acción en el mismo archivo que el componente. Eso funciona bien para casos simples. En cuanto una acción toca autorización, lógica de negocio compleja o se comparte entre varias funcionalidades, conviene extraerla a un módulo dedicado.
| Patrón | Cuándo usarlo |
|---|---|
Colocada en page.tsx o form.tsx | Formularios simples, mutaciones de un solo uso |
Módulo de funcionalidad features/projects/actions.ts | Acciones compartidas dentro de un mismo dominio |
| Capa de servicio + acción delgada | Lógica de negocio compleja, reutilizada en varios contextos |
El patrón de capa de servicio es especialmente útil cuando la misma lógica debe ejecutarse tanto en una Server Action como en una ruta de API:
// services/projects.ts — pure business logic, no "use server"
export async function createProjectForUser(
userId: string,
data: CreateProjectInput
) {
// validate, authorize, write to DB — framework-agnostic
}
// features/projects/actions.ts — thin action, just auth + delegate
"use server";
export async function createProjectAction(formData: FormData) {
const session = await getSession();
if (!session?.user) throw new Error("Unauthorized");
const result = await createProjectForUser(
session.user.id,
parseFormData(formData)
);
revalidatePath("/projects");
return result;
}Cuando la lógica de negocio vive en una función de servicio simple, puedes probarla con pruebas unitarias sin necesidad de levantar un servidor de Next.js ni simular internamente React.
Manejo de errores: lanza excepciones con cuidado
Las excepciones no controladas dentro de una Server Action llegan como errores no controlados hasta el error boundary más cercano. Esto es intencional para fallos inesperados: tiempos de espera agotados en la base de datos, errores de red, violaciones de invariantes. Pero, para los fallos esperados, es mejor devolver un valor de error tipado en lugar de lanzar una excepción.
// ❌ Throws for an expected condition — triggers error boundary unnecessarily
export async function getProject(id: string) {
const project = await db.project.findUnique({ where: { id } });
if (!project) throw new Error("Not found");
return project;
}
// ✅ Return null for expected absence; throw only for unexpected failures
export async function getProject(id: string) {
return db.project.findUnique({ where: { id } });
}Esta distinción importa porque los error boundaries son una herramienta poco precisa. Un "no encontrado" no necesita hacer explotar todo el subárbol: el componente puede manejar el valor null con elegancia.
Mejora progresiva
Una ventaja genuina de las Server Actions: los formularios funcionan sin JavaScript. Como la prop action acepta una función que se traduce en una solicitud HTTP POST, tus formularios siguen funcionando en entornos donde el JS todavía no cargó o falló.
// This form submits correctly with and without JavaScript
<form action={createProject}>
<input name="name" required />
<button type="submit">Create</button>
</form>No desperdicies esta ventaja envolviendo tu acción en un onClick. Usa la prop action en el elemento <form> y añade la mejora progresiva por encima —actualizaciones optimistas, estados de carga— como una capa adicional, no como un reemplazo.
Usa useOptimistic junto con useActionState para actualizar la UI de
inmediato mientras la acción se ejecuta en segundo plano. Deshaz los cambios
automáticamente si la acción devuelve un error.
Puntos clave
- Valida cada entrada con un esquema —
FormDataes entrada de usuario sin tipar; trátala como cualquier payload de una API pública - Autoriza dentro de la acción — el árbol de componentes no es un límite de seguridad; verifica la sesión y la propiedad de forma explícita
- Devuelve resultados tipados para los fallos esperados — los errores de validación y los registros inexistentes no deberían disparar un error boundary
- Mantén la lógica de negocio en una capa de servicio — las acciones delgadas delegan en funciones reutilizables, lo que permite probarlas sin la sobrecarga del framework
- Preserva la mejora progresiva — usa la prop
actionen<form>, noonClick, para que los formularios funcionen antes de que cargue el JS


