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.
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
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.
Continue lendo
Como Tipar Page Params no Next.js App Router com TypeScript
Tipagem de params, searchParams e rotas dinâmicas no Next.js App Router.
Como Tipar Fetch e Data Fetching no Next.js com TypeScript
Typed fetch wrapper, data fetching em server components e cache tipado no Next.js.
Como Tipar Metadata no Next.js com TypeScript
Tipagem completa de Metadata e generateMetadata no Next.js com TypeScript.
Interface no TypeScript
Quando usar interface