Atualizado em setembro de 2026 para Next.js 16 e React 19. O código usa o App Router e funciona a partir do Next.js 15.
A maioria dos textos sobre React Server Components explica o conceito e para por aí. Aqui a proposta é outra: construir uma tela real, do zero, e esbarrar nos problemas que aparecem quando você sai do exemplo de uma lista só.
A tela é um painel de chamados de suporte: uma lista de tickets que pode ser filtrada por status, um resumo que demora para calcular e um botão para fechar o chamado. São quatro necessidades comuns em qualquer sistema interno, e cada uma delas mostra uma parte diferente do modelo.
O modelo mental em um parágrafo
Um Server Component roda só no servidor. O código dele nunca chega ao navegador. O que chega é o resultado: HTML na primeira carga e um formato serializado da árvore (o RSC payload) nas navegações seguintes. Um Client Component é o React que você já conhece: é renderizado no servidor na primeira carga e depois hidratado no navegador, com estado e eventos.
A diretiva 'use client' não marca um componente, marca uma fronteira de módulo. Tudo que aquele arquivo importa passa a fazer parte do bundle do cliente. Esse detalhe explica metade dos erros da seção final.
Preparando o projeto
npx create-next-app@latest painel-chamados --typescript --app
cd painel-chamados
npm install server-only
O pacote server-only não faz nada em tempo de execução. A função dele é quebrar o build se algum arquivo marcado com ele for parar no bundle do cliente. É a forma mais barata de garantir que código de banco e segredos não vazem.
A camada de dados fica em um único arquivo. Aqui ela usa um array em memória com atraso simulado; no seu projeto, troque pelo ORM ou driver que você já usa.
// lib/chamados.ts
import 'server-only'
export type Status = 'aberto' | 'em_andamento' | 'fechado'
export interface Chamado {
id: number
titulo: string
cliente: string
status: Status
abertoEm: Date
}
// Banco em memória só para o tutorial.
const chamados: Chamado[] = [
{ id: 1, titulo: 'VPN cai a cada 30 minutos', cliente: 'Contabilidade Silva', status: 'aberto', abertoEm: new Date('2026-09-22T12:14:00Z') },
{ id: 2, titulo: 'Backup noturno falhou no servidor de arquivos', cliente: 'Clínica Moura', status: 'em_andamento', abertoEm: new Date('2026-09-22T02:02:00Z') },
{ id: 3, titulo: 'Impressora da recepção offline', cliente: 'Escritório Duarte', status: 'fechado', abertoEm: new Date('2026-09-19T17:40:00Z') },
]
const esperar = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export async function listarChamados(status?: Status) {
await esperar(300)
return status ? chamados.filter((c) => c.status === status) : chamados
}
export async function resumoPorStatus() {
await esperar(2000) // simula uma agregação pesada
const contar = (status: Status) => chamados.filter((c) => c.status === status).length
return {
aberto: contar('aberto'),
em_andamento: contar('em_andamento'),
fechado: contar('fechado'),
}
}
export async function marcarComoFechado(id: number) {
const chamado = chamados.find((c) => c.id === id)
if (chamado) chamado.status = 'fechado'
}
Passo 1: buscar dados sem API e sem useEffect
No App Router, todo componente é Server Component até que se diga o contrário. Isso permite que a página seja async e chame a camada de dados diretamente:
// app/chamados/page.tsx
import { listarChamados } from '@/lib/chamados'
export default async function ChamadosPage() {
const chamados = await listarChamados()
return (
<main>
<h1>Chamados</h1>
<ul>
{chamados.map((c) => (
<li key={c.id}>
<strong>{c.titulo}</strong> · {c.cliente} · {c.status}{' '}
<time dateTime={c.abertoEm.toISOString()}>
{c.abertoEm.toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}
</time>
</li>
))}
</ul>
</main>
)
}
Compare com o jeito antigo: uma rota de API, um useEffect com fetch, um estado de carregamento e um estado de erro. Tudo isso some, porque a busca acontece antes de o HTML ser gerado.
Repare no timeZone. A formatação de data agora roda no servidor, e o servidor de produção quase sempre está em UTC. Sem o fuso explícito, o chamado aberto às 9h14 em Porto Alegre aparece como 12h14. Esse erro não aparece na sua máquina, só em produção.
Passo 2: filtrar pela URL, não pelo estado
O impulso natural é colocar um useState para o filtro. Só que isso obriga a página inteira a virar Client Component, ou obriga você a mandar todos os chamados para o navegador e filtrar lá. Com 3 registros tanto faz; com 5.000, não.
A saída é deixar o filtro na URL. O servidor lê o parâmetro e filtra na consulta, e o único trecho que precisa de JavaScript é o select.
// app/chamados/filtro-status.tsx
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useTransition } from 'react'
const OPCOES = [
{ valor: '', rotulo: 'Todos' },
{ valor: 'aberto', rotulo: 'Abertos' },
{ valor: 'em_andamento', rotulo: 'Em andamento' },
{ valor: 'fechado', rotulo: 'Fechados' },
]
export function FiltroStatus() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [pendente, startTransition] = useTransition()
function aoMudar(valor: string) {
const params = new URLSearchParams(searchParams)
if (valor) params.set('status', valor)
else params.delete('status')
startTransition(() => router.replace(`${pathname}?${params}`))
}
return (
<label>
Status{' '}
<select
defaultValue={searchParams.get('status') ?? ''}
onChange={(e) => aoMudar(e.target.value)}
disabled={pendente}
>
{OPCOES.map((o) => (
<option key={o.valor} value={o.valor}>
{o.rotulo}
</option>
))}
</select>
{pendente && ' carregando…'}
</label>
)
}
Na página, o parâmetro chega em searchParams. A partir do Next.js 15 ele é uma Promise:
// app/chamados/page.tsx
import { listarChamados, type Status } from '@/lib/chamados'
import { FiltroStatus } from './filtro-status'
const STATUS_VALIDOS: Status[] = ['aberto', 'em_andamento', 'fechado']
export default async function ChamadosPage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>
}) {
const { status } = await searchParams
// A URL é entrada do usuário: aceite só valores conhecidos.
const filtro = STATUS_VALIDOS.find((s) => s === status)
const chamados = await listarChamados(filtro)
return (
<main>
<h1>Chamados</h1>
<FiltroStatus />
{/* ...mesma lista do passo 1 */}
</main>
)
}
Você ganha três coisas sem escrever código a mais: o link filtrado pode ser compartilhado, o botão voltar do navegador funciona e o volume de dados enviado continua proporcional ao que aparece na tela. O useTransition mantém a lista antiga visível enquanto a nova é buscada, em vez de piscar uma tela vazia.
Passo 3: não deixar a parte lenta travar a página
O resumo por status leva 2 segundos. Se a página fizer await nele, a página inteira leva 2 segundos para aparecer, inclusive a lista, que ficaria pronta em 300 ms.
A solução é isolar a parte lenta em um componente próprio e envolvê-lo em Suspense:
// app/chamados/resumo.tsx
import { resumoPorStatus } from '@/lib/chamados'
export async function ResumoPorStatus() {
const resumo = await resumoPorStatus()
return (
<dl>
<dt>Abertos</dt>
<dd>{resumo.aberto}</dd>
<dt>Em andamento</dt>
<dd>{resumo.em_andamento}</dd>
<dt>Fechados</dt>
<dd>{resumo.fechado}</dd>
</dl>
)
}
// app/chamados/page.tsx (trecho)
import { Suspense } from 'react'
import { ResumoPorStatus } from './resumo'
// ...dentro do <main>
<Suspense fallback={<p>Calculando resumo…</p>}>
<ResumoPorStatus />
</Suspense>
O servidor envia o HTML da lista primeiro, com o texto do fallback no lugar do resumo, e mantém a conexão aberta. Quando o resumo fica pronto, o restante chega pela mesma resposta e substitui o fallback. Não há requisição extra.
Existe uma armadilha parecida quando você precisa de duas consultas no mesmo componente:
// Sequencial: 300 ms + 2 s = 2,3 s
const chamados = await listarChamados()
const resumo = await resumoPorStatus()
// Paralelo: o tempo da mais lenta, 2 s
const [chamados, resumo] = await Promise.all([listarChamados(), resumoPorStatus()])
Use Promise.all quando as duas informações precisam estar prontas antes de renderizar. Use Suspense quando uma delas pode chegar depois. No painel, a lista é o que a pessoa veio ver, então ela não deve esperar pelo resumo.
Passo 4: fechar o chamado com uma Server Action
Para alterar dados, a página precisa de uma função que rode no servidor e possa ser chamada de um formulário. É isso que a diretiva 'use server' faz:
// app/chamados/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { marcarComoFechado } from '@/lib/chamados'
export async function fecharChamado(id: number) {
// Uma Server Action é um endpoint público: qualquer pessoa pode chamá-la
// com um POST. Confira a permissão aqui dentro, não só na interface.
// const sessao = await auth()
// if (!sessao) throw new Error('Não autorizado')
if (!Number.isInteger(id)) throw new Error('ID inválido')
await marcarComoFechado(id)
revalidatePath('/chamados')
}
O botão precisa saber se o envio está em andamento, e isso exige um Client Component pequeno:
// app/chamados/botao-fechar.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function BotaoFechar() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Fechando…' : 'Fechar'}
</button>
)
}
Na lista, cada chamado aberto ganha um formulário:
// app/chamados/page.tsx (trecho da lista)
import { fecharChamado } from './actions'
import { BotaoFechar } from './botao-fechar'
<li key={c.id}>
{/* ...título, cliente e data */}
{c.status !== 'fechado' && (
<form action={fecharChamado.bind(null, c.id)}>
<BotaoFechar />
</form>
)}
</li>
Três pontos importam aqui:
revalidatePathrefaz a página no servidor. A lista e o resumo voltam atualizados sem você mexer em estado no cliente.- O formulário funciona sem JavaScript. Se o JS ainda não carregou, o navegador envia um POST comum e a action roda do mesmo jeito.
- O
idpassado pelobindviaja pelo navegador. Qualquer pessoa pode trocar esse valor e fechar o chamado de outro cliente. Por isso a validação e a checagem de permissão ficam dentro da action, e nunca apenas no fato de o botão estar ou não visível.
Passo 5: Server Component dentro de Client Component
Suponha que o resumo deva ficar dentro de um painel que a pessoa pode recolher. O painel tem estado, então é Client Component. O reflexo é importar o ResumoPorStatus dentro dele, e é aí que o build quebra:
// Errado: importar um Server Component dentro de um arquivo 'use client'
'use client'
import { ResumoPorStatus } from './resumo' // arrasta lib/chamados para o cliente
Como 'use client' é uma fronteira de módulo, esse import tenta levar resumo.tsx e lib/chamados.ts para o navegador. O server-only interrompe o build nesse ponto, e é exatamente para isso que ele serve.
O jeito certo é o Client Component receber o conteúdo pronto via children:
// app/chamados/painel-recolhivel.tsx
'use client'
import { useState, type ReactNode } from 'react'
export function PainelRecolhivel({ titulo, children }: { titulo: string; children: ReactNode }) {
const [aberto, setAberto] = useState(true)
return (
<section>
<button type="button" onClick={() => setAberto(!aberto)} aria-expanded={aberto}>
{titulo}
</button>
{aberto && children}
</section>
)
}
// app/chamados/page.tsx (trecho)
<PainelRecolhivel titulo="Resumo">
<Suspense fallback={<p>Calculando resumo…</p>}>
<ResumoPorStatus />
</Suspense>
</PainelRecolhivel>
Quem monta a composição é a página, que é Server Component. O painel recebe o resultado já renderizado e só decide se mostra ou esconde.
Se o único comportamento for abrir e fechar, <details> e <summary> resolvem sem JavaScript nenhum. O padrão com children vale quando o Client Component tem estado de verdade: abas controladas, modal, arrastar e soltar.
A página completa
// app/chamados/page.tsx
import { Suspense } from 'react'
import { listarChamados, type Status } from '@/lib/chamados'
import { fecharChamado } from './actions'
import { BotaoFechar } from './botao-fechar'
import { FiltroStatus } from './filtro-status'
import { PainelRecolhivel } from './painel-recolhivel'
import { ResumoPorStatus } from './resumo'
const STATUS_VALIDOS: Status[] = ['aberto', 'em_andamento', 'fechado']
export default async function ChamadosPage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>
}) {
const { status } = await searchParams
const filtro = STATUS_VALIDOS.find((s) => s === status)
const chamados = await listarChamados(filtro)
return (
<main>
<h1>Chamados</h1>
<PainelRecolhivel titulo="Resumo">
<Suspense fallback={<p>Calculando resumo…</p>}>
<ResumoPorStatus />
</Suspense>
</PainelRecolhivel>
<FiltroStatus />
{chamados.length === 0 && <p>Nenhum chamado com esse status.</p>}
<ul>
{chamados.map((c) => (
<li key={c.id}>
<strong>{c.titulo}</strong> · {c.cliente} · {c.status}{' '}
<time dateTime={c.abertoEm.toISOString()}>
{c.abertoEm.toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}
</time>
{c.status !== 'fechado' && (
<form action={fecharChamado.bind(null, c.id)}>
<BotaoFechar />
</form>
)}
</li>
))}
</ul>
</main>
)
}
A estrutura final tem sete arquivos, e só três deles vão para o navegador:
| Arquivo | Onde roda |
|---|---|
lib/chamados.ts | Servidor (protegido por server-only) |
app/chamados/page.tsx | Servidor |
app/chamados/resumo.tsx | Servidor |
app/chamados/actions.ts | Servidor (chamado pelo formulário) |
app/chamados/filtro-status.tsx | Cliente |
app/chamados/botao-fechar.tsx | Cliente |
app/chamados/painel-recolhivel.tsx | Cliente |
Erros comuns e como resolver
| Sintoma | Causa | Correção |
|---|---|---|
Erro dizendo que useState só funciona em Client Component | Hook usado em arquivo sem 'use client' | Adicione a diretiva no componente folha que usa o hook, não no layout |
| "Functions cannot be passed directly to Client Components" | Passou uma função comum como prop de Server para Client | Passe dados, ou transforme a função em Server Action com 'use server' |
Build falha citando server-only | Código de servidor importado, direta ou indiretamente, em arquivo 'use client' | Mova a busca para o Server Component e passe o resultado por props ou children |
Variável de ambiente undefined no navegador | Só variáveis com prefixo NEXT_PUBLIC_ chegam ao cliente | Mantenha o segredo no servidor e exponha só o resultado |
| Página inteira lenta por causa de um trecho | await de consulta lenta no topo da página | Isole o trecho em componente próprio com Suspense |
| Horários com 3 horas de diferença em produção | Data formatada no servidor, que roda em UTC | Informe timeZone: 'America/Sao_Paulo' |
| Bundle do cliente grande mesmo usando RSC | 'use client' no layout.tsx ou em componente alto na árvore | Desça a diretiva até o menor componente interativo |
Sobre props: o que cruza a fronteira de Server para Client precisa ser serializável. Strings, números, booleanos, arrays, objetos simples, Date, Map, Set e até Promise passam. Funções comuns e instâncias de classe não passam.
Quando RSC não resolve
- Interface muito interativa, como editor de texto, planilha ou canvas. Quase tudo ali é estado do cliente, e forçar Server Components só cria fronteiras sem ganho.
- Export estático (
output: 'export'). Os Server Components continuam existindo, mas rodam no build: a busca de dados gera HTML com o retrato daquele momento. Server Actions,searchParamslidos no servidor erevalidatePathnão existem nesse modo. Este site é um exemplo: ele é exportado como estático, e por isso a busca e a paginação do blog rodam no navegador. - Dados que mudam a cada segundo, como cotação ou chat. RSC entrega bem o estado inicial, mas a atualização contínua ainda pede WebSocket ou polling em um Client Component.
Checklist
- A página começa como Server Component e busca dados direto na camada de dados.
- Arquivos com acesso a banco ou segredo importam
server-only. 'use client'fica só nos componentes que usam estado, efeito, evento ou API do navegador.- Filtro, busca e paginação ficam na URL e são validados no servidor.
- Trechos lentos ficam dentro de
Suspense; consultas independentes rodam comPromise.all. - Toda Server Action valida a entrada e confere a permissão por conta própria.
- Datas formatadas no servidor informam o fuso horário.
Com esses sete pontos, o painel entrega a lista em cerca de 300 ms, deixa o resumo chegar sozinho e manda para o navegador, além do runtime do React, só o JavaScript do filtro, do botão e do painel.
