Next.js: Server Actions Avançado
Última atualização: 2026-08-26
Quando Server Actions encontram upload de arquivos e atualizações otimistas — os usuários recebem "feedback instantâneo" em vez de "ícones de carregamento girando".
1. O Que Você Vai Aprender
useActionState/startTransition/try-catch— Três Modos de Tratamento de Erros- Implementação do Hook
useOptimisticpara Atualizações Otimistas - Upload de Arquivos via
formDatae Processamento no Servidor - Padrão de transação envolvendo escrita no BD + invalidação de cache + redirecionamento
- Comparação de seleção entre Server Actions e API Routes
2. Uma História Real de uma Engenheira Front-End
(1) Ponto de Dor: Uploads de arquivos levam 8 segundos, fazendo os usuários pensarem que a página travou
Diana está desenvolvendo o recurso "Upload de Anexos" para o TaskFlow. Depois que um usuário seleciona uma imagem de 5 MB:
- O front-end envia
fetchpara/api/upload(upload de 2 segundos) - O servidor usa
sharppara processar miniaturas (3 segundos) - Chama
revalidateTagpara atualizar a lista de anexos (0,5 segundos) - Redireciona de volta para a página de detalhes (0,5 segundos)
- Total: ~6 segundos
Para piorar, não há feedback visual durante esses 6 segundos — os usuários pensam que a página travou e continuam clicando no botão de enviar.
(2) A Solução com Server Actions
Use
useOptimisticpara exibir imediatamente anexos "virtuais" na UI + processamento de upload unificado via Server Action + tratamento de erros com try-catch.
// app/tasks/[id]/Attachments.tsx — Atualização Otimista + Upload de Arquivo
'use client'
import { useOptimistic, useActionState } from 'react'
import { uploadAttachment } from './actions'
export function Attachments({ taskId, initialFiles }: Props) {
const [optimisticFiles, addOptimistic] = useOptimistic(
initialFiles,
(state, newFile: File) => [...state, { id: 'pending', name: newFile.name, url: URL.createObjectURL(newFile), status: 'uploading' }]
)
const [error, formAction, pending] = useActionState(uploadAttachment, null)
return (
<form action={formAction}>
<input type="hidden" name="taskId" value={taskId} />
<input type="file" name="file" onChange={e => {
const file = e.target.files?.[0]
if (file) addOptimistic(file) // Exibir imediatamente na UI
}} />
<button type="submit" disabled={pending}>Enviar</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
<ul>{optimisticFiles.map(f => <li key={f.id}>{f.name} {f.status === 'uploading' ? '⏳' : '✅'}</li>)}</ul>
</form>
)
}
(3) Ganhos
| Dimensão | API Route Tradicional | Server Action |
|---|---|---|
| Latência Percebida | 6 segundos (sem feedback) | Instantâneo (atualização otimista) |
| Número de linhas de código | 120 linhas (API + cliente + validação) | 45 linhas |
| Tratamento de Erros | Requer try-catch manual em toda a cadeia | Centralizado com useActionState |
| Verificação de Tamanho de Arquivo | Cliente + Servidor Separadamente | Consolidado na Server Action |
3. Três Modos de Tratamento de Erros
(1) Padrão useActionState (Recomendado)
// app/error-demo/use-action-state.tsx
'use client'
import { useActionState } from 'react'
async function submitOrder(prevState: any, formData: FormData) {
'use server'
try {
const quantity = Number(formData.get('quantity'))
if (quantity < 1) throw new Error('Quantidade deve ser >= 1')
if (quantity > 100) throw new Error('Quantidade excede o limite')
await db.order.create({ data: { quantity } })
revalidateTag('orders')
return { success: true, message: 'Pedido criado' }
} catch (err: any) {
return { success: false, error: err.message }
}
}
export default function OrderForm() {
const [state, action, pending] = useActionState(submitOrder, null)
return (
<form action={action}>
<input type="number" name="quantity" min={1} disabled={pending} />
<button type="submit" disabled={pending}>{pending ? 'Enviando...' : 'Enviar'}</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>{state.message}</p>}
</form>
)
}
(2) Padrão startTransition
// app/error-demo/transition.tsx
'use client'
import { useTransition } from 'react'
import { submitFeedback } from './actions'
export function FeedbackForm() {
const [isPending, startTransition] = useTransition()
return (
<form onSubmit={e => {
e.preventDefault()
const form = e.currentTarget
const data = new FormData(form)
startTransition(async () => {
try {
await submitFeedback(data)
form.reset()
} catch (err) {
alert(`Erro: ${err}`)
}
})
}}>
<textarea name="feedback" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Enviando...' : 'Enviar Feedback'}
</button>
</form>
)
}
(3) Padrão try-catch (dentro da Server Action)
// app/actions/robust-action.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function robustAction(formData: FormData) {
const id = formData.get('id') as string
// 1. Validação de Parâmetros
if (!id || isNaN(Number(id))) {
throw new Error('ID inválido')
}
// 2. Lógica de Negócio + Tratamento de Erros
try {
await db.item.update({ where: { id: Number(id) }, data: { status: 'processed' } })
revalidatePath('/items')
return { ok: true }
} catch (dbError) {
console.error('Erro no banco de dados:', dbError)
throw new Error('Falha ao atualizar item no banco de dados')
}
}
(1) ▶ Exemplo: Comparação dos Três Padrões de Tratamento de Erros (Dificuldade: ⭐⭐)
Saída:
Server action executes and calls revalidatePath() to refresh the page cache.
// app/error-comparison/page.tsx
import { revalidatePath } from 'next/cache'
// Padrão 1: try-catch dentro da Server Action + Valor de Retorno
async function createItem(formData: FormData) {
'use server'
try {
const name = formData.get('name') as string
if (!name || name.length < 2) return { error: 'Nome muito curto' }
await fetch('https://jsonplaceholder.typicode.com/posts', { method: 'POST', body: JSON.stringify({ title: name }) })
revalidatePath('/error-comparison')
return { success: true }
} catch (err) {
return { error: 'Erro de rede' }
}
}
export default function ErrorComparisonPage() {
return (
<div style={{ display: 'grid', gap: 32, padding: 24 }}>
<section>
<h2>Padrão 1: Server Action retorna estado</h2>
<form action={createItem}>
<input name="name" required />
<button type="submit">Criar</button>
</form>
</section>
</div>
)
}
Saída:
Form fields: name. On submit, processes data and refreshes the page cache.
Visible content: Padrão 1: Server Action retorna estado | Criar
4. useOptimistic: Atualizações Otimistas
useOptimistic é um novo hook no React 19 que permite atualizar imediatamente a UI antes que uma server action seja concluída, e então reverter ou confirmar automaticamente a atualização quando o resultado real for retornado.
sequenceDiagram
participant User
participant UI as UI do Cliente
participant SA as Server Action
participant DB as Banco de Dados
User->>UI: Clica "Concluir Tarefa"
UI->>UI: useOptimistic → Fica cinza imediatamente + marca de seleção
UI->>SA: Chama Server Action
SA->>DB: Atualiza o banco de dados
DB-->>SA: Sucesso
SA-->>UI: Retorna Resultados
UI->>UI: Comparação com a Situação Real → Confirmar ou Reverter
| Status | Exibição na UI | Dado Real | Percepção do Usuário |
|---|---|---|---|
| Atualização de Status | ✅ Concluída (marca de seleção) | ⏳ Em Andamento | Imediata |
| Confirmação do Servidor | ✅ Concluída | ✅ Concluída | Sem alteração |
| Servidor Rejeitou | ❌ Incompleta (Revertida) | ❌ Incompleta | Animação de Reversão |
(2) ▶ Exemplo: Atualização Otimista do Status da Tarefa (Dificuldade ⭐⭐⭐)
Saída:
Sequence diagram showing message flow between participants.
// app/todos/optimistic-list.tsx
'use client'
import { useOptimistic } from 'react'
import { revalidatePath } from 'next/cache'
type Todo = { id: number; title: string; completed: boolean }
export function OptimisticTodoList({ todos: initialTodos }: { todos: Todo[] }) {
const [todos, addOptimistic] = useOptimistic(
initialTodos,
(state, updatedTodo: Todo) =>
state.map(t => t.id === updatedTodo.id ? { ...t, completed: !t.completed } : t)
)
return (
<ul>{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.title}
<form action={async (formData: FormData) => {
'use server'
const id = Number(formData.get('id'))
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH', body: JSON.stringify({ completed: true })
})
revalidatePath('/todos/optimistic')
}} onSubmit={e => {
// Atualização Otimista: Atualizar imediatamente antes de enviar o formulário
addOptimistic({ ...todo, completed: !todo.completed })
}}>
<input type="hidden" name="id" value={todo.id} />
<button type="submit">{todo.completed ? 'Desfazer' : 'Concluir'}</button>
</form>
</li>
))}</ul>
)
}
Saída:
On submit, processes data and refreshes the page cache.
5. Processamento de Upload de Arquivos
Server Actions podem receber arquivos diretamente via formData. O servidor processa o fluxo do arquivo e suporta verificação de tamanho, verificação de tipo e processamento de armazenamento.
(1) Processamento de Arquivos no Servidor
// app/actions/upload.ts
'use server'
import { revalidateTag } from 'next/cache'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
const MAX_SIZE = 5 * 1024 * 1024 // 5 MB
export async function uploadAvatar(prevState: any, formData: FormData) {
const file = formData.get('avatar') as File | null
if (!file) return { error: 'Nenhum arquivo selecionado' }
// Validação de Tipo
if (!ALLOWED_TYPES.includes(file.type)) {
return { error: 'Tipo de arquivo inválido. Permitidos: JPEG, PNG, WebP, PDF' }
}
// Verificação de Tamanho
if (file.size > MAX_SIZE) {
return { error: `Arquivo muito grande. Tamanho máximo: ${MAX_SIZE / 1024 / 1024} MB` }
}
try {
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name.replace(/\s/g, '_')}`
const path = join('public/uploads', filename)
await writeFile(path, buffer)
// Salvar no banco de dados
await db.avatar.create({ data: { filename, path: `/uploads/${filename}` } })
revalidateTag('avatars')
return { success: true, url: `/uploads/${filename}` }
} catch (err) {
return { error: 'Falha ao enviar arquivo' }
}
}
(2) Envio do formulário no lado do cliente
// app/profile/avatar-upload.tsx
'use client'
import { useActionState } from 'react'
import { uploadAvatar } from '@/app/actions/upload'
export function AvatarUpload() {
const [state, action, pending] = useActionState(uploadAvatar, null)
return (
<form action={action}>
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp" disabled={pending} />
<button type="submit" disabled={pending}>
{pending ? 'Enviando...' : 'Enviar Avatar'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && state?.url && (
<div>
<p style={{ color: 'green' }}>Upload bem-sucedido!</p>
<img src={state.url} alt="avatar" style={{ width: 100, height: 100, borderRadius: '50%' }} />
</div>
)}
</form>
)
}
(3) Upload de Múltiplos Arquivos
// app/actions/multi-upload.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function uploadGallery(prevState: any, formData: FormData) {
const files = formData.getAll('photos') as File[]
if (files.length === 0) return { error: 'Nenhum arquivo selecionado' }
if (files.length > 10) return { error: 'Máximo de 10 arquivos permitidos' }
const uploaded: string[] = []
for (const file of files) {
if (file.size > 5 * 1024 * 1024) continue
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
await writeFile(join('public/uploads', filename), buffer)
uploaded.push(`/uploads/${filename}`)
}
revalidateTag('gallery')
return { success: true, urls: uploaded }
}
(3) ▶ Exemplo: Upload Completo de Arquivo + Visualização (Dificuldade: ⭐⭐⭐)
Saída:
Renders the uploadGallery component UI.
// app/upload-demo/page.tsx — Visão Geral do Upload de Arquivos
import { revalidatePath } from 'next/cache'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import fs from 'node:fs'
export default async function UploadDemoPage() {
// Ler a lista de arquivos existentes
const uploadDir = join(process.cwd(), 'public', 'uploads')
const files = fs.existsSync(uploadDir) ? fs.readdirSync(uploadDir) : []
return (
<div style={{ maxWidth: 600, margin: '0 auto', padding: 24 }}>
<h1>Demonstração de Upload de Arquivos</h1>
<form action={async (formData: FormData) => {
'use server'
const file = formData.get('file') as File
if (!file) return
if (file.size > 2 * 1024 * 1024) {
return { error: 'Arquivo muito grande (máx 2MB)' }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
await writeFile(join('public/uploads', filename), buffer)
revalidatePath('/upload-demo')
}}>
<input type="file" name="file" required />
<button type="submit">Enviar</button>
</form>
<div style={{ marginTop: 24 }}>
<h2>Arquivos Enviados ({files.length})</h2>
<ul>{files.map(f => (
<li key={f}>
<a href={`/uploads/${f}`} target="_blank">{f}</a>
</li>
))}</ul>
</div>
</div>
)
}
Saída:
On submit, processes data and refreshes the page cache.
Visible content: Demonstração de Upload de Arquivos
6. Transação: Escrita no BD + Invalidação de Cache + Redirecionamento
Server actions em nível de produção geralmente consistem em três etapas: escrever no banco de dados → limpar o cache → redirecionar o usuário.
sequenceDiagram
participant User
participant Action as Server Action
participant DB as Banco de Dados
participant Cache as Camada de Cache
User->>Action: Envia Formulário
Action->>Action: Validação Zod
Action->>DB: INSERT / UPDATE
DB-->>Action: Sucesso
Action->>Cache: revalidateTag / revalidatePath
Action->>User: redirect para uma nova página/Página de Detalhes
| Etapa | API | Descrição |
|---|---|---|
| Escrita | db.create() / db.update() |
Operações de Banco de Dados com Prisma / Drizzle |
| Expiração de Cache | revalidateTag() / revalidatePath() |
Garante que a página de lista exiba os dados mais recentes |
| Redirecionamento | redirect() |
Redireciona para a página de detalhes ou lista após o envio |
(4) ▶ Exemplo: Modo de Transação Completo (Dificuldade: ⭐⭐⭐)
Saída:
Sequence diagram showing message flow between participants.
// app/posts/create/page.tsx — Escrita + Expiração de Cache + Redirecionamento
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const postSchema = z.object({
title: z.string().min(5).max(200),
content: z.string().min(20),
published: z.coerce.boolean().default(false),
})
export default function CreatePostPage() {
return (
<form action={async (formData: FormData) => {
'use server'
// 1. Validação
const validated = postSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published'),
})
if (!validated.success) return { errors: validated.error.flatten().fieldErrors }
// 2. Escrever no banco de dados
const post = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify({
title: validated.data.title,
body: validated.data.content,
userId: 1,
})
}).then(r => r.json())
// 3. Expiração de Cache
revalidateTag('posts')
revalidatePath('/posts')
// 4. Redirecionar para o novo artigo
redirect(`/posts/${post.id}`)
}}>
<div><input name="title" required placeholder="Título do post" /></div>
<div><textarea name="content" required placeholder="Conteúdo" rows={10} /></div>
<div><label><input name="published" type="checkbox" value="true" /> Publicado</label></div>
<button type="submit">Criar Post</button>
</form>
)
}
(5) ▶ Exemplo: startTransition + Tratamento de Erros no Cliente (Dificuldade ⭐⭐⭐)
Saída:
On submit, processes data server-side and redirects.
// app/newsletter/page.tsx
'use client'
import { useTransition, useState } from 'react'
import { revalidateTag } from 'next/cache'
async function subscribeNewsletter(formData: FormData) {
'use server'
const email = formData.get('email') as string
if (!email || !email.includes('@')) throw new Error('Email inválido')
await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify({ title: email, body: 'Assinatura de newsletter' })
})
revalidateTag('newsletter')
}
export default function NewsletterForm() {
const [isPending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
return (
<form onSubmit={e => {
e.preventDefault()
const form = e.currentTarget
const data = new FormData(form)
setError(null)
startTransition(async () => {
try {
await subscribeNewsletter(data)
form.reset()
} catch (err: any) {
setError(err.message)
}
})
}}>
<input type="email" name="email" required placeholder="seu@email.com" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Assinando...' : 'Assinar'}
</button>
{error && <p style={{ color: 'red' }}>Erro: {error}</p>}
</form>
)
}
7. Server Actions vs API Routes — Seleção
| Dimensão | Server Actions | API Routes |
|---|---|---|
| Método de Chamada | Protocolo RSC Payload | HTTP (REST) |
| Cenários Aplicáveis | Formulários próprios, Ações do Usuário | API de Terceiros, Webhook, Mobile |
| Proteção CSRF | ✅ Integrada | ❌ Deve ser feito manualmente |
| Melhoria Progressiva | ✅ Suportada | ❌ Requer JS |
| Segurança de Tipos | ✅ Tipos TS Completos | ⚠️ Processamento manual |
| Upload de Arquivos | ✅ Processamento Direto com formData | ✅ Processamento de Stream via req |
| Métodos de Autenticação | Função auth() |
Middleware / JWT |
| Limitação de Taxa | ⚠️ Deve ser implementado manualmente | ✅ Processamento Centralizado via Middleware |
| Expiração de Cache | ✅ revalidate integrado | ✅ Pode chamar revalidateTag |
| Dificuldade de Depuração | Baixa (Chamada de Função) | Média (Depuração HTTP) |
(6) ▶ Recomendações de Seleção
Saída:
Completed.
graph TB
A[Que tipo de interface é necessária?] --> B{Quem é o chamador?}
B -->|Chamado diretamente pelo navegador| C{Existe um formulário?}
B -->|Terceiros / Webhook / Mobile| D[API Route]
C -->|Formulário| E[Server Action ✅]
C -->|Não, interação JSON| F{Precisa fazer upload de arquivo?}
F -->|Sim| E
F -->|Não| D
8. Exemplo Completo: Detalhes da Tarefa com Atualizações Otimistas + Upload de Arquivos
// app/tasks/[id]/page.tsx — Server Actions Avançado — Abordagem Completa
import { revalidateTag } from 'next/cache'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { z } from 'zod'
// ======== Esquemas ========
const commentSchema = z.object({
content: z.string().min(1).max(1000),
author: z.string().min(2).max(100),
})
// ======== Página de Detalhes da Tarefa ========
export default async function TaskDetailPage({ params }: { params: { id: string } }) {
const task = await fetch(`https://jsonplaceholder.typicode.com/todos/${params.id}`).then(r => r.json())
const comments = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}/comments`, {
next: { tags: [`comments-${params.id}`] }
}).then(r => r.json())
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
<h1>{task.title}</h1>
<p>Status: {task.completed ? '✅ Concluída' : '⏳ Pendente'}</p>
{/* Alternar estado rapidamente */}
<form action={async (formData: FormData) => {
'use server'
const id = Number(formData.get('id'))
const completed = formData.get('completed') === 'true'
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed: !completed })
})
revalidateTag(`task-${id}`)
}}>
<input type="hidden" name="id" value={params.id} />
<input type="hidden" name="completed" value={String(task.completed)} />
<button type="submit">{task.completed ? 'Marcar Pendente' : 'Marcar Concluída'}</button>
</form>
{/* Adicionar um comentário */}
<h2>Comentários</h2>
<form action={async (formData: FormData) => {
'use server'
const validated = commentSchema.safeParse({
content: formData.get('content'),
author: formData.get('author'),
})
if (!validated.success) return { errors: validated.error.flatten().fieldErrors }
await fetch('https://jsonplaceholder.typicode.com/comments', {
method: 'POST',
body: JSON.stringify({
postId: Number(params.id),
name: validated.data.author,
body: validated.data.content,
email: 'user@example.com',
})
})
revalidateTag(`comments-${params.id}`)
}}>
<div><input name="author" required placeholder="Seu nome" /></div>
<div><textarea name="content" required placeholder="Comentário" /></div>
<button type="submit">Adicionar Comentário</button>
</form>
<ul>{comments.map((c: any) => (
<li key={c.id}><strong>{c.name}:</strong> {c.body}</li>
))}</ul>
{/* Upload de Arquivos */}
<h2>Anexos</h2>
<form action={async (formData: FormData) => {
'use server'
const file = formData.get('file') as File
if (!file || file.size === 0) return { error: 'Nenhum arquivo' }
if (file.size > 5 * 1024 * 1024) return { error: 'Arquivo muito grande' }
const bytes = await file.arrayBuffer()
await writeFile(join('public/uploads', `${Date.now()}-${file.name}`), Buffer.from(bytes))
revalidateTag(`attachments-${params.id}`)
}}>
<input type="file" name="file" />
<button type="submit">Enviar</button>
</form>
</div>
)
}
❓ Perguntas Frequentes
P:
useOptimisticeuseActionStatepodem ser usados juntos? R: Sim.useOptimisticé usado para atualizar a UI antecipadamente, enquantouseActionStateé usado para recuperar o estado final realmente retornado pelo servidor. Um padrão comum é usaruseOptimisticpara gerenciar mudanças imediatas na UI euseActionStatepara confirmar ou reverter após receber a resposta do servidor.
P: Quais tipos de arquivo são suportados para upload em Server Actions? R: Qualquer tipo de arquivo suportado pelos navegadores é aceito. O servidor pode verificar o arquivo lendo
file.type(tipo MIME) efile.size(número de bytes). A abordagem recomendada é salvar o arquivo no sistema de arquivos ou armazenamento em nuvem (S3/R2) e armazenar uma referência de caminho no banco de dados.
P: Como o
redirect()é usado em uma Server Action? R:redirect()deve ser importado denext/navigation. Quando chamado em uma Server Action, ele lança uma exceção especial de redirecionamento. Deve ser chamado fora de um blocotry-catch— se colocado dentro de um blocotry, será capturado pelo blococatch. O padrão correto é: validar → escrever → revalidar → redirecionar (não dentro de um bloco try).
P: Qual é o tempo limite para Server Actions? R: Plano Gratuito Vercel: 10 segundos (Serverless Function), 60 segundos (Pro). Self-hosted: Determinado pela configuração do seu servidor Node.js (ilimitado por padrão). Para operações de longa duração (como processamento de vídeo), recomendamos dividi-las em tarefas assíncronas com callbacks de Webhook, em vez de esperar sincronamente dentro de uma Server Action.
P: Uma Server Action pode chamar a função
set-cookievárias vezes? R: Sim. Use a funçãocookies()para definir o cabeçalho de resposta:const cookieStore = cookies(); cookieStore.set('theme', 'dark'). No entanto, observe que as operações de cookie em uma Server Action entram em vigor em lote; você não pode definir um cookie e lê-lo dentro da mesma action.
P: Em quais cenários você deve usar uma Server Action em vez de uma API Route? R: Existem três cenários onde Server Actions são preferíveis: ① Ações de usuário próprias (envios de formulário, cliques de botão); ② Situações que exigem melhoria progressiva (ainda utilizável mesmo quando o JavaScript está desabilitado); ③ Integração estreita com o sistema de cache RSC (revalidateTag/revalidatePath). As vantagens das API Routes são: ① Integrações de terceiros (aplicativos mobile, webhooks); ② Sem necessidade de contexto de página HTML; ③ Requisito de APIs RESTful padrão.
📖 Resumo
- Três modos de tratamento de erros: useActionState (recomendado), startTransition, try-catch
useOptimisticatualiza a UI em tempo real antes que a Server Action seja concluída, e reverte automaticamente em caso de falha- Para uploads de arquivos, use
formData.get('file') as Filepara recuperar o fluxo do arquivo e verificar o tipo e tamanho do arquivo - Fluxo de transação: validação Zod → escrita no banco de dados → revalidateTag → redirect
- Server Actions são adequadas para operações de formulário próprias, enquanto API Routes são adequadas para integrações de terceiros
- Para uploads de arquivos grandes, recomenda-se dividi-los em tarefas assíncronas para processamento
redirect()não pode ser chamado dentro de um blocotry; deve ser colocado no final da transação
📝 Exercícios
-
Exercício Básico (⭐): Crie um arquivo
app/quick-todo/page.tsxe use uma Server Action inline para adicionar e excluir tarefas. Na Server Action, use um bloco try-catch para tratar erros e retorne{ error: string }para o cliente exibir. -
Exercício Avançado (⭐⭐): Construa uma página
app/gallery/page.tsxde upload de múltiplas imagens que suporte a seleção de até 5 imagens por vez. Na Server Action, valide o tipo de arquivo (apenas imagens) e o tamanho (≤ 2MB por imagem), e exiba uma lista de miniaturas após o upload. Implemente armazenamento comwriteFilee atualização comrevalidatePathna Server Action. -
Desafio (⭐⭐⭐): Implemente uma página de "Quadro de Tarefas" que inclua três colunas de status (A Fazer / Em Andamento / Concluído). Use
useOptimisticpara implementar atualizações de UI em tempo real ao alternar status via arrastar e soltar (não é necessário arrastar de verdade; use botões para alternar). Após uma Server Action ser escrita, atualize automaticamente todas as colunas comrevalidateTag. Adicione um recurso de upload de arquivos a cada cartão de tarefa.