React Server Components na Prática: Um Painel de Chamados com Next.js
Desenvolvimento Web

React Server Components na Prática: Um Painel de Chamados com Next.js

Tutorial passo a passo: buscar dados direto no servidor, filtrar pela URL, carregar a parte lenta com streaming e fechar chamados com Server Actions. Inclui os erros que aparecem no caminho e como resolver cada um.

Carlos Mendes
14 de março de 2024
14 min de leitura
CM

Carlos Mendes

Engenheiro de Software Sênior especializado em React e Next.js.

ReactNext.jsServer ComponentsServer ActionsTutorial

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:

  • revalidatePath refaz 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 id passado pelo bind viaja 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:

ArquivoOnde roda
lib/chamados.tsServidor (protegido por server-only)
app/chamados/page.tsxServidor
app/chamados/resumo.tsxServidor
app/chamados/actions.tsServidor (chamado pelo formulário)
app/chamados/filtro-status.tsxCliente
app/chamados/botao-fechar.tsxCliente
app/chamados/painel-recolhivel.tsxCliente

Erros comuns e como resolver

SintomaCausaCorreção
Erro dizendo que useState só funciona em Client ComponentHook 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 ClientPasse dados, ou transforme a função em Server Action com 'use server'
Build falha citando server-onlyCó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 navegadorSó variáveis com prefixo NEXT_PUBLIC_ chegam ao clienteMantenha o segredo no servidor e exponha só o resultado
Página inteira lenta por causa de um trechoawait de consulta lenta no topo da páginaIsole o trecho em componente próprio com Suspense
Horários com 3 horas de diferença em produçãoData formatada no servidor, que roda em UTCInforme timeZone: 'America/Sao_Paulo'
Bundle do cliente grande mesmo usando RSC'use client' no layout.tsx ou em componente alto na árvoreDesç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, searchParams lidos no servidor e revalidatePath nã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

  1. A página começa como Server Component e busca dados direto na camada de dados.
  2. Arquivos com acesso a banco ou segredo importam server-only.
  3. 'use client' fica só nos componentes que usam estado, efeito, evento ou API do navegador.
  4. Filtro, busca e paginação ficam na URL e são validados no servidor.
  5. Trechos lentos ficam dentro de Suspense; consultas independentes rodam com Promise.all.
  6. Toda Server Action valida a entrada e confere a permissão por conta própria.
  7. 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.

Newsletter Exclusiva

Fique por Dentro das Inovações

Receba insights exclusivos sobre transformação digital, tendências tecnológicas e estratégias de crescimento diretamente no seu e-mail.

📊

Insights Semanais

Análises e tendências do mercado tech

🚀

Cases de Sucesso

Histórias reais de transformação digital

🎯

Conteúdo Exclusivo

Materiais disponíveis apenas para assinantes

Ao se inscrever, você concorda em receber e-mails da LineNetwork. Você pode cancelar a inscrição a qualquer momento.

Junte-se a mais de 2.500+ profissionais que já recebem nossos insights

⭐⭐⭐⭐⭐4.9/5 de satisfação

Privacidade e cookies

Utilizamos cookies para garantir o funcionamento correto do site e personalizar a sua experiência. Você pode aceitar todos, recusar os opcionais ou ajustar as preferências de acordo com a LGPD. Saiba mais na Política de Privacidade.