Introdução: O Que São Server Actions e Por Que Eles Mudaram Tudo
Se você acompanha o ecossistema React e Next.js há alguns anos, provavelmente se lembra da época em que toda mutação de dados — criar um usuário, atualizar um produto, deletar um comentário — exigia criar uma rota de API separada. Você montava o arquivo app/api/produtos/route.ts, fazia um fetch do cliente, tratava os erros de rede, passava os dados, e torcia pra não esquecer de validar nada no servidor. Funcionava, claro. Mas era muito boilerplate.
Os Server Actions chegaram pra mudar essa dinâmica de um jeito bem fundamental. Em vez de criar um endpoint de API na mão, você escreve uma função assíncrona marcada com a diretiva 'use server' e o Next.js cuida de expô-la como um endpoint HTTP POST automaticamente. A função pode ser chamada direto de um formulário HTML ou de código cliente — sem fetch manual, sem rota de API explícita. Simples assim.
Mas não se engane: Server Actions não são mágica que elimina a complexidade. Elas são, na prática, endpoints HTTP POST gerados automaticamente. O Next.js cria uma URL única pra cada função marcada com 'use server', e quando você "chama" essa função do cliente, o browser tá na verdade fazendo um POST pra esse endpoint. Essa distinção é crítica pra segurança — e vamos falar bastante sobre isso mais adiante.
Outro aspecto que eu acho particularmente poderoso é o progressive enhancement nativo. Quando você conecta uma Server Action a um formulário HTML usando o atributo action, o formulário funciona mesmo sem JavaScript. O browser envia os dados diretamente via POST padrão. Quando o JavaScript carrega, o Next.js intercepta o envio e usa uma chamada otimizada, sem recarregar a página. Acessibilidade e resiliência de graça — o que não é pouca coisa.
Lembre-se: Server Actions não substituem 100% dos casos de uso de API Routes. Para APIs públicas consumidas por terceiros, apps mobile, ou integrações webhook, você ainda vai querer usar Route Handlers. Server Actions são ideais pra mutações dentro da sua própria aplicação Next.js.
Criando Sua Primeira Server Action
Existem duas formas de definir uma Server Action: inline dentro de um Server Component ou em um arquivo separado com 'use server' no topo. Cada abordagem tem seu caso de uso ideal, então vamos ver as duas.
Abordagem 1: Inline em Server Components
A forma mais simples é definir a função diretamente dentro do componente servidor. A diretiva 'use server' vai dentro da função, sinalizando que aquela função específica deve rodar no servidor.
// app/tarefas/page.tsx
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export default function PaginaTarefas() {
async function adicionarTarefa(formData: FormData) {
'use server'
const titulo = formData.get('titulo') as string
if (!titulo || titulo.trim().length === 0) {
throw new Error('Título é obrigatório')
}
await db.tarefa.create({
data: {
titulo: titulo.trim(),
concluida: false,
},
})
revalidatePath('/tarefas')
}
return (
<div>
<h1>Minhas Tarefas</h1>
<form action={adicionarTarefa}>
<input name="titulo" placeholder="Nova tarefa..." required />
<button type="submit">Adicionar</button>
</form>
</div>
)
}
Repare que a função adicionarTarefa recebe um objeto FormData automaticamente quando conectada ao atributo action do formulário. O Next.js cuida de serializar os dados e passá-los pra função no servidor. Você não precisa se preocupar com nada disso.
Abordagem 2: Arquivo Separado (Recomendada para Reutilização)
Quando a mesma Server Action precisa ser usada em vários componentes — ou quando você simplesmente quer organizar melhor o código — o ideal é criar um arquivo dedicado com 'use server' no topo.
// app/actions/tarefas.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function adicionarTarefa(formData: FormData) {
const titulo = formData.get('titulo') as string
await db.tarefa.create({
data: { titulo: titulo.trim(), concluida: false },
})
revalidatePath('/tarefas')
}
export async function concluirTarefa(id: string) {
await db.tarefa.update({
where: { id },
data: { concluida: true },
})
revalidatePath('/tarefas')
}
export async function excluirTarefa(id: string) {
await db.tarefa.delete({ where: { id } })
revalidatePath('/tarefas')
}
Com o 'use server' no topo do arquivo, todas as funções exportadas automaticamente se tornam Server Actions. Essa convenção facilita muito a organização em projetos maiores — você agrupa as actions por domínio (tarefas, produtos, usuários, pedidos) em arquivos separados na pasta app/actions/.
Atenção à diferença:
'use server'no topo do arquivo = todas as funções exportadas são Server Actions.'use server'dentro de uma função = apenas aquela função específica é uma Server Action. Misturar as duas abordagens no mesmo arquivo pode gerar bastante confusão, então evite.
Formulários com Server Actions e o Componente <Form> do Next.js
A integração mais natural de Server Actions é com formulários HTML. O atributo action do <form> pode receber diretamente uma Server Action no lugar de uma string de URL. E isso é basicamente tudo que você precisa pra fazer o formulário funcionar — inclusive sem JavaScript habilitado.
Passando Dados Adicionais com bind
Às vezes você precisa passar dados além do que está no formulário — como um ID de registro que precisa ser atualizado. A forma recomendada (e honestamente a mais elegante) é usar .bind() na Server Action.
// app/produtos/[id]/page.tsx
import { atualizarProduto } from '@/app/actions/produtos'
export default async function EditarProduto({ params }: { params: { id: string } }) {
const produto = await db.produto.findUnique({ where: { id: params.id } })
// Pré-vincula o ID do produto à action
const atualizarComId = atualizarProduto.bind(null, params.id)
return (
<form action={atualizarComId}>
<input name="nome" defaultValue={produto?.nome} />
<input name="preco" type="number" defaultValue={produto?.preco} />
<button type="submit">Salvar</button>
</form>
)
}
// app/actions/produtos.ts
'use server'
export async function atualizarProduto(id: string, formData: FormData) {
const nome = formData.get('nome') as string
const preco = parseFloat(formData.get('preco') as string)
await db.produto.update({
where: { id },
data: { nome, preco },
})
revalidatePath(`/produtos/${id}`)
redirect(`/produtos/${id}`)
}
O Componente <Form> do Next.js
O Next.js oferece um componente <Form> importado de next/form que estende o elemento HTML nativo com funcionalidades extras. A principal vantagem? Ele pré-carrega o destino quando o formulário fica visível na tela — um comportamento parecido com o prefetching do componente <Link>.
// app/busca/page.tsx
import Form from 'next/form'
export default function PaginaBusca() {
return (
<div>
<h1>Busca de Produtos</h1>
<Form action="/busca/resultados">
<input name="q" placeholder="O que você está procurando?" />
<select name="categoria">
<option value="">Todas as categorias</option>
<option value="eletronicos">Eletrônicos</option>
<option value="roupas">Roupas</option>
</select>
<button type="submit">Buscar</button>
</Form>
</div>
)
}
Para navegação baseada em busca (onde os parâmetros vão pra URL como query strings), o componente <Form> com um action de string é perfeito. Para mutações de dados, continue usando Server Actions diretamente no action.
Gerenciando Estado com useActionState
Formulários simples que só submetem dados já funcionam muito bem com o que vimos até aqui. Mas aplicações reais precisam de feedback: indicador de carregamento, mensagens de erro, dados retornados pela action. É aí que entra o hook useActionState do React 19.
Ele substitui o antigo useFormState (que foi descontinuado) e oferece uma API bem mais limpa pra gerenciar o estado completo de uma Server Action. Recebe a action original e um estado inicial, e retorna o estado atualizado, uma versão "wrapped" da action, e um booleano de pendência.
// app/actions/contato.ts
'use server'
import { z } from 'zod'
const schemaContato = z.object({
nome: z.string().min(2, 'Nome deve ter pelo menos 2 caracteres'),
email: z.string().email('E-mail inválido'),
mensagem: z.string().min(10, 'Mensagem deve ter pelo menos 10 caracteres'),
})
type EstadoContato = {
sucesso: boolean
erros?: {
nome?: string[]
email?: string[]
mensagem?: string[]
_form?: string[]
}
mensagemSucesso?: string
}
export async function enviarContato(
estadoAnterior: EstadoContato,
formData: FormData
): Promise<EstadoContato> {
const dadosBrutos = {
nome: formData.get('nome'),
email: formData.get('email'),
mensagem: formData.get('mensagem'),
}
const validacao = schemaContato.safeParse(dadosBrutos)
if (!validacao.success) {
return {
sucesso: false,
erros: validacao.error.flatten().fieldErrors,
}
}
try {
await enviarEmailContato(validacao.data)
return {
sucesso: true,
mensagemSucesso: 'Mensagem enviada com sucesso! Respondemos em até 24h.',
}
} catch {
return {
sucesso: false,
erros: { _form: ['Erro ao enviar mensagem. Tente novamente.'] },
}
}
}
// app/contato/FormularioContato.tsx
'use client'
import { useActionState } from 'react'
import { enviarContato } from '@/app/actions/contato'
const estadoInicial = { sucesso: false }
export function FormularioContato() {
const [estado, action, isPending] = useActionState(enviarContato, estadoInicial)
if (estado.sucesso) {
return (
<div className="alert alert-success">
{estado.mensagemSucesso}
</div>
)
}
return (
<form action={action}>
<div>
<label htmlFor="nome">Nome</label>
<input id="nome" name="nome" type="text" />
{estado.erros?.nome && (
<p className="erro">{estado.erros.nome[0]}</p>
)}
</div>
<div>
<label htmlFor="email">E-mail</label>
<input id="email" name="email" type="email" />
{estado.erros?.email && (
<p className="erro">{estado.erros.email[0]}</p>
)}
</div>
<div>
<label htmlFor="mensagem">Mensagem</label>
<textarea id="mensagem" name="mensagem" rows={5} />
{estado.erros?.mensagem && (
<p className="erro">{estado.erros.mensagem[0]}</p>
)}
</div>
{estado.erros?._form && (
<div className="alert alert-error">
{estado.erros._form[0]}
</div>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Enviando...' : 'Enviar Mensagem'}
</button>
</form>
)
}
Perceba a assinatura da action quando usada com useActionState: ela recebe o estado anterior como primeiro parâmetro e o FormData como segundo. Isso é diferente das actions simples que recebem só o FormData. Detalhe pequeno, mas que pega muita gente de surpresa no início.
Você também pode usar o hook useFormStatus do React-DOM dentro de um componente filho pra acessar o estado de pendência sem prop drilling.
Extraindo o Botão de Submit
// app/components/BotaoSubmit.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function BotaoSubmit({ texto = 'Enviar', textoPendente = 'Enviando...' }) {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending} aria-disabled={pending}>
{pending ? textoPendente : texto}
</button>
)
}
O useFormStatus acessa o estado do formulário pai mais próximo via contexto React, sem precisar receber props. Isso torna o BotaoSubmit reutilizável em qualquer formulário da aplicação — bem prático.
Validação de Dados com Zod
Validar dados do lado do servidor não é opcional — é obrigatório. Nunca confie em dados vindos do cliente. O Zod é, de longe, a biblioteca de validação mais popular no ecossistema TypeScript, e integra de forma super natural com Server Actions.
Schema de Validação Reutilizável
// lib/schemas/produto.ts
import { z } from 'zod'
export const schemaCriarProduto = z.object({
nome: z
.string()
.min(3, 'Nome deve ter pelo menos 3 caracteres')
.max(100, 'Nome deve ter no máximo 100 caracteres')
.trim(),
descricao: z
.string()
.min(10, 'Descrição muito curta')
.max(1000, 'Descrição muito longa')
.optional(),
preco: z
.string()
.transform((val) => parseFloat(val))
.pipe(z.number().positive('Preço deve ser positivo').max(99999, 'Preço muito alto')),
estoque: z
.string()
.transform((val) => parseInt(val, 10))
.pipe(z.number().int('Estoque deve ser inteiro').min(0, 'Estoque não pode ser negativo')),
categoriaId: z.string().uuid('Categoria inválida'),
ativo: z
.string()
.optional()
.transform((val) => val === 'on'),
})
export type CriarProdutoInput = z.infer<typeof schemaCriarProduto>
// app/actions/produtos.ts
'use server'
import { schemaCriarProduto } from '@/lib/schemas/produto'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { autenticarOuLancarErro } from '@/lib/auth'
type EstadoProduto = {
sucesso: boolean
erros?: Record<string, string[]>
produtoId?: string
}
export async function criarProduto(
estadoAnterior: EstadoProduto,
formData: FormData
): Promise<EstadoProduto> {
// Autenticação e autorização primeiro (mais sobre isso na seção de segurança)
const usuario = await autenticarOuLancarErro()
if (!usuario.roles.includes('admin')) {
return {
sucesso: false,
erros: { _form: ['Você não tem permissão pra criar produtos.'] },
}
}
const dadosBrutos = Object.fromEntries(formData)
const validacao = schemaCriarProduto.safeParse(dadosBrutos)
if (!validacao.success) {
return {
sucesso: false,
erros: validacao.error.flatten().fieldErrors,
}
}
const produto = await db.produto.create({
data: {
...validacao.data,
criadoPorId: usuario.id,
},
})
revalidatePath('/admin/produtos')
revalidatePath('/produtos')
return { sucesso: true, produtoId: produto.id }
}
Dica importante sobre FormData e Zod: O objeto
FormDataretorna tudo como string. Por isso, use.transform()com.pipe()no schema Zod pra converter tipos antes de validar — como no campoprecoque converte string pra número antes de checar se é positivo. Esse padrão vai te salvar de muita dor de cabeça.
Atualizações Otimistas com useOptimistic
Uma das melhores experiências que você pode dar pros seus usuários é o feedback instantâneo. Em vez de esperar o servidor confirmar uma operação, você atualiza a UI na hora e reverte se algo der errado. É o que chamamos de atualização otimista, e o hook useOptimistic do React 19 facilita muito essa implementação.
Confesso que quando testei pela primeira vez, achei a API surpreendentemente simples pro que ela resolve.
// app/tarefas/ListaTarefas.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { concluirTarefa, excluirTarefa } from '@/app/actions/tarefas'
type Tarefa = {
id: string
titulo: string
concluida: boolean
}
export function ListaTarefas({ tarefasIniciais }: { tarefasIniciais: Tarefa[] }) {
const [, startTransition] = useTransition()
const [tarefasOtimistas, adicionarAtualizacaoOtimista] = useOptimistic(
tarefasIniciais,
(estado: Tarefa[], { tipo, id }: { tipo: 'concluir' | 'excluir'; id: string }) => {
if (tipo === 'concluir') {
return estado.map((t) => (t.id === id ? { ...t, concluida: true } : t))
}
if (tipo === 'excluir') {
return estado.filter((t) => t.id !== id)
}
return estado
}
)
function handleConcluir(id: string) {
startTransition(async () => {
adicionarAtualizacaoOtimista({ tipo: 'concluir', id })
await concluirTarefa(id)
})
}
function handleExcluir(id: string) {
startTransition(async () => {
adicionarAtualizacaoOtimista({ tipo: 'excluir', id })
await excluirTarefa(id)
})
}
return (
<ul>
{tarefasOtimistas.map((tarefa) => (
<li
key={tarefa.id}
style={{ opacity: tarefa.concluida ? 0.5 : 1 }}
>
<span style={{ textDecoration: tarefa.concluida ? 'line-through' : 'none' }}>
{tarefa.titulo}
</span>
{!tarefa.concluida && (
<button onClick={() => handleConcluir(tarefa.id)}>
Concluir
</button>
)}
<button onClick={() => handleExcluir(tarefa.id)}>
Excluir
</button>
</li>
))}
</ul>
)
}
O funcionamento é elegante: useOptimistic recebe o estado real e uma função redutora. Quando você chama adicionarAtualizacaoOtimista, o estado otimista é atualizado na hora — o usuário vê o feedback sem demora nenhuma. Se a Server Action falhar, o React automaticamente reverte o estado otimista pro estado real. Se tiver sucesso e o componente revalidar, o estado real vai eventualmente refletir a mudança.
Por que usar
startTransition? OuseOptimisticprecisa que a chamada à Server Action seja feita dentro de uma transição (startTransition) ou dentro de umaactionde formulário. Fora desses contextos, o estado otimista simplesmente não funciona direito.
Revalidação de Cache e Dados
Server Actions são o momento ideal pra invalidar caches depois de uma mutação. Isso integra diretamente com o sistema de cache que exploramos no artigo sobre Cache Components e Partial Prerendering no Next.js 16 — se você ainda não leu, vale muito a pena dar uma olhada antes de continuar aqui.
Existem duas funções principais pra revalidação: revalidatePath e revalidateTag. Vamos ver quando usar cada uma.
Revalidação por Caminho com revalidatePath
A forma mais direta é invalidar tudo que está cacheado em uma rota específica.
// app/actions/blog.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
export async function publicarPost(id: string) {
await db.post.update({
where: { id },
data: { publicadoEm: new Date(), status: 'publicado' },
})
// Invalida a lista de posts
revalidatePath('/blog')
// Invalida a página específica do post
revalidatePath(`/blog/${id}`)
// Invalida o layout (útil se o layout mostra contagem de posts, por ex.)
revalidatePath('/blog', 'layout')
redirect(`/blog/${id}`)
}
Revalidação por Tag com revalidateTag
Quando você usa a diretiva "use cache" com cacheTag, pode invalidar caches de forma granular por tag — independente da rota onde aquele dado aparece. Essa abordagem é mais poderosa e, na minha opinião, bem mais escalável.
// app/components/ListaProdutos.tsx
import { cacheTag } from 'next/cache'
async function ListaProdutos({ categoriaId }: { categoriaId: string }) {
"use cache"
cacheTag('produtos', `categoria-${categoriaId}`)
const produtos = await db.produto.findMany({
where: { categoriaId, ativo: true },
})
return (
<div className="grid">
{produtos.map((p) => (
<ProdutoCard key={p.id} produto={p} />
))}
</div>
)
}
// app/actions/produtos.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function atualizarEstoque(produtoId: string, quantidade: number) {
await db.produto.update({
where: { id: produtoId },
data: { estoque: quantidade },
})
// Invalida TODOS os componentes com a tag 'produtos',
// em qualquer página onde apareçam
revalidateTag('produtos')
}
export async function arquivarCategoria(categoriaId: string) {
await db.categoria.update({
where: { id: categoriaId },
data: { ativa: false },
})
// Invalida apenas os componentes com essa tag específica
revalidateTag(`categoria-${categoriaId}`)
}
A combinação de cacheTag nos componentes e revalidateTag nas Server Actions cria um sistema de invalidação preciso e eficiente. Um produto atualizado invalida exatamente os componentes que mostram aquele produto — sem invalidar caches não relacionados. Bonito, né?
Redirect Após Mutação
O padrão POST/Redirect/GET é uma boa prática clássica que evita reenvios acidentais de formulário. Em Server Actions, o redirect funciona perfeitamente:
// app/actions/carrinho.ts
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function finalizarPedido(formData: FormData) {
const pedidoId = await criarPedidoNoBanco(formData)
revalidatePath('/carrinho')
revalidatePath('/pedidos')
// redirect lança um erro internamente, então deve ser a última chamada
redirect(`/pedidos/${pedidoId}/confirmacao`)
}
Cuidado com try/catch e redirect: O
redirect()funciona lançando um erro internamente. Se você envolver a chamada num blocotry/catchamplo, vai capturar esse erro e o redirect não vai acontecer. A solução? Chame oredirectfora do try/catch, ou useisRedirectErrordonext/navigationpra re-lançar o erro. Já perdi um tempinho debugando isso — fica o aviso.
Segurança: Server Actions Como Endpoints Públicos
Este é, sem dúvida, o tópico mais importante deste guia. E também um dos mais ignorados por quem tá começando com Server Actions.
Vou ser direto: toda função marcada com 'use server' é um endpoint HTTP POST publicamente acessível.
O Next.js gera uma URL única pra cada Server Action (baseada em um hash do código-fonte). Qualquer pessoa com o DevTools ou um cliente HTTP pode descobrir essa URL e fazer uma chamada POST diretamente — sem passar pelo seu componente React, sem JavaScript, sem nenhum contexto de autenticação do frontend. Isso precisa ficar muito claro na sua cabeça.
Nunca Confie no Contexto do Componente
// app/actions/admin.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
// ERRADO: Assume que só admins chegam aqui porque o componente
// só renderiza pra admins. VULNERÁVEL a chamadas diretas!
export async function deletarUsuarioInseguro(userId: string) {
await db.usuario.delete({ where: { id: userId } })
}
// CORRETO: Verifica autorização dentro da própria action
export async function deletarUsuario(userId: string) {
const sessao = await auth()
if (!sessao?.usuario) {
throw new Error('Não autenticado')
}
if (sessao.usuario.role !== 'admin') {
throw new Error('Não autorizado')
}
// Validação adicional: impede que um admin delete a si mesmo
if (sessao.usuario.id === userId) {
throw new Error('Você não pode deletar sua própria conta')
}
await db.usuario.delete({ where: { id: userId } })
}
Padrão de Autenticação Reutilizável
Em vez de repetir a checagem de auth em cada action, crie funções utilitárias. Isso vai te poupar muita repetição (e muitos bugs):
// lib/auth-actions.ts
import { auth } from '@/lib/auth'
export async function autenticarOuLancarErro() {
const sessao = await auth()
if (!sessao?.usuario) {
throw new Error('Você precisa estar logado pra executar esta ação.')
}
return sessao.usuario
}
export async function verificarPermissao(permissao: string) {
const usuario = await autenticarOuLancarErro()
if (!usuario.permissoes.includes(permissao)) {
throw new Error(`Você não tem a permissão: ${permissao}`)
}
return usuario
}
// app/actions/produtos.ts
'use server'
import { verificarPermissao } from '@/lib/auth-actions'
export async function criarProduto(formData: FormData) {
// Uma linha garante autenticação E autorização
await verificarPermissao('produtos:criar')
// ... resto da lógica
}
export async function deletarProduto(id: string) {
await verificarPermissao('produtos:deletar')
// ... resto da lógica
}
CSRF e Valores Bound
O Next.js implementa proteção CSRF automaticamente em Server Actions — ele valida o header Origin das requisições. Chamadas de outros domínios são bloqueadas por padrão, sem configuração adicional da sua parte. Uma coisa a menos pra se preocupar.
Quando você usa .bind() pra passar dados pra uma Server Action (como o ID de um registro), esses valores são criptografados pelo Next.js antes de serem enviados ao cliente. O usuário não consegue inspecionar ou modificar esses valores diretamente — eles chegam como um blob opaco que o Next.js descriptografa no servidor.
Mas atenção: isso não substitui validação e autorização no servidor. Ainda confirme que o usuário tem permissão pra operar sobre aquele ID específico.
Validação e Rate Limiting
// app/actions/comentarios.ts
'use server'
import { autenticarOuLancarErro } from '@/lib/auth-actions'
import { rateLimit } from '@/lib/rate-limit'
import { schemaComentario } from '@/lib/schemas/comentario'
export async function adicionarComentario(
estadoAnterior: unknown,
formData: FormData
) {
const usuario = await autenticarOuLancarErro()
// Rate limiting: máximo 5 comentários por minuto por usuário
const { sucesso } = await rateLimit(`comentarios:${usuario.id}`, {
requests: 5,
window: '1m',
})
if (!sucesso) {
return {
sucesso: false,
erro: 'Você está comentando muito rápido. Aguarde um momento.',
}
}
const validacao = schemaComentario.safeParse(Object.fromEntries(formData))
if (!validacao.success) {
return {
sucesso: false,
erros: validacao.error.flatten().fieldErrors,
}
}
// Sanitize conteúdo HTML antes de salvar
const conteudoSeguro = sanitizarHtml(validacao.data.conteudo)
await db.comentario.create({
data: {
conteudo: conteudoSeguro,
autorId: usuario.id,
postId: validacao.data.postId,
},
})
revalidatePath(`/blog/${validacao.data.postSlug}`)
return { sucesso: true }
}
Padrões Avançados
Upload de Arquivos
Server Actions suportam upload de arquivos nativamente via FormData. O arquivo chega como um objeto File (que implementa a interface Blob), e trabalhar com ele é bem direto.
// app/actions/uploads.ts
'use server'
import { autenticarOuLancarErro } from '@/lib/auth-actions'
import { uploadParaS3 } from '@/lib/s3'
const TIPOS_PERMITIDOS = ['image/jpeg', 'image/png', 'image/webp']
const TAMANHO_MAXIMO = 5 * 1024 * 1024 // 5MB
export async function uploadAvatar(formData: FormData) {
const usuario = await autenticarOuLancarErro()
const arquivo = formData.get('avatar') as File
if (!arquivo || arquivo.size === 0) {
return { sucesso: false, erro: 'Nenhum arquivo enviado' }
}
if (!TIPOS_PERMITIDOS.includes(arquivo.type)) {
return { sucesso: false, erro: 'Tipo de arquivo não permitido. Use JPG, PNG ou WebP.' }
}
if (arquivo.size > TAMANHO_MAXIMO) {
return { sucesso: false, erro: 'Arquivo muito grande. Máximo 5MB.' }
}
const buffer = await arquivo.arrayBuffer()
const nomeArquivo = `avatars/${usuario.id}-${Date.now()}.${arquivo.type.split('/')[1]}`
const url = await uploadParaS3(buffer, nomeArquivo, arquivo.type)
await db.usuario.update({
where: { id: usuario.id },
data: { avatarUrl: url },
})
revalidatePath('/perfil')
return { sucesso: true, url }
}
A Biblioteca next-safe-action
Para projetos maiores, a biblioteca next-safe-action oferece uma API declarativa que resolve de forma elegante os padrões mais comuns: validação com Zod, autenticação, tratamento de erros e tipagem ponta a ponta. Honestamente, em qualquer projeto com mais de meia dúzia de actions, eu já considero usar essa lib.
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth'
export const action = createSafeActionClient()
export const authAction = createSafeActionClient({
async middleware() {
const sessao = await auth()
if (!sessao?.usuario) {
throw new Error('Não autenticado')
}
return { usuario: sessao.usuario }
},
})
// app/actions/pedidos.ts
'use server'
import { authAction } from '@/lib/safe-action'
import { z } from 'zod'
const schemaCriarPedido = z.object({
produtoId: z.string().uuid(),
quantidade: z.number().int().positive(),
enderecoId: z.string().uuid(),
})
export const criarPedido = authAction
.schema(schemaCriarPedido)
.action(async ({ parsedInput, ctx }) => {
const { produtoId, quantidade, enderecoId } = parsedInput
const { usuario } = ctx
const produto = await db.produto.findUnique({ where: { id: produtoId } })
if (!produto || produto.estoque < quantidade) {
throw new Error('Produto indisponível na quantidade solicitada')
}
const pedido = await db.pedido.create({
data: {
clienteId: usuario.id,
produtoId,
quantidade,
enderecoId,
total: produto.preco * quantidade,
},
})
revalidateTag('pedidos')
return { pedidoId: pedido.id }
})
Compondo Múltiplas Actions
Em fluxos complexos como checkout de e-commerce, você pode compor múltiplas operações dentro de uma única Server Action. O segredo aqui é manter tudo numa transação de banco de dados pra garantir consistência:
// app/actions/checkout.ts
'use server'
import { autenticarOuLancarErro } from '@/lib/auth-actions'
import { db } from '@/lib/db'
export async function processarCheckout(formData: FormData) {
const usuario = await autenticarOuLancarErro()
const carrinho = await db.carrinhoItem.findMany({
where: { usuarioId: usuario.id },
include: { produto: true },
})
if (carrinho.length === 0) {
return { sucesso: false, erro: 'Carrinho vazio' }
}
// Tudo em uma transação atômica
const pedido = await db.$transaction(async (tx) => {
// 1. Verificar e reservar estoque
for (const item of carrinho) {
const produto = await tx.produto.findUnique({
where: { id: item.produtoId },
select: { estoque: true },
})
if (!produto || produto.estoque < item.quantidade) {
throw new Error(`Produto "${item.produto.nome}" sem estoque suficiente`)
}
await tx.produto.update({
where: { id: item.produtoId },
data: { estoque: { decrement: item.quantidade } },
})
}
// 2. Criar o pedido
const total = carrinho.reduce(
(acc, item) => acc + item.produto.preco * item.quantidade,
0
)
const novoPedido = await tx.pedido.create({
data: {
clienteId: usuario.id,
total,
status: 'aguardando_pagamento',
itens: {
create: carrinho.map((item) => ({
produtoId: item.produtoId,
quantidade: item.quantidade,
precoUnitario: item.produto.preco,
})),
},
},
})
// 3. Limpar o carrinho
await tx.carrinhoItem.deleteMany({ where: { usuarioId: usuario.id } })
return novoPedido
})
revalidatePath('/carrinho')
revalidatePath('/pedidos')
redirect(`/checkout/pagamento/${pedido.id}`)
}
Error Boundaries com Server Actions
Quando uma Server Action lança um erro não tratado (não retorna um estado de erro, mas lança de fato), o Next.js propaga esse erro para o Error Boundary mais próximo. Configure um error.tsx nos segmentos relevantes pra capturar esses casos:
// app/checkout/error.tsx
'use client'
import { useEffect } from 'react'
export default function CheckoutErro({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log pra seu sistema de monitoramento (Sentry, etc.)
console.error('Erro no checkout:', error)
}, [error])
return (
<div className="erro-container">
<h2>Algo deu errado no checkout</h2>
<p>{error.message || 'Erro inesperado. Tente novamente.'}</p>
<button onClick={reset}>Tentar Novamente</button>
</div>
)
}
Conclusão
Server Actions representam uma evolução significativa na forma como construímos aplicações full-stack com Next.js. Elas eliminam o boilerplate de routes de API pra mutações internas, integram nativamente com formulários HTML pra progressive enhancement, e se conectam de forma coesa com o sistema de cache e revalidação.
Mas, como vimos ao longo deste guia, com essa conveniência vem a responsabilidade de entender o que acontece por baixo dos panos. Server Actions são endpoints públicos — cada função 'use server' é uma URL POST acessível por qualquer cliente. Ignorar isso é abrir brechas de segurança sérias.
Checklist de Boas Práticas
- Autenticação primeiro: Verifique sessão/autenticação no início de toda Server Action que manipula dados sensíveis.
- Autorização explícita: Não assuma que o usuário tem permissão só porque o componente tá numa área restrita.
- Valide tudo com Zod: Nunca confie nos dados do
FormDatasem validar e tipar corretamente no servidor. - Use
useActionStatepra feedback: Retorne estados de erro e sucesso tipados em vez de lançar erros diretamente. - Prefira
revalidateTag: É mais preciso e escalável querevalidatePathquando combinado comcacheTag. - Rate limiting em actions públicas: Especialmente em formulários de contato, comentários e qualquer operação custosa.
- Organize por domínio: Use arquivos separados (
actions/produtos.ts,actions/pedidos.ts) em vez de actions inline pra reutilização. - Transações pra operações complexas: Quando uma action executa múltiplas operações de banco, use transações pra garantir consistência.
- Atualizações otimistas com cuidado: Só aplique quando a probabilidade de sucesso for alta — falhas frequentes criam uma UX confusa.
- Considere
next-safe-action: Em projetos maiores, a biblioteca reduz bastante o boilerplate de validação e autenticação.
O ecossistema continua evoluindo — o React 19 trouxe melhorias significativas com useActionState e useOptimistic, e o Next.js segue expandindo as capacidades das Server Actions a cada versão. Vale ficar de olho nas notas de versão e na documentação oficial.
Com os padrões e técnicas deste guia, você tem uma base sólida pra construir aplicações Next.js robustas, seguras e com ótima experiência do usuário. Boas builds!