Server Actions no Next.js: Guia Prático com Segurança, Formulários e Padrões Avançados

Domine Server Actions no Next.js: desde formulários com progressive enhancement até padrões avançados como useActionState, useOptimistic, validação com Zod e segurança de endpoints públicos.

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 FormData retorna tudo como string. Por isso, use .transform() com .pipe() no schema Zod pra converter tipos antes de validar — como no campo preco que 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? O useOptimistic precisa que a chamada à Server Action seja feita dentro de uma transição (startTransition) ou dentro de uma action de 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 bloco try/catch amplo, vai capturar esse erro e o redirect não vai acontecer. A solução? Chame o redirect fora do try/catch, ou use isRedirectError do next/navigation pra 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 FormData sem validar e tipar corretamente no servidor.
  • Use useActionState pra feedback: Retorne estados de erro e sucesso tipados em vez de lançar erros diretamente.
  • Prefira revalidateTag: É mais preciso e escalável que revalidatePath quando combinado com cacheTag.
  • 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!

Sobre o Autor Editorial Team

Our team of expert writers and editors.