Server Action Forms
Valide dados de formulário com Zod dentro de Server Actions e use useActionState para exibir erros e estado de pendência.
Busque em todas as páginas da documentação
Valide dados de formulário com Zod dentro de Server Actions e use useActionState para exibir erros e estado de pendência.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Cartão de receita de referência rápida - pronto para copiar e colar.
// app/actions/contact.ts
"use server";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(1, "O nome é obrigatório"),
email: z.string().email("Email inválido"),
message: z.string().min(10, "Pelo 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[]> };
}
// Processar os dados validados
await saveToDatabase(result.data);
return { success: true, message: "Mensagem enviada!" };
}// 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>
);
}Quando usar isso: Quando você deseja validação no lado do servidor sem uma biblioteca de formulário no lado do cliente - aprimoramento progressivo, bundle mais simples e funciona sem 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: "As senhas não coincidem",
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 }, // preservar campos não sensíveis
};
}
// Verificar se o usuário existe
const existing = await db.user.findUnique({ where: { email: result.data.email } });
if (existing) {
return {
formError: "Uma conta com este email já existe",
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">Nome de usuário</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">Email</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">Senha</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 Senha</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 ? "Criando conta..." : "Cadastrar"}
</button>
</form>
);
}O que isso demonstra:
useActionState para gerenciamento de estado e UI pendenteredirect() após mutação bem-sucedida"use server" que rodam no servidor<form action={formAction}>, o navegador envia uma requisição POST FormDatauseActionState(action, initialState) envolve a action, gerenciando atualizações de estado e fornecendo isPending(prevState, formData) e deve retornar a mesma forma de estadoHelper de validação reutilizável:
// 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 na action
export async function myAction(prev: State, formData: FormData) {
const v = validateFormData(MySchema, formData);
if (!v.success) return { errors: v.errors };
// v.data é tipado
}Combinando validação do cliente e do servidor:
// Cliente: feedback imediato
const { register, handleSubmit } = useForm({ resolver: zodResolver(Schema) });
// Servidor: validação autoritativa
async function onSubmit(data: FormData) {
const result = await serverAction(data);
if (result.errors) setError("root", { message: result.formError });
}// O tipo de estado deve corresponder entre a assinatura da action e useActionState
type State = { errors?: Record<string, string[]>; message?: string };
// Assinatura da action para useActionState
export async function myAction(prev: State, formData: FormData): Promise<State> { ... }
// useActionState retorna [State, (formData: FormData) => void, boolean]
const [state, action, isPending] = useActionState(myAction, {} as State);FormData.get() retorna string | File | null - Você precisa converter ou coerir. Correção: Use as string para campos de texto, ou use z.coerce.* no seu schema.
redirect() lança internamente - Não envolva redirect() em um try/catch. Correção: Chame redirect() fora de blocos try/catch, ou relance erros de redirect.
Senhas no estado - Nunca retorne valores de senha no objeto de estado. Correção: Persista apenas valores de campo não sensíveis para repopular o formulário.
useActionState vs useFormStatus - useActionState envolve a action e gerencia o estado. useFormStatus lê o estado pendente de um <form> pai e deve estar em um componente filho. Eles resolvem problemas diferentes.
Validação sem tempo real - Formulários de Server Action só validam no envio. Correção: Adicione validação no lado do cliente com RHF + Zod para feedback instantâneo, e mantenha a validação do servidor como a autoridade.
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
| RHF + Zod (apenas cliente) | Você precisa de feedback instantâneo em nível de campo | Você quer aprimoramento progressivo |
| Remix actions | Você está usando Remix em vez de Next.js | Você está no App Router do Next.js |
| tRPC mutations | Você quer RPC com tipagem de ponta a ponta sem actions de formulário | Você quer aprimoramento progressivo nativo de formulário |
| API route + fetch | Você precisa de mais controle sobre a requisição HTTP | Server Actions são mais simples para envios de formulário |
async function myAction(
prevState: State,
formData: FormData
): Promise<State> { ... }Ela recebe o estado anterior e um objeto FormData, e retorna o novo estado.
state -- o objeto de estado atual retornado pela actionformAction -- a função para passar para <form action={...}>isPending -- um booleano que é true enquanto a action está em andamentoUse result.error.flatten().fieldErrors do safeParse do Zod, retorne os erros no estado e, em seguida, renderize-os por campo:
{state.errors?.email && <p>{state.errors.email[0]}</p>}Após um ciclo de servidor, o formulário é re-renderizado com campos vazios. Retornar valores não sensíveis (por exemplo, username, email) no estado permite que você repopule os inputs via defaultValue={state.values?.fieldName}.
O formulário usa um POST nativo <form action={...}>. Se o JavaScript estiver desabilitado, o navegador ainda envia o formulário e o servidor retorna HTML com erros. Com JS habilitado, useActionState intercepta o envio para uma experiência SPA contínua.
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 envolve a action, gerencia o estado e fornece isPendinguseFormStatus lê o estado pendente de um <form> pai e deve ser usado em um componente filhoredirect() lança internamente para acionar a navegação. Envolvê-lo em try/catch suprime o redirect. Sempre chame redirect() fora de blocos try/catch.
O estado é serializado e enviado para o cliente. Retornar valores sensíveis como senhas os expõe no payload da resposta. Persista apenas campos não sensíveis para repopular o formulário.
Adicione um campo formError ao seu tipo de estado para erros que não estão vinculados a um campo específico (por exemplo, "Uma conta com este email já existe") e renderize-o acima dos campos do formulário.
export type State = {
errors?: Record<string, string[]>;
formError?: string;
values?: Record<string, string>;
};
// Tanto a assinatura da action quanto useActionState usam este tipostring | File | nullas string ou z.coerce.* no seu schemanullSim. Use RHF + Zod no cliente para feedback instantâneo, e então revalide com o mesmo schema na Server Action como a verificação autoritativa. A validação do servidor é a fonte da verdade.
Revisado por Chris St. John·Última atualização: 16 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥