Tipar Server Actions no Next.js: Guia
Domine a tipagem de Server Actions no Next.js. De 'use server' até form data, retornos tipados e error handling, tudo com código que funciona em produção.
Por que isso é importante
Tipar Server Actions no Next.js: Guia. Domine a tipagem de Server Actions no Next.js. De 'use server' até form data, retornos tipados e error handling, tudo com código que funciona em produção.
O Que São Server Actions e Por Que Tipar
Server Actions são funções assíncronas que rodam no servidor. Você marca com 'use server' e pronto: o Next.js cuida do resto. A função roda no backend, mas é chamada direto do componente React, seja via form action, onClick ou qualquer evento.
O problema é que sem tipagem, FormData é um buraco negro. Você chama formData.get('email') e recebe FormDataEntryValue | null. Pode ser string, pode ser File, pode ser null. Sem tipo explícito, você tá no escuro.
Tipar Server Actions significa definir exatamente o que entra e o que sai. Que campos o form tem? Que tipo cada campo carrega? O que a action retorna em caso de sucesso? E em caso de erro? Quando tudo isso tá tipado, o código se documenta sozinho e o TypeScript pega bugs antes de você rodar o app.
Galera costuma pular a tipagem de actions porque parece trabalhoso. Mas a dor de debugar um form que falha silenciosamente em produção é muito pior. Cinco minutos tipando salvam horas debugando.
Passo a Passo: Tipando Server Actions
Vamos construir a tipagem completa de uma Server Action, do input ao retorno.
- Passo 1 - Crie o type de retorno da action: Defina um tipo com campo success (boolean), data (opcional) e error (opcional). Isso padroniza como o componente trata o resultado.
- Passo 2 - Defina a interface do form data: Crie um type com cada campo do formulário e seu tipo esperado. Não dependa de FormData.get() sem tipagem.
- Passo 3 - Marque a função com 'use server': Coloque no topo do arquivo ou da função. Só funciona em funções async. O TypeScript garante que a função é async.
- Passo 4 - Valide e converta o FormData: Extraia cada campo, converta pra string e valide. Crie uma função helper que transforma FormData no seu tipo definido.
- Passo 5 - Retorne sempre o tipo padronizado: Sucesso retorna { success: true, data }. Erro retorna { success: false, error }. O componente sabe exatamente o que esperar.
- Passo 6 - Use useActionState pra gerenciar estado: O hook useActionState (React 19+) aceita generic types. Tipe o state e a action pra autocomplete completo.
Exemplos Práticos de Server Actions Tipadas
Cada exemplo mostra um padrão que você vai usar em projetos reais. Cola no seu código e adapta.
Action Básica com FormData Tipado
// app/actions/auth.ts
"use server";
// Tipo do retorno padronizado
type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string };
// Tipo dos dados do form
type LoginData = {
email: string;
password: string;
};
export async function loginAction(
formData: FormData
): Promise<ActionResult<{ token: string }>> {
// Extrai e valida campos
const email = formData.get("email") as string | null;
const password = formData.get("password") as string | null;
if (!email || !password) {
return { success: false, error: "Email e senha obrigatórios" };
}
try {
const token = await authenticate(email, password);
return { success: true, data: { token } };
} catch (err) {
return { success: false, error: "Credenciais inválidas" };
}
}
Action com Zod pra Validação Tipada
// app/actions/contact.ts
"use server";
import { z } from "zod";
// Schema Zod gera o tipo automaticamente
const contactSchema = z.object({
name: z.string().min(2, "Nome muito curto"),
email: z.string().email("Email inválido"),
message: z.string().min(10, "Mensagem muito curta"),
});
// Tipo inferido do schema
type ContactData = z.infer<typeof contactSchema>;
type ActionResult =
| { success: true; message: string }
| { success: false; errors: Record<string, string[]> };
export async function submitContact(
formData: FormData
): Promise<ActionResult> {
const raw = {
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
};
const result = contactSchema.safeParse(raw);
if (!result.success) {
// Erros tipados por campo
return {
success: false,
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
};
}
// result.data tem tipo ContactData
await saveContact(result.data);
return { success: true, message: "Mensagem enviada" };
}
Action com useActionState Tipado
// app/components/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { submitContact } from "@/app/actions/contact";
// Tipo do state da action
type FormState = {
success: boolean;
message?: string;
errors?: Record<string, string[]>;
} | null;
export function ContactForm() {
// useActionState com tipos genéricos
const [state, formAction, isPending] = useActionState<FormState, FormData>(
async (prevState: FormState, formData: FormData) => {
const result = await submitContact(formData);
return result;
},
null // estado inicial tipado como FormState
);
return (
<form action={formAction}>
<input name="name" disabled={isPending} />
<input name="email" type="email" disabled={isPending} />
<textarea name="message" disabled={isPending} />
{state?.errors?.email && (
<p className="text-red-500">{state.errors.email[0]}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "Enviando..." : "Enviar"}
</button>
</form>
);
}
Action com revalidatePath e revalidateTag
// app/actions/posts.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
type CreatePostInput = {
title: string;
content: string;
published: boolean;
};
type PostResult = {
id: string;
title: string;
slug: string;
};
export async function createPost(
formData: FormData
): Promise<{ success: true; data: PostResult } | { success: false; error: string }> {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const published = formData.get("published") === "on";
if (!title || !content) {
return { success: false, error: "Título e conteúdo obrigatórios" };
}
const post = await db.post.create({
data: { title, content, published },
});
// Revalida o cache da página de listagem
revalidatePath("/blog");
revalidateTag("posts");
// Redireciona pro post criado
redirect(`/blog/${post.slug}`);
}
Action sem FormData: Chamada Direta
// app/actions/favorites.ts
"use server";
import { revalidatePath } from "next/cache";
// Action que recebe argumentos tipados diretamente
export async function toggleFavorite(
productId: string,
userId: string
): Promise<{ isFavorite: boolean }> {
const existing = await db.favorite.findUnique({
where: { userId_productId: { userId, productId } },
});
if (existing) {
await db.favorite.delete({ where: { id: existing.id } });
revalidatePath("/favorites");
return { isFavorite: false };
}
await db.favorite.create({ data: { userId, productId } });
revalidatePath("/favorites");
return { isFavorite: true };
}
// No componente client:
// const result = await toggleFavorite(productId, userId);
// result.isFavorite é boolean tipado
Percebe o padrão? Toda action tem input tipado, retorno tipado e tratamento de erro padronizado. O componente que consome a action sabe exatamente o que vai receber. Sem surpresas.
Erros Comuns com Server Actions Tipadas
Armadilhas que pegam até dev experiente
Esquecer o 'use server': Sem essa diretiva, a função roda no client. O TypeScript não reclama, mas o comportamento muda completamente. Coloque sempre no topo do arquivo ou no início da função.
Confiar no as string sem validar: formData.get('email') as string é perigoso. Se o campo não existe no form, você tem null convertido pra string. Sempre verifique se o valor existe antes de fazer type assertion.
Retornar tipos inconsistentes: Se uma action retorna { success: true } em um caminho e { error: 'msg' } em outro, o componente não consegue tratar direito. Padronize o retorno com um discriminated union.
Não tipar o estado do useActionState: O hook aceita generics. Se você não tipa, o state é unknown e você perde autocomplete. Defina FormState e passe como generic.
Serialização quebrada: Server Actions passam dados via rede. Objetos como Date, Map, Set e funções não serializam. Converta pra string ou number antes de retornar. TypeScript não checa serialização automaticamente.
Checklist de Server Actions Tipadas
- Diretiva 'use server' no topo do arquivo ou da função
- Type de retorno padronizado com discriminated union (success/error)
- FormData extraído e validado antes de usar
- Zod ou validação manual com tipos inferidos
- useActionState com generics tipados no componente client
- revalidatePath e revalidateTag chamados após mutações
- Retorno contém apenas dados serializáveis (sem Date, Map, Set)
- Tratamento de erro com try/catch retornando tipo padronizado
Construa Forms Profissionais com TypeScript
Server Actions tipadas são uma peça do projeto completo. No CrazyStack, você constrói formulários de produção com validação Zod, Server Actions tipadas, feedback em tempo real e tratamento de erro profissional. Tudo integrado num SaaS real com Next.js e TypeScript.
Se você quer sair do achismo e ter controle total sobre o que entra e sai dos seus formulários, esse é o caminho.