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



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:

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.

TSX
// 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

100%
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)

TS
// 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')
}
TSX
// 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)

TSX
// 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)

TSX
// 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:

TEXT 📖 Somente leitura
Um formulário com campos: todo.
Ao enviar, a server action processa os dados e invalida as tags de cache relevantes.
TSX
// 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:

TEXT 📖 Somente leitura
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
TSX
// 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>
  )
}
TS
// 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

TSX
// 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:

TEXT 📖 Somente leitura
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.
TSX
// 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:

TEXT 📖 Somente leitura
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:

  1. Uso normal: Digite o texto → Clique em "Enviar" → A lista é atualizada
  2. 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

TS
// 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(),
})
TS
// 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

TSX
// 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:

TEXT 📖 Somente leitura
Um formulário com atualizações otimistas de UI usando useActionState.
Texto visível: } | } | Projeto criado! | }
      {state?.error &&
TSX
// 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:

TEXT 📖 Somente leitura
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:

TEXT 📖 Somente leitura
O componente renderiza a UI descrita no navegador.
TSX
// 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:

TEXT 📖 Somente leitura
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:

TEXT 📖 Somente leitura
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.

TSX
// 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

TSX
// 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 useActionState só 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 via formData ou defini-los em um arquivo actions.ts separado.

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 formData ou 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 usar useActionState(action, initialState) ou startTransition + uma chamada explícita callAction(). Recomendamos usar useActionState para gerenciar valores de retorno.


📖 Resumo


📝 Exercícios

  1. 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 usando console.log (para simular uma escrita no banco de dados) e atualize a página com revalidatePath após o envio.

  2. 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.

  3. Desafio (⭐⭐⭐): Construa um módulo completo de "Criação de Projeto": app/projects/create/page.tsx inclui um formulário com múltiplos campos (nome, descrição, data de entrega, lista de membros da equipe); defina server actions CRUD em app/actions/projects.ts; use useActionState para gerenciar o status de envio (sucesso/erro/pendente) no lado do cliente; e suporte entrada e visualização de URL de imagem.

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%