Next.js: Fundamentos de Server Actions
Última atualização: 2026-08-26
Server Actions são o "superpoder" do Next.js — elas permitem que o navegador chame funções do lado do servidor diretamente, sem precisar construir endpoints de API manualmente.
1. O Que Você Vai Aprender
- Como as Diretivas
'use server'e as Server Actions São Definidas - O modo de processamento de formulários com
<form action={}> - Aprimoramento Progressivo: Formulários podem ser enviados mesmo sem JavaScript
revalidatePath()Atualiza os dados da página após o envio- Integração com Zod para validação de parâmetros de formulário
2. Uma História Real de um Desenvolvedor Full-Stack
(1) Ponto de Dor: São necessárias 80 linhas de código para enviar um formulário "simples"
Bob está adicionando um recurso "Criar Projeto" ao TaskFlow. A abordagem tradicional requer:
- Escrever uma rota de API
POST /api/projects(20 linhas) - Escrever uma chamada cliente
fetch()(10 linhas) - Lidar com o token CSRF (10 linhas)
- Lidar com estados de carregamento, erro e sucesso (20 linhas)
- Atualizar dados da página (10 linhas)
- Validação de Entrada (10 linhas)
- Total: ~80 linhas de código
Bob suspirou: "Eu só quero enviar um formulário."
(2) A Solução com Server Actions
Processe formulários diretamente usando uma única função
'use server'— zero sobrecarga de JS no cliente, zero rotas de API.
// app/projects/page.tsx
export default function ProjectsPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const name = formData.get('name') as string
await db.project.create({ data: { name } })
revalidatePath('/projects')
}}>
<input name="name" required placeholder="Nome do projeto" />
<button type="submit">Criar Projeto</button>
</form>
)
}
(3) Resultados
| Dimensão | Rota de API Tradicional | Server Actions |
|---|---|---|
| Linhas de código | 80 linhas | 15 linhas |
| Arquivo de Rota de API | ✅ Arquivos adicionais necessários | ❌ Não necessário |
| Dependências JavaScript | ✅ Necessário | ❌ Aprimoramento Progressivo |
| Proteção CSRF | ❌ Manual | ✅ Integrada |
| Status de Carregamento | ✅ Estado manual necessário | ✅ useActionState |
| Segurança de Tipos | ❌ Frouxa | ✅ Tipos TS Completos |
3. Fluxo de Trabalho Central das Server Actions
sequenceDiagram
participant Browser as Navegador
participant RSC as RSC Payload
participant SA as Server Action
participant DB as Banco de Dados
Browser->>SA: <form action={action}> Enviar
SA->>SA: Marcação em tempo de compilação 'use server'
SA->>DB: Escrita Direta no Banco de Dados
DB-->>SA: Sucesso
SA->>SA: revalidatePath() / revalidateTag()
SA-->>Browser: Retorna RSC Payload (nova UI)
Note over Browser: Sem necessidade de atualizar a página inteira
| Participante | Papel | Descrição |
|---|---|---|
| Navegador | Enviador do formulário | Chamado via <form action> ou JS |
| RSC Payload | Protocolo de Transmissão | Ponte de Serialização Entre Navegador e Servidor |
| Server Action | Manipulador | Função assíncrona marcada com 'use server' |
| Banco de Dados | Persistência de Dados | Prisma / Drizzle ou API Externa |
4. Três Maneiras de Definir Server Actions
Server Action tem dois locais de definição e uma integração com API do React 19:
| Método | Local | Caso de Uso | Exemplo de Código |
|---|---|---|---|
| Arquivo separado | app/actions.ts |
Compartilhado entre várias páginas | export async function createProject(...) |
| Inline | Dentro de um componente <form action={async ...}> |
Operação simples | 'use server' Dentro de uma função |
| useActionState | Client Component | Requer gerenciamento de estado complexo | useActionState(action, initialState) |
(1) Modo de Arquivo Separado (Recomendado)
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function createProject(formData: FormData) {
const name = formData.get('name') as string
const description = formData.get('description') as string
await db.project.create({
data: { name, description }
})
revalidatePath('/projects')
}
export async function deleteProject(id: number) {
await db.project.delete({ where: { id } })
revalidatePath('/projects')
}
// app/projects/page.tsx
import { createProject } from '@/app/actions'
export default function ProjectsPage() {
return (
<form action={createProject}>
<input name="name" required />
<input name="description" />
<button type="submit">Criar</button>
</form>
)
}
(2) Modo Inline (Cenários Simples)
// app/inline-demo/page.tsx — Server Action Inline
export default function InlineDemoPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const message = formData.get('message') as string
console.log('Recebido:', message)
// Processamento Direto, Sem necessidade de arquivos adicionais
}}>
<input name="message" required />
<button type="submit">Enviar</button>
</form>
)
}
(3) Padrão useActionState (Recomendado para Formulários com Estado)
// app/todos/use-action-state.tsx
'use client'
import { useActionState } from 'react'
async function addTodo(prevState: string[], formData: FormData) {
'use server'
const todo = formData.get('todo') as string
await db.todo.create({ data: { title: todo } })
revalidateTag('todos')
return [...prevState, todo]
}
export default function TodoForm() {
const [todos, formAction, isPending] = useActionState(addTodo, [])
return (
<form action={formAction}>
<input name="todo" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Adicionando...' : 'Adicionar Tarefa'}
</button>
<ul>{todos.map((t, i) => <li key={i}>{t}</li>)}</ul>
</form>
)
}
▶ Exemplo: Comparação dos Três Métodos de Definição (Dificuldade: ⭐)
Saída:
Um formulário com campos: todo.
Ao enviar, a server action processa os dados e invalida as tags de cache relevantes.
// app/actions-comparison/page.tsx
import { createTodoInline, createTodoAction } from './actions'
export default function ActionsComparisonPage() {
return (
<div>
<h1>Server Actions — 3 Maneiras</h1>
{/* Método 1: Arquivo Separado */}
<section>
<h2>1. Arquivo separado</h2>
<form action={createTodoAction}>
<input name="title" required />
<button type="submit">Criar</button>
</form>
</section>
{/* Método 2: Inline */}
<section>
<h2>2. Inline</h2>
<form action={async (formData: FormData) => {
'use server'
const title = formData.get('title') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
}}>
<input name="title" required />
<button type="submit">Criar</button>
</form>
</section>
{/* Método 3: Chamada de Client Component */}
<section>
<h2>3. UseActionState (veja abaixo)</h2>
<TodoFormWithState />
</section>
</div>
)
}
Saída:
Campos do formulário: title. Ao enviar, processa os dados do formulário no servidor.
Conteúdo visível: Server Actions — 3 Maneiras | 1. Arquivo separado | Criar
// app/actions-comparison/TodoFormWithState.tsx
'use client'
import { useActionState } from 'react'
import { createTodoAction } from './actions'
export default function TodoFormWithState() {
const [state, action, pending] = useActionState(createTodoAction, null)
return (
<form action={action}>
<input name="title" required disabled={pending} />
<button type="submit">{pending ? 'Criando...' : 'Criar'}</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>Tarefa criada!</p>}
</form>
)
}
// app/actions-comparison/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createTodoAction(prevState: any, formData: FormData) {
const title = formData.get('title') as string
if (!title || title.length < 2) {
return { error: 'O título deve ter pelo menos 2 caracteres' }
}
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
revalidatePath('/actions-comparison')
return { success: true }
}
export async function createTodoInline(formData: FormData) {
const title = formData.get('title') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
revalidatePath('/actions-comparison')
}
5. "action" do Formulário e Aprimoramento Progressivo
O recurso mais importante das Server Actions é o Aprimoramento Progressivo — mesmo que o JavaScript esteja desabilitado no navegador, os formulários ainda podem ser enviados com sucesso.
(1) Comportamento Nativo do Formulário HTML
// app/progressive/page.tsx — Aprimoramento Progressivo: Funciona mesmo com JS desabilitado
export default function ProgressivePage() {
return (
<form action={async (formData: FormData) => {
'use server'
const email = formData.get('email') as string
const message = formData.get('message') as string
await sendEmail({ to: email, body: message })
revalidatePath('/progressive')
}}>
<label>Email: <input name="email" type="email" required /></label>
<label>Mensagem: <textarea name="message" required /></label>
<button type="submit">Enviar</button>
</form>
)
}
| Status | HTML Nativo | + Aprimoramentos JavaScript |
|---|---|---|
| Método de Envio | POST para a URL atual |
Usando fetch + RSC Payload |
| Atualizar Página | Atualiza a Página Inteira | Sem Atualização Completa (Soft Navigation) |
| Experiência do Usuário | Envio de Formulário Tradicional | Envio Sem Tremulação |
| Funcionalidade | ✅ Totalmente disponível | ✅ Experiência aprimorada |
(2) Usando a propriedade action vs. onSubmit
| Método | Requisitos HTML | Requisitos JS | Aprimoramento Progressivo |
|---|---|---|---|
<form action={serverAction}> |
✅ Sem JS necessário | ✅ Aprimorado | ✅ Sim |
<form onSubmit={handler}> |
❌ Requer preventDefault |
✅ Necessário | ❌ Não |
▶ Exemplo: Teste de Cenário com JS Desabilitado (Dificuldade ⭐⭐)
Saída:
Um formulário com campos de entrada e um botão de envio.
Ao enviar, a server action processa os dados e atualiza o cache da página.
// app/no-js-demo/page.tsx — Testando Aprimoramento Progressivo
export default function NoJsDemoPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const item = formData.get('item') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title: item, completed: false })
})
revalidatePath('/no-js-demo')
}}>
<input name="item" required placeholder="Digite um item de tarefa" />
<button type="submit">Adicionar Tarefa</button>
</form>
)
}
Saída:
Um formulário com campos de entrada e um botão de envio.
Ao enviar, a server action processa os dados e atualiza o cache da página.
Método de Teste:
- Uso normal: Digite o texto → Clique em "Enviar" → A lista é atualizada
- Desabilitar JavaScript: DevTools → Configurações → Desabilitar JavaScript → Atualizar → Enviar o formulário → Ainda funciona
6. Integração com Validação Zod
Server Actions devem sempre validar os dados de entrada. Zod é a biblioteca de validação mais popular no ecossistema TypeScript.
(1) Modo de Verificação Básica
// app/actions/schema.ts
import { z } from 'zod'
export const projectSchema = z.object({
name: z.string().min(2, 'Nome deve ter pelo menos 2 caracteres').max(100),
description: z.string().max(500).optional(),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de data inválido').optional(),
})
// app/actions/create.ts
'use server'
import { revalidatePath } from 'next/cache'
import { projectSchema } from './schema'
export async function createProject(prevState: any, formData: FormData) {
const validated = projectSchema.safeParse({
name: formData.get('name'),
description: formData.get('description'),
dueDate: formData.get('dueDate'),
})
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }
}
try {
await db.project.create({ data: validated.data })
revalidatePath('/projects')
return { success: true }
} catch (err) {
return { error: 'Falha ao criar projeto' }
}
}
(2) O cliente exibe um erro de validação
// app/projects/CreateProjectForm.tsx
'use client'
import { useActionState } from 'react'
import { createProject } from '@/app/actions/create'
export function CreateProjectForm() {
const [state, action, pending] = useActionState(createProject, null)
return (
<form action={action}>
<div>
<input name="name" required placeholder="Nome do projeto" disabled={pending} />
{state?.errors?.name && <p style={{ color: 'red' }}>{state.errors.name[0]}</p>}
</div>
<div>
<textarea name="description" placeholder="Descrição" disabled={pending} />
</div>
<div>
<input name="dueDate" type="date" disabled={pending} />
{state?.errors?.dueDate && <p style={{ color: 'red' }}>{state.errors.dueDate[0]}</p>}
</div>
<button type="submit" disabled={pending}>
{pending ? 'Criando...' : 'Criar Projeto'}
</button>
{state?.success && <p style={{ color: 'green' }}>Projeto criado!</p>}
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
</form>
)
}
▶ Exemplo: Formulário Completo com Validação Zod (Dificuldade: ⭐⭐)
Saída:
Um formulário com atualizações otimistas de UI usando useActionState.
Texto visível: } | } | Projeto criado! | }
{state?.error &&
// app/zod-demo/page.tsx — Verificação Zod + Server Actions
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const contactSchema = z.object({
name: z.string().min(2),
email: z.string().email('Email inválido'),
message: z.string().min(10, 'Mensagem muito curta').max(1000),
})
export default function ZodDemoPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const validated = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
})
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }
}
console.log('Formulário de contato enviado:', validated.data)
revalidatePath('/zod-demo')
return { success: true }
}}>
<div style={{ marginBottom: 12 }}>
<label>Nome: <input name="name" required /></label>
</div>
<div style={{ marginBottom: 12 }}>
<label>Email: <input name="email" type="email" required /></label>
</div>
<div style={{ marginBottom: 12 }}>
<label>Mensagem: <textarea name="message" required /></label>
</div>
<button type="submit">Enviar Contato</button>
</form>
)
}
Saída:
Campos do formulário: name, email. Ao enviar, processa os dados e atualiza o cache da página.
Conteúdo visível: Nome: | Email: | Mensagem:
▶ Exemplo: revalidatePath para atualizar a lista (Dificuldade: ⭐)
Saída:
O componente renderiza a UI descrita no navegador.
// app/todos/page.tsx — Atualiza a lista após o envio
import { revalidatePath } from 'next/cache'
export default async function TodosPage() {
const todos = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5', {
next: { tags: ['todos'] }
}).then(r => r.json())
return (
<div>
<h1>Tarefas</h1>
<ul>{todos.map((t: any) => <li key={t.id}>{t.title}</li>)}</ul>
<form action={async (formData: FormData) => {
'use server'
const title = formData.get('title') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
revalidatePath('/todos')
}}>
<input name="title" required />
<button type="submit">Adicionar</button>
</form>
</div>
)
}
Saída:
Um formulário com campos de entrada e um botão de envio.
Ao enviar, a server action processa os dados e atualiza o cache da página.
Texto visível: Tarefas
▶ Exemplo: Passando Parâmetros Adicionais Usando bind (Dificuldade: ⭐⭐)
Saída:
A busca de dados no lado do servidor renderiza uma lista de itens.
Conteúdo: Tarefas
Server Action pode ser usada em conjunto com a propriedade action do <form> e o método .bind() para passar parâmetros adicionais.
// app/bind-demo/page.tsx
import { revalidatePath } from 'next/cache'
export default async function BindDemoPage() {
const todos = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5', {
next: { tags: ['bind-todos'] }
}).then(r => r.json())
return (
<div>
<h1>Lista de Tarefas com Bind</h1>
<ul>{todos.map((t: any) => (
<li key={t.id}>
{t.title}
<form action={completeTodo.bind(null, t.id)} style={{ display: 'inline' }}>
<button type="submit">{t.completed ? '✅' : '⬜'}</button>
</form>
</li>
))}</ul>
</div>
)
}
async function completeTodo(id: number, formData: FormData) {
'use server'
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH', body: JSON.stringify({ completed: true })
})
revalidatePath('/bind-demo')
}
7. Exemplo Completo: Sistema de Gerenciamento de Tarefas
// app/tasks/page.tsx — Exemplo Abrangente de Server Actions
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
// ======== Schema ========
const taskSchema = z.object({
title: z.string().min(1, 'Título obrigatório').max(200),
priority: z.enum(['low', 'medium', 'high']),
assignee: z.string().min(1, 'Responsável obrigatório'),
})
// ======== Lista de Tarefas ========
export default async function TasksPage() {
const tasks = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=10', {
next: { tags: ['tasks'] }
}).then(r => r.json())
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
<h1>Gerenciador de Tarefas</h1>
<AddTaskForm />
<div style={{ marginTop: 24 }}>
{tasks.map((t: any) => (
<div key={t.id} style={{ border: '1px solid #ddd', borderRadius: 8, padding: 12, marginBottom: 8 }}>
<span>{t.title}</span>
<form action={deleteTask} style={{ display: 'inline', marginLeft: 12 }}>
<input type="hidden" name="id" value={t.id} />
<button type="submit" style={{ color: 'red' }}>Excluir</button>
</form>
</div>
))}
</div>
</div>
)
}
// ======== Formulário de Nova Tarefa ========
function AddTaskForm() {
return (
<form action={createTask} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input name="title" required placeholder="Título da tarefa" />
<select name="priority" defaultValue="medium">
<option value="low">Baixa</option>
<option value="medium">Média</option>
<option value="high">Alta</option>
</select>
<input name="assignee" required placeholder="Responsável" />
<button type="submit">Adicionar Tarefa</button>
</form>
)
}
// ======== Server Actions ========
async function createTask(formData: FormData) {
'use server'
const validated = taskSchema.safeParse({
title: formData.get('title'),
priority: formData.get('priority'),
assignee: formData.get('assignee'),
})
if (!validated.success) {
console.error('Falha na validação:', validated.error.flatten())
return
}
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title: validated.data.title, completed: false })
})
revalidatePath('/tasks')
}
async function deleteTask(formData: FormData) {
'use server'
const id = formData.get('id') as string
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, { method: 'DELETE' })
revalidatePath('/tasks')
}
❓ Perguntas Frequentes
P: Qual é a diferença entre uma Server Action e uma Rota de API tradicional? R: Uma Server Action é uma função do lado do servidor que é chamada diretamente pelo navegador (via protocolo RSC Payload); não requer um endpoint HTTP, lida automaticamente com CSRF e suporta aprimoramento progressivo. Rotas de API são endpoints HTTP, adequados para integrações de terceiros, webhooks e clientes que não são navegadores. As duas não são intercambiáveis — Server Actions lidam com formulários próprios, enquanto Rotas de API lidam com APIs de terceiros.
P: O
useActionStatesó pode ser usado em Client Components? R: Sim.useActionStateé um hook do lado do cliente no React 19 que requer'use client'. Ele gerencia estados de formulário (pending, error, success) no lado do cliente. Server Components puros podem usar<form action={async} >no modo inline.
P: Como as Server Actions previnem ataques CSRF? R: O Next.js 16 inclui proteção CSRF integrada. Server Actions só podem ser invocadas via protocolo RSC Payload (não podem ser simuladas usando uma requisição HTTP POST comum). O framework verifica automaticamente se a origem da requisição corresponde ao cookie, então não há necessidade de adicionar manualmente um token CSRF.
P: Uma Server Action inline pode referenciar props de um componente? R: Não. Uma
'use server'inline é uma função de módulo independente marcada em tempo de compilação — ela não pode acessar variáveis dentro de um closure. Se você precisar usar props ou estado de um componente, deve passá-los viaformDataou defini-los em um arquivoactions.tsseparado.
P: Os parâmetros da Server Action podem ser de qualquer tipo? R: Os parâmetros da Server Action devem ser serializáveis (de acordo com as regras de Props RSC). FormData, objetos comuns, arrays, strings e números são suportados. Funções, Date, undefined e Symbol não são suportados. Se você precisar de parâmetros complexos, passe-os via
formDataou serialização JSON.
P: Como o cliente obtém o valor de retorno de uma Server Action? R: O valor de retorno é serializado de volta ao cliente via RSC Payload. Em
<form action={action}>, o valor de retorno é ignorado. Para obter o valor de retorno, você deve usaruseActionState(action, initialState)oustartTransition+ uma chamada explícitacallAction(). Recomendamos usaruseActionStatepara gerenciar valores de retorno.
📖 Resumo
- Server Action é definida usando a diretiva
'use server'e suporta três métodos: arquivos independentes, inline e useActionState <form action={action}>é o método mais recomendado para envio de formulário e suporta nativamente aprimoramento progressivo- Aprimoramento Progressivo permite que formulários ainda sejam enviados via POST nativo mesmo quando o JavaScript está desabilitado
revalidatePath()erevalidateTag()são usados na Server Action para atualizar dados em cache após o envio- A integração com Zod implementa validação type-safe dos parâmetros da Server Action
- Server Actions incluem proteção CSRF integrada; não há necessidade de adicionar manualmente um token
- Use
useActionState(action, initialState)para gerenciar itens pendentes, valores de retorno e status de erro
📝 Exercícios
-
Exercício Básico (⭐): Crie um mural de recados
app/guestbook/page.tsx. Use uma Server Action inline para processar envios de formulário, exiba o conteúdo da mensagem usandoconsole.log(para simular uma escrita no banco de dados) e atualize a página comrevalidatePathapós o envio. -
Exercício Avançado (⭐⭐): Implemente um sistema de gerenciamento de tarefas com validação Zod em
app/todos-zod/page.tsx. Requisitos do schema: título (1–100 caracteres), prioridade (enumeração: low/medium/high), dueDate (opcional, formato YYYY-MM-DD). Exiba erros de validação específicos do campo abaixo do formulário. -
Desafio (⭐⭐⭐): Construa um módulo completo de "Criação de Projeto":
app/projects/create/page.tsxinclui um formulário com múltiplos campos (nome, descrição, data de entrega, lista de membros da equipe); defina server actions CRUD emapp/actions/projects.ts; useuseActionStatepara gerenciar o status de envio (sucesso/erro/pendente) no lado do cliente; e suporte entrada e visualização de URL de imagem.