Saltar al contenido

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.

5 min de lectura
Código de Server Actions de Next.js gestionando mutaciones de formularios con tipos de TypeScript

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.

tstypescript
"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.

tstypescript
// ❌ 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.

tstypescript
"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.

tstypescript
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.

tstypescript
"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ónCuándo usarlo
Colocada en page.tsx o form.tsxFormularios simples, mutaciones de un solo uso
Módulo de funcionalidad features/projects/actions.tsAcciones compartidas dentro de un mismo dominio
Capa de servicio + acción delgadaLó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:

tstypescript
// 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.

tstypescript
// ❌ 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ó.

tstypescript
// 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

  1. Valida cada entrada con un esquema — FormData es entrada de usuario sin tipar; trátala como cualquier payload de una API pública
  2. 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
  3. Devuelve resultados tipados para los fallos esperados — los errores de validación y los registros inexistentes no deberían disparar un error boundary
  4. 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
  5. Preserva la mejora progresiva — usa la prop action en <form>, no onClick, para que los formularios funcionen antes de que cargue el JS
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX