Server Actions: manejo de formularios sin API separada
1. Descripción general
Antes, si querías manejar un formulario en Next.js, tenías que escribir una ruta API (app/api/.../route.ts), y luego llamar a fetch desde el cliente para enviar los datos. Server Actions elimina ese paso intermedio: escribes una función que se ejecuta en el servidor, la atajas directamente a la propiedad action del <form>, y listo.
Después de leer este artículo, sabrás cómo declarar Server Actions, usarlos en formularios con validación, manejar estados pending/error con useFormState y useFormStatus, revalidar datos después de enviar el formulario, y evitar algunos errores comunes al migrar desde rutas API.
Nota: Server Actions es una característica de App Router (Next.js 13.4+), estable desde Next.js 14. Si aún usas Pages Router, este artículo no te aplica.
2. Tu primer Server Action
Un Server Action es una función async con la directiva "use server", que puede estar en un archivo separado o directamente en un componente (si ese componente es un Server Component).
// app/actions/customer-actions.ts
"use server";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
console.log("Crear cliente:", name, email);
// Simulación de guardado en BD
}
// app/customers/new/page.tsx
import { createCustomer } from "@/app/actions/customer-actions";
export default function NewCustomerPage() {
return (
<form action={createCustomer}>
<input name="name" placeholder="Nombre del cliente" />
<input name="email" placeholder="Email" />
<button type="submit">Crear</button>
</form>
);
}
"use server"al principio del archivo le dice a Next.js que todos los exports en este archivo son Server Actions, se ejecutan solo en el servidor y no se incluyen en el JavaScript que se envía al cliente.action={createCustomer}vincula la función directamente al formulario. Next.js serializa automáticamente el formulario enFormDatay llama a la función en el servidor cuando se envía.- No necesitas
onSubmit, no necesitaspreventDefault, no necesitasfetch. El formulario funciona incluso cuando JavaScript aún no ha cargado completamente (mejora progresiva).
Cuando envías: el log aparece en la terminal del servidor (no en la consola del navegador), porque la función se ejecuta en el servidor.
3. Devolver resultados al cliente con useFormState
En la práctica, necesitas mostrar errores de validación o mensajes de éxito. useFormState (de react-dom) ayuda a que Server Action devuelva el estado al cliente.
// app/actions/customer-actions.ts
"use server";
type ActionResult = { success: boolean; message: string };
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
if (!name || !email) {
return { success: false, message: "Falta nombre o email." };
}
if (!email.includes("@")) {
return { success: false, message: "Email no válido." };
}
// Simulación de guardado en BD
return { success: true, message: `Cliente ${name} creado.` };
}
// app/customers/new/page.tsx
"use client";
import { useFormState } from "react-dom";
import { createCustomer } from "@/app/actions/customer-actions";
const initialState = { success: false, message: "" };
export default function NewCustomerForm() {
const [state, formAction] = useFormState(createCustomer, initialState);
return (
<form action={formAction}>
<input name="name" placeholder="Nombre del cliente" />
<input name="email" placeholder="Email" />
<button type="submit">Crear</button>
{state.message && (
<p style={{ color: state.success ? "green" : "red" }}>
{state.message}
</p>
)}
</form>
);
}
useFormState(action, initialState)envuelve el Server Action, devuelve[state, formAction].statees el valor devuelto más reciente de la acción,formActiones la función que se vincula a<form action={...}>.- Server Action recibe un parámetro adicional al principio llamado
prevState— es obligatorio usarlo conuseFormState, aunque no lo uses. - Como
useFormStatees un hook de React, este archivo debe ser un Client Component, así que necesita"use client".
# Enviar sin email
> Falta nombre o email.
# Enviar válido
> Cliente Nguyễn Văn A creado.
4. Estado pending con useFormStatus
Cuando envías, quieres desactivar el botón y mostrar que está cargando. useFormStatus lee el estado pending del formulario padre más cercano — pero debe estar en un componente hijo separado.
// app/customers/new/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Creando..." : "Crear"}
</button>
);
}
useFormStatussolo funciona cuando el componente se renderiza dentro de un<form>. Si colocas<SubmitButton />en el mismo componente que contiene el<form>, aún funciona correctamente porque React busca el formulario padre más cercano en el árbol DOM, no en el mismo componente.pendingestruemientras el Server Action se ejecuta en el servidor, te ayuda a desactivar el botón para evitar envíos dobles.
Nota: No llames a
useFormStatusdirectamente en el componente que contiene el<form>esperando que sepa su estado — de hecho sigue funcionando porque busca en el árbol DOM, pero sepáralo en un componente hijo es la mejor práctica para evitar confusiones cuando hay múltiples formularios en la misma página.
5. Validación de datos con Zod
Validar manualmente con if/else se vuelve rápidamente confuso. Usa Zod para declarar schemas de forma clara.
<!-- No aplicable — esta es una dependencia npm, mira abajo -->
npm install zod
// app/actions/customer-actions.ts
"use server";
import { z } from "zod";
const customerSchema = z.object({
name: z.string().min(2, "El nombre debe tener al menos 2 caracteres."),
email: z.string().email("Email no válido."),
});
type ActionResult = {
success: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function createCustomer(
_prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const parsed = customerSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
});
if (!parsed.success) {
return {
success: false,
message: "Datos no válidos.",
errors: parsed.error.flatten().fieldErrors,
};
}
return { success: true, message: `Cliente ${parsed.data.name} creado.` };
}
safeParsedevuelve un objeto consuccessydata/error, sin lanzar excepciones — perfecto para devolver errores al cliente en lugar de bloquear la acción.flatten().fieldErrorsproduce un objeto como{ name: ["El nombre debe..."], email: [...] }, útil para mostrar errores justo debajo de cada campo.- Define el schema una sola vez, reutilízalo tanto para validación del cliente (si es necesario) como del servidor — evita duplicar lógica.
6. Revalidar datos después de enviar
Después de crear un cliente, la página de lista de clientes (almacenada en caché por Next.js) necesita actualizarse. Usa revalidatePath o revalidateTag.
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createCustomer(formData: FormData) {
const name = formData.get("name") as string;
// ... guardar en BD
revalidatePath("/customers");
redirect("/customers");
}
revalidatePath("/customers")elimina el caché de esa página, la próxima vez que se acceda, Next.js renderizará con datos nuevos.redirectlanza una excepción especial que Next.js captura para redirigir — llámala después derevalidatePath, y no la coloques entry/catchpara que la excepción no sea tragada, ya que bloquearía la redirección.- Ambas funciones solo se pueden llamar dentro de un Server Action o Server Component, no desde el cliente.
7. Llamar Server Actions desde Client Components (sin formulario)
Los Server Actions no tienen que adjuntarse a <form>. Puedes llamarlos directamente como una función async, por ejemplo en onClick.
// app/customers/[id]/delete-button.tsx
"use client";
import { useState } from "react";
import { deleteCustomer } from "@/app/actions/customer-actions";
export function DeleteButton({ customerId }: { customerId: string }) {
const [isDeleting, setIsDeleting] = useState(false);
async function handleClick() {
setIsDeleting(true);
await deleteCustomer(customerId);
setIsDeleting(false);
}
return (
<button onClick={handleClick} disabled={isDeleting}>
{isDeleting ? "Eliminando..." : "Eliminar"}
</button>
);
}
// app/actions/customer-actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function deleteCustomer(id: string) {
console.log("Eliminar cliente id:", id);
revalidatePath("/customers");
}
- Server Actions se compilan realmente en un endpoint POST oculto por Next.js, el cliente lo llama a través de una solicitud de red automática — no ves el código fetch pero es real, visible en la pestaña Network.
- Esta forma es práctica para botones de eliminar, like, toggle de estado — lugares donde no necesitas un
<form>completo.
8. Escribir tests para Server Actions
Como los Server Actions son solo funciones async que reciben FormData, pruébalos como una función unitaria normal, sin necesidad de mockear HTTP.
npm install -D vitest
// app/actions/customer-actions.test.ts
import { describe, it, expect } from "vitest";
import { createCustomer } from "./customer-actions";
function buildFormData(data: Record<string, string>): FormData {
const fd = new FormData();
for (const [key, value] of Object.entries(data)) {
fd.append(key, value);
}
return fd;
}
describe("createCustomer", () => {
it("devuelve error cuando el email no es válido", async () => {
const formData = buildFormData({ name: "An", email: "khong-hop-le" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(false);
expect(result.errors?.email).toContain("Email no válido.");
});
it("tiene éxito con datos válidos", async () => {
const formData = buildFormData({ name: "An", email: "an@example.com" });
const result = await createCustomer({ success: false, message: "" }, formData);
expect(result.success).toBe(true);
expect(result.message).toContain("An");
});
});
buildFormDatasimulaFormDatacomo el navegador envía, sin necesidad de renderizar componentes o mockear el servidor.- Como un Server Action es una función pura (recibe entrada, devuelve salida), las pruebas se ejecutan rápido, sin necesidad de iniciar el servidor de desarrollo de Next.js.
Nota: Si el Server Action llama a
redirectorevalidatePath, la prueba lanzará un error porque esas funciones solo funcionan en el contexto de solicitud real de Next.js. Separa la lógica de negocio (validación, cálculo) de las llamadas aredirect/revalidatePathpara que las pruebas sean más fáciles.
9. Errores comunes
Olvidar "use server" o colocarlo en el lugar incorrecto. La directiva debe estar en la primera línea del archivo (o al principio de la función, si se declara inline en un Server Component). Sin ella, Next.js trata la función como código normal, la agrupa en el cliente y falla al ejecutarse.
Usar useFormState/useFormStatus en Server Components. Estos hooks pertenecen a react-dom, solo se ejecutan en Client Components. Olvidar "use client" causará un error de compilación.
Devolver objetos complejos (instancias de clases, Date sin serializar) desde un Server Action. Los datos devueltos deben poder serializarse a través de la red, como JSON. Devolver Date sin procesar a veces causa errores silenciosos — conviértelo a string primero.
Llamar Server Actions para todo, incluida la lógica que no necesita servidor. Si es solo validación simple sin tocar la base de datos, procesar en el cliente será más rápido, evita round-trips innecesarios.
No tener fallback cuando JavaScript falla. Los Server Actions soportan mejora progresiva, pero solo si usas correctamente <form action={...}>. Si haces preventDefault() y luego llamas la acción manualmente, pierdes este beneficio.
10. Cuándo no usar Server Actions
Los Server Actions son adecuados para formularios CRUD internos de tu aplicación Next.js. Pero si necesitas:
- Una API pública para que aplicaciones móviles o terceros la llamen — escribe un Route Handler (
route.ts) real, con versionamiento, autenticación clara. - Respuestas en streaming o manejo de cargas de archivos muy grandes con control detallado — Route Handler es más flexible.
- Lógica que necesita ejecutarse independientemente, reutilizarse fuera del contexto de Next.js (por ejemplo, workers, cron jobs) — sepárala en un servicio propio, Server Action solo lo llama.
11. Conclusión
Los Server Actions te ayudan a escribir formularios que procesan datos en el servidor sin necesidad de crear rutas API separadas, reduciendo enormemente el boilerplate de fetch/JSON. Combinados con useFormState para mostrar errores, useFormStatus para indicar estados pending, y revalidatePath para actualizar el caché, tienes un flujo CRUD limpio que funciona incluso cuando JavaScript aún no ha cargado. Usa Zod para validación clara, y recuerda separar la acción en un Route Handler real si necesitas una API pública para externos.