Formularios con Server Actions
Valida datos de formulario con Zod dentro de Server Actions y usa useActionState para mostrar errores y estado de carga.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
// app/actions/contact.ts
"use server";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(1, "El nombre es requerido"),
email: z.string().email("Email inválido"),
message: z.string().min(10, "Al menos 10 caracteres"),
});
export type ContactState = {
errors?: Record<string, string[]>;
message?: string;
success?: boolean;
};
export async function submitContact(
prevState: ContactState,
formData: FormData
): Promise<ContactState> {
const raw = Object.fromEntries(formData);
const result = ContactSchema.safeParse(raw);
if (!result.success) {
return { errors: result.error.flatten().fieldErrors as Record<string, string[]> };
}
// Procesa los datos validados
await saveToDatabase(result.data);
return { success: true, message: "¡Mensaje enviado!" };
}// app/contact/page.tsx
"use client";
import { useActionState } from "react";
import { submitContact, type ContactState } from "@/app/actions/contact";
export default function ContactPage() {
const [state, formAction, isPending] = useActionState(submitContact, {});
return (
<form action={formAction}>
<input name="name" />
{state.errors?.name && <p>{state.errors.name[0]}</p>}
<input name="email" />
{state.errors?.email && <p>{state.errors.email[0]}</p>}
<textarea name="message" />
{state.errors?.message && <p>{state.errors.message[0]}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Enviando..." : "Enviar"}
</button>
{state.success && <p>{state.message}</p>}
</form>
);
}Cuándo usarlo: Cuando quieres validación del lado del servidor sin una librería de formularios del lado del cliente — mejora progresiva, bundle más pequeño, y funciona sin JavaScript.
Ejemplo Funcional
// app/actions/signup.ts
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
const SignupSchema = z
.object({
username: z.string().min(3).max(20).regex(/^[a-z0-9_]+$/),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
});
export type SignupState = {
errors?: Record<string, string[]>;
formError?: string;
values?: Record<string, string>;
};
export async function signup(
prevState: SignupState,
formData: FormData
): Promise<SignupState> {
const raw = {
username: formData.get("username") as string,
email: formData.get("email") as string,
password: formData.get("password") as string,
confirmPassword: formData.get("confirmPassword") as string,
};
const result = SignupSchema.safeParse(raw);
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
values: { username: raw.username, email: raw.email }, // preserva campos no sensibles
};
}
// Verifica si el usuario existe
const existing = await db.user.findUnique({ where: { email: result.data.email } });
if (existing) {
return {
formError: "Ya existe una cuenta con este correo electrónico",
values: { username: raw.username, email: raw.email },
};
}
await db.user.create({ data: result.data });
redirect("/dashboard");
}// app/signup/page.tsx
"use client";
import { useActionState } from "react";
import { signup, type SignupState } from "@/app/actions/signup";
export default function SignupPage() {
const [state, formAction, isPending] = useActionState(signup, {});
return (
<form action={formAction} className="max-w-md space-y-4">
{state.formError && (
<div className="rounded bg-red-50 p-3 text-sm text-red-700">{state.formError}</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium">Usuario</label>
<input
id="username"
name="username"
defaultValue={state.values?.username ?? ""}
className="w-full rounded border p-2"
/>
{state.errors?.username && (
<p className="mt-1 text-sm text-red-600">{state.errors.username[0]}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">Correo electrónico</label>
<input
id="email"
name="email"
type="email"
defaultValue={state.values?.email ?? ""}
className="w-full rounded border p-2"
/>
{state.errors?.email && (
<p className="mt-1 text-sm text-red-600">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">Contraseña</label>
<input id="password" name="password" type="password" className="w-full rounded border p-2" />
{state.errors?.password && (
<p className="mt-1 text-sm text-red-600">{state.errors.password[0]}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium">Confirmar contraseña</label>
<input id="confirmPassword" name="confirmPassword" type="password" className="w-full rounded border p-2" />
{state.errors?.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{state.errors.confirmPassword[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? "Creando cuenta..." : "Registrarse"}
</button>
</form>
);
}Lo que esto demuestra:
- Validación del lado del servidor con Zod en una Server Action
useActionStatepara gestión de estado e interfaz de carga- Preservar valores de formulario no sensibles en toda las presentaciones
- Errores a nivel de formulario separados de errores a nivel de campo
redirect()después de una mutación exitosa
Análisis profundo
Cómo funciona
- Los Server Actions son funciones asincrónicas marcadas con
"use server"que se ejecutan en el servidor - Cuando se usan con
<form action={formAction}>, el navegador envía una solicitud POST deFormData useActionState(action, initialState)envuelve la acción, gestionando actualizaciones de estado y proporcionandoisPending- La acción recibe
(prevState, formData)y debe devolver la misma forma de estado - En caso de fallo de validación, devuelve errores; el componente se re-renderiza con el nuevo estado
- Los formularios funcionan sin JavaScript (mejora progresiva) — los errores aparecen después de un viaje de ida y vuelta completo de la página
Variaciones
Ayudante de validación reutilizable:
// lib/validate.ts
import { z } from "zod";
export function validateFormData<T extends z.ZodType>(
schema: T,
formData: FormData
): { success: true; data: z.infer<T> } | { success: false; errors: Record<string, string[]> } {
const raw = Object.fromEntries(formData);
const result = schema.safeParse(raw);
if (result.success) return { success: true, data: result.data };
return { success: false, errors: result.error.flatten().fieldErrors as Record<string, string[]> };
}
// Uso en la acción
export async function myAction(prev: State, formData: FormData) {
const v = validateFormData(MySchema, formData);
if (!v.success) return { errors: v.errors };
// v.data está tipado
}Combinación de validación del cliente y servidor:
// Cliente: retroalimentación inmediata
const { register, handleSubmit } = useForm({ resolver: zodResolver(Schema) });
// Servidor: validación autorizada
async function onSubmit(data: FormData) {
const result = await serverAction(data);
if (result.errors) setError("root", { message: result.formError });
}Notas de TypeScript
// El tipo de estado debe coincidir entre la firma de la acción y useActionState
type State = { errors?: Record<string, string[]>; message?: string };
// Firma de acción para useActionState
export async function myAction(prev: State, formData: FormData): Promise<State> { ... }
// useActionState devuelve [State, (formData: FormData) => void, boolean]
const [state, action, isPending] = useActionState(myAction, {} as State);Trampas comunes
-
FormData.get()devuelvestring | File | null— Necesitas hacer casting o coerción. Solución: Usaas stringpara campos de texto, o usaz.coerce.*en tu esquema. -
redirect()lanza internamente — No envuelvasredirect()en un try/catch. Solución: Llama aredirect()fuera de bloques try/catch, o vuelve a lanzar errores de redirección. -
Contraseñas en state — Nunca devuelvas valores de contraseña en el objeto state. Solución: Solo persiste valores de campo no sensibles para repoblar el formulario.
-
useActionStatevsuseFormStatus—useActionStateenvuelve la acción y gestiona el estado.useFormStatuslee el estado pendiente de un<form>padre y debe estar en un componente hijo. Resuelven problemas diferentes. -
Sin validación en tiempo real — Los formularios de Server Action solo validan al enviar. Solución: Agrega validación del lado del cliente con RHF + Zod para retroalimentación instantánea, y mantén la validación del servidor como la autoridad.
Alternativas
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
| RHF + Zod (solo cliente) | Necesitas retroalimentación a nivel de campo instantánea | Quieres mejora progresiva |
| Remix actions | Estás usando Remix en lugar de Next.js | Estás en Next.js App Router |
| tRPC mutations | Quieres RPC tipado de extremo a extremo sin acciones de formulario | Quieres mejora progresiva de formulario nativa |
| API route + fetch | Necesitas más control sobre la solicitud HTTP | Los Server Actions son más simples para presentaciones de formulario |
FAQs
¿Cuál es la firma de una Server Action usada con useActionState?
async function myAction(
prevState: State,
formData: FormData
): Promise<State> { ... }Recibe el estado anterior y un objeto FormData, y devuelve el nuevo estado.
¿Qué tres valores devuelve useActionState?
state-- el objeto de estado actual devuelto por la acciónformAction-- la función para pasar a<form action={...}>isPending-- un booleano que estruemientras la acción está en vuelo
¿Cómo muestras errores de validación a nivel de campo de una Server Action?
Usa result.error.flatten().fieldErrors de safeParse de Zod, devuelve los errores en state, luego renderiza por campo:
{state.errors?.email && <p>{state.errors.email[0]}</p>}¿Por qué deberías preservar valores de formulario en el estado devuelto?
Después de un viaje de ida y vuelta del servidor, el formulario se re-renderiza con campos vacíos. Devolver valores no sensibles (por ejemplo, username, email) en state te permite repoblar entradas a través de defaultValue={state.values?.fieldName}.
¿Cómo funciona la mejora progresiva con formularios de Server Action?
El formulario usa un <form action={...}> POST nativo. Si JavaScript está deshabilitado, el navegador aún envía el formulario y el servidor devuelve HTML con errores. Con JS habilitado, useActionState intercepta la presentación para una experiencia SPA sin inconvenientes.
¿Cómo creas un ayudante de validación reutilizable para Server Actions?
function validateFormData<T extends z.ZodType>(
schema: T, formData: FormData
) {
const raw = Object.fromEntries(formData);
const result = schema.safeParse(raw);
if (result.success) return { success: true, data: result.data };
return { success: false, errors: result.error.flatten().fieldErrors };
}¿Cuál es la diferencia entre useActionState y useFormStatus?
useActionStateenvuelve la acción, gestiona el estado, y proporcionaisPendinguseFormStatuslee el estado pendiente de un<form>padre y debe usarse en un componente hijo- Resuelven problemas diferentes y pueden usarse juntos
Trampa común: ¿Qué sucede si envuelves redirect() en un try/catch?
redirect() lanza internamente para desencadenar la navegación. Envolverlo en try/catch atrapa la redirección. Siempre llama a redirect() fuera de bloques try/catch.
Trampa común: ¿Por qué nunca deberías devolver valores de contraseña en el estado de la acción?
El estado se serializa y se envía al cliente. Devolver valores sensibles como contraseñas los expone en la carga de respuesta. Solo persiste campos no sensibles para repoblar el formulario.
¿Cómo manejas errores a nivel de formulario separados de errores a nivel de campo?
Agrega un campo formError a tu tipo de estado para errores que no están vinculados a un campo específico (por ejemplo, "Ya existe una cuenta con este correo electrónico"), y renderiza sobre los campos del formulario.
TypeScript: ¿Cómo escribes el objeto de estado compartido entre la acción y el componente?
export type State = {
errors?: Record<string, string[]>;
formError?: string;
values?: Record<string, string>;
};
// Tanto la firma de la acción como useActionState usan este tipoTypeScript: ¿Qué devuelve FormData.get() y cómo lo manejas?
- Devuelve
string | File | null - Para campos de texto, convierte con
as stringo usaz.coerce.*en tu esquema - Nunca asumas que el valor es una cadena sin manejar
null
¿Puedes combinar validación del lado del cliente y del servidor?
Sí. Usa RHF + Zod en el cliente para retroalimentación instantánea, luego re-valida con el mismo esquema en la Server Action como la verificación autoritaria. La validación del servidor es la fuente de verdad.
Relacionado
- Zod Basics — definición de esquema
- Form Patterns Basic — patrones de inicio de sesión y registro
- Optimistic Forms — useOptimistic con formularios
- Form Error Display — patrones de visualización de errores