Formularios con Server Actions
Valida datos de formulario con Zod dentro de Server Actions y usa useActionState para mostrar errores y estado de carga.
Busca en todas las páginas de la documentación
Valida datos de formulario con Zod dentro de Server Actions y usa useActionState para mostrar errores y estado de carga.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
useActionState para gestión de estado e interfaz de cargaredirect() después de una mutación exitosa"use server" que se ejecutan en el servidor<form action={formAction}>, el navegador envía una solicitud POST de FormDatauseActionState(action, initialState) envuelve la acción, gestionando actualizaciones de estado y proporcionando isPending(prevState, formData) y debe devolver la misma forma de estadoAyudante 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 });
}// 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);FormData.get() devuelve string | File | null - Necesitas hacer casting o coerción. Solución: Usa as string para campos de texto, o usa z.coerce.* en tu esquema.
redirect() lanza internamente - No envuelvas redirect() en un try/catch. Solución: Llama a redirect() 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.
useActionState vs useFormStatus - useActionState envuelve la acción y gestiona el estado. useFormStatus lee 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.
| 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 |
async function myAction(
prevState: State,
formData: FormData
): Promise<State> { ... }Recibe el estado anterior y un objeto FormData, y devuelve el nuevo estado.
state -- el objeto de estado actual devuelto por la acciónformAction -- la función para pasar a <form action={...}>isPending -- un booleano que es true mientras la acción está en vueloUsa 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>}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}.
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.
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 };
}useActionState envuelve la acción, gestiona el estado, y proporciona isPendinguseFormStatus lee el estado pendiente de un <form> padre y debe usarse en un componente hijoredirect() lanza internamente para desencadenar la navegación. Envolverlo en try/catch atrapa la redirección. Siempre llama a redirect() fuera de bloques try/catch.
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.
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.
export type State = {
errors?: Record<string, string[]>;
formError?: string;
values?: Record<string, string>;
};
// Tanto la firma de la acción como useActionState usan este tipostring | File | nullas string o usa z.coerce.* en tu esquemanullSí. 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.
Revisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥