React Server Components: o que roda no servidor
A fronteira entre componente de servidor e de cliente, o que o use client marca de verdade e o que chega ao navegador — com o payload real.
Um React Server Component roda uma vez, no servidor, e nunca é enviado para o
navegador. O que desce pelo fio não é o componente: é o resultado dele, num
formato de texto próprio que não é HTML nem JSON. Só o que você marca com
use client vira JavaScript baixado, hidratado e interativo.
Para não explicar isso no chute, montei a Clínica Pata Amiga — uma agenda de consultas com banco em SQLite — usando React 19.2.8 puro, sem Next.js e sem framework nenhum. Todo payload, erro e byte deste artigo saiu dessa bancada, num Node 24.16.0 rodando num MacBook com Apple M4 Pro.
A cozinha manda o prato, não o fogão
Num restaurante, a cozinha prepara o prato e manda o resultado para a mesa. O cliente não recebe forno, panela e receita para repetir o preparo. Mas o botão de chamar o garçom precisa funcionar na mesa, então essa parte interativa fica do lado do cliente.
Um React Server Component executa no servidor e envia uma descrição
serializada do resultado; um Client Component leva JavaScript para ser
hidratado no navegador. A fronteira use client separa esses trabalhos. Na
bancada abaixo, compare os arquivos que entram no bundle com o payload recebido:
o componente ausente do JavaScript do cliente é a prova verificável de onde ele
rodou.
A agenda da clínica é uma tela simples: lista as consultas do dia e, em cada linha, um botão que a recepção clica para confirmar. Os dados vêm do banco.
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE consulta (
id INTEGER PRIMARY KEY,
pet TEXT NOT NULL,
tutor TEXT NOT NULL,
horario TEXT NOT NULL,
veterinario TEXT NOT NULL
);
INSERT INTO consulta (pet, tutor, horario, veterinario) VALUES
('Nina', 'Ana Prado', '08:30', 'Dra. Helena'),
('Thor', 'Bruno Lima', '09:15', 'Dr. Marcelo'),
('Bidu', 'Carla Nunes', '10:00', 'Dra. Helena'),
('Pipoca','Diego Castro', '11:20', 'Dr. Marcelo');
`);
export function listarConsultasDoDia() {
return db
.prepare('SELECT id, pet, tutor, horario, veterinario FROM consulta ORDER BY horario')
.all();
}São essas quatro consultas que você vai reconhecer em toda saída daqui para baixo — Nina, Thor, Bidu e Pipoca.
No React de sempre, esse SELECT não pode acontecer no componente: o navegador
não fala com o banco. A saída conhecida é
consumir uma API no React — um useEffect, um
estado de carregando, um estado de erro e uma rota no back-end só para devolver
essa lista em JSON.
Com Server Components, o componente é o back-end daquela tela:
import { listarConsultasDoDia } from './banco.js';
import BotaoConfirmar from './BotaoConfirmar.js';
export default function AgendaDoDia() {
const consultas = listarConsultasDoDia();
return (
<section>
<h2>Agenda de hoje — Clínica Pata Amiga</h2>
<ul>
{consultas.map((c) => (
<li key={c.id}>
<strong>{c.horario}</strong> — {c.pet} ({c.tutor}) com {c.veterinario}
<BotaoConfirmar consultaId={c.id} pet={c.pet} />
</li>
))}
</ul>
</section>
);
}E o botão, que precisa de clique e de estado, carrega a diretiva na primeira linha:
'use client';
import { useState } from 'react';
export default function BotaoConfirmar({ consultaId, pet }) {
const [confirmada, setConfirmada] = useState(false);
return (
<button type="button" onClick={() => setConfirmada(true)}>
{confirmada ? `${pet} confirmado` : `Confirmar ${pet}`}
</button>
);
}Dois arquivos, duas máquinas. A pergunta que este artigo responde é: o que exatamente acontece na linha que separa um do outro?
A bancada: quem faz o papel do bundler
O React resolve isso em duas camadas, e entender a divisão evita atribuir ao Next.js coisa que é do React — e vice-versa.
A primeira camada é a condição de exportação react-server. Quando o Node
resolve o pacote react com essa condição ligada, ele carrega um build
diferente do React. É por isso que a bancada roda em dois passos:
node preparar.mjs
NODE_ENV=production node --conditions=react-server gerar-payload.mjsA segunda camada é o bundler — o preparar.mjs do primeiro passo. É ele que
lê a diretiva use client, e é ele que faz a única coisa que realmente importa:
trocar o módulo do componente de cliente por uma referência. O bundler
inteiro da bancada cabe em trinta e seis linhas, e este é ele por completo:
// Faz o papel do bundler: transforma JSX e troca todo módulo com 'use client'
// por uma referência de cliente (o "buraco" que o servidor não renderiza).
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { transform } from 'esbuild';
mkdirSync('build', { recursive: true });
const arquivos = readdirSync('src');
const manifesto = {};
for (const arquivo of arquivos) {
const fonte = readFileSync(`src/${arquivo}`, 'utf8');
const saida = `build/${arquivo.replace(/\.jsx$/, '.js')}`;
if (/^['"]use client['"]/.test(fonte.trim())) {
const id = `./src/${arquivo}`;
manifesto[`${id}#default`] = {
id: `cliente/${arquivo.replace(/\.jsx$/, '.js')}`,
chunks: ['cliente', 'cliente.js'],
name: 'default',
};
writeFileSync(saida, `import { registerClientReference } from 'react-server-dom-webpack/server';
export default registerClientReference(
function () { throw new Error('Componente de cliente não roda no servidor'); },
${JSON.stringify(id)},
'default'
);`);
continue;
}
const { code } = await transform(fonte, { loader: 'jsx', jsx: 'automatic', format: 'esm' });
writeFileSync(saida, code);
}
writeFileSync('build/manifesto.json', JSON.stringify(manifesto, null, 2));
console.log('manifesto do cliente:', JSON.stringify(manifesto, null, 2));Todo arquivo comum é só transpilado de JSX para JavaScript. O arquivo com a diretiva é descartado e substituído.
Repare no que sobrou do BotaoConfirmar do lado do servidor: nenhuma linha do
componente. Nem o useState, nem o onClick, nem o JSX.
import { registerClientReference } from 'react-server-dom-webpack/server';
export default registerClientReference(
function () { throw new Error('Componente de cliente não roda no servidor'); },
"./src/BotaoConfirmar.jsx",
'default'
);O que desce pelo fio: nem HTML, nem JSON
Com a bancada montada, renderizar a agenda é uma chamada só:
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToReadableStream } from 'react-server-dom-webpack/server';
import AgendaDoDia from './build/AgendaDoDia.js';
const manifesto = JSON.parse(readFileSync('./build/manifesto.json', 'utf8'));
const stream = renderToReadableStream(createElement(AgendaDoDia), manifesto);
process.stdout.write(await new Response(stream).text());E isto é a saída completa, byte por byte, do que o servidor produz:
São 909 bytes em duas linhas. Vale ler devagar, porque quase tudo que importa está aí.
Cada linha começa com um identificador e dois-pontos. A linha 1:I[...] é uma
declaração de módulo: “o número 1 é o componente de cliente que vive no arquivo
cliente/BotaoConfirmar.js”. A linha 0: é a árvore.
Dentro da árvore, cada elemento é um array de quatro posições:
["$", tipo, key, props]. O "$" na frente é o carimbo de “isto é um elemento
React”. Então ["$","strong",null,{"children":"08:30"}] é exatamente o
<strong>08:30</strong> que você escreveria.
Agora a parte que muda tudo. Onde estava o <BotaoConfirmar>, o payload traz:
["$","$L1",null,{"consultaId":1,"pet":"Nina"}]O tipo do elemento não é uma tag nem uma função: é "$L1" — uma referência
preguiçosa para o módulo declarado na linha 1. O servidor não renderizou o
botão. Ele deixou um buraco com o endereço de quem vai preenchê-lo, e passou
as props junto.
Isso não é JSON, embora se pareça. É um formato de linhas em que cada uma pode
chegar num momento diferente, e em que prefixos como $ e $L têm significado.
Se você jogar essa string num
JSON.parse, ele quebra na primeira linha.
O mecanismo, num desenho
O que use client marca — e o que ele não marca
Duas confusões aparecem sempre, e as duas dão para desmontar com execução.
A primeira: use client não quer dizer “roda só no navegador”. Quer dizer
“a partir daqui, o código também vai para o navegador”. Ele continua rodando no
servidor durante a renderização de HTML — que é uma etapa separada. A seção
sobre SSR, mais adiante, mostra isso acontecendo.
A segunda: a diretiva marca a porta, não a sala. Um componente de cliente
pode receber um Server Component como children. O Painel é de cliente: ele
tem estado e um botão que abre e fecha.
'use client';
import { useState } from 'react';
export default function Painel({ titulo, children }) {
const [aberto, setAberto] = useState(true);
return (
<div>
<button type="button" onClick={() => setAberto(!aberto)}>{titulo}</button>
{aberto && children}
</div>
);
}E quem monta a lista da agenda dentro dele é um Server Component:
import { listarConsultasDoDia } from './banco.js';
import Painel from './Painel.js';
export default function AgendaDentroDoPainel() {
const consultas = listarConsultasDoDia();
return (
<Painel titulo="Agenda de hoje">
<ul>
{consultas.slice(0, 2).map((c) => (
<li key={c.id}>{c.horario} — {c.pet} com {c.veterinario}</li>
))}
</ul>
</Painel>
);
}A <ul> com os horários viajou dentro das props do componente de cliente,
já renderizada. O Painel vai receber isso pronto e só decidir se mostra ou
esconde. Quem escreveu o Painel não precisa saber que aquilo veio do banco.
É por isso que a regra prática é empurrar o use client para as folhas da
árvore, e passar o resto por
children e composição. Marcar o layout
inteiro como cliente arrasta tudo que está dentro dele junto.
Server Component não tem estado, efeito nem evento
Aqui a maioria dos textos diz “não pode usar hooks”. A frase é imprecisa, e o
typeof conta a história melhor. O mesmo arquivo, rodado duas vezes, mudando só
a condição de resolução:
import * as React from 'react';
console.log('useState ->', typeof React.useState);
console.log('useEffect ->', typeof React.useEffect);
console.log('useRef ->', typeof React.useRef);
console.log('use ->', typeof React.use);
console.log('cache ->', typeof React.cache);
console.log('createContext ->', typeof React.createContext);Com node --conditions=react-server:
E com node normal, que é o que o navegador enxerga:
Não é uma regra de lint. As funções não existem no build de servidor do React. Chamar useState num Server Component dá o erro mais literal possível:
import * as React from 'react';
export default function AgendaComEstado() {
const [filtro, setFiltro] = React.useState('');
return <input value={filtro} onChange={(e) => setFiltro(e.target.value)} />;
}E faz sentido: estado é o que muda entre um render e o próximo, e um Server
Component não tem “próximo”. Ele roda uma vez e vira texto. Pela mesma razão não
existe useEffect — não há ciclo de vida no navegador
para se pendurar — nem onClick, porque não há navegador para clicar.
O que sobra em pé nos dois builds, e é o que o servidor de fato usa: cache,
para não repetir a mesma consulta duas vezes na mesma requisição, e use, para
consumir uma promise. E, principalmente, a função do componente pode ser async
e dar await direto no banco — é assim que o ExamesPendentes da seção de
streaming funciona.
Atravessar a fronteira: o que é serializável e o que não é
Toda prop que vai de um Server Component para um Client Component precisa caber naquele formato de texto. Para saber exatamente o que cabe, passei nove tipos diferentes de uma vez e li o payload:
import BotaoConfirmar from './BotaoConfirmar.js';
class Pet {
constructor(nome) { this.nome = nome; }
latir() { return `${this.nome} latiu`; }
}
export default function NoveTipos() {
return (
<BotaoConfirmar
texto="Nina"
numero={4}
data={new Date('2026-08-04T08:30:00Z')}
lista={['Nina', 'Thor']}
mapa={new Map([['08:30', 'Nina']])}
conjunto={new Set(['Dra. Helena'])}
grande={9007199254740993n}
promessa={Promise.resolve('Bidu')}
instancia={new Pet('Pipoca')}
/>
);
}Oito passaram, um caiu. E dá para ler a tabela inteira direto da saída:
| o que passei | como saiu no payload | atravessa? |
|---|---|---|
| string, número | "Nina", 4 |
sim, literal |
| array e objeto simples | ["Nina","Thor"] |
sim, literal |
Date |
"$D2026-08-04T08:30:00.000Z" |
sim, com prefixo $D |
Map |
"$Q2" + linha 2: |
sim, numa linha própria |
Set |
"$W3" + linha 3: |
sim, numa linha própria |
BigInt |
"$n9007199254740993" |
sim, com prefixo $n |
Promise |
"$@4" + linha 4: depois |
sim, e resolve em streaming |
| instância de classe | "$5" + linha 5:E |
não — vira erro |
| função | erro na serialização | não |
Duas linhas dessa saída merecem atenção. A 4:"Bidu" é a promise resolvendo
depois, numa linha separada — o payload não esperou por ela. E a 5:E é uma
linha de erro: o React marca o buraco como quebrado e segue renderizando o
resto, em vez de derrubar a página inteira.
A função é o erro que todo mundo comete
O caso mais comum não é passar um Map: é passar um callback. Você escreve isto,
que em React de sempre seria a coisa mais banal do mundo:
import BotaoConfirmar from './BotaoConfirmar.js';
export default function AgendaComFuncao() {
return (
<BotaoConfirmar
consultaId={1}
pet="Nina"
aoConfirmar={() => console.log('confirmada')}
/>
);
}E recebe isto:
Repare no acento circunflexo apontando para a prop culpada — a mensagem do React
19 é boa e economiza depuração. E repare no que ela sugere: 'use server'. Uma
função pode cruzar a fronteira, desde que seja uma Server Action. Aí o que
viaja não é o código: é um identificador, e chamar a função no navegador vira
uma requisição de volta para o servidor.
Server Components, SSR e SSG não são a mesma coisa
Esta é a confusão mais cara, porque as três coisas envolvem “React no servidor” e são etapas distintas do mesmo pipeline.
O payload da agenda não é HTML. Para virar HTML, ele precisa ser consumido. Fiz o navegador na mão: li o arquivo do payload, registrei o componente de cliente de verdade e mandei o resultado para o renderizador de HTML.
import { readFileSync } from 'node:fs';
import { renderToStaticMarkup } from 'react-dom/server';
import { createFromReadableStream } from 'react-server-dom-webpack/client';
import BotaoConfirmar from './cliente/BotaoConfirmar.js';
// no navegador, quem responde por estes dois globais é o runtime do bundler
globalThis.__webpack_chunk_load__ = async () => {};
globalThis.__webpack_require__ = () => ({ default: BotaoConfirmar });
const payload = readFileSync('./dist/agenda.flight.txt');
const stream = new ReadableStream({
start(c) { c.enqueue(new Uint8Array(payload)); c.close(); },
});
const serverConsumerManifest = {
moduleMap: {
'cliente/BotaoConfirmar.js': {
default: { id: 'cliente/BotaoConfirmar.js', chunks: [], name: 'default' },
},
},
moduleLoading: null,
serverModuleMap: null,
};
const arvore = await createFromReadableStream(stream, { serverConsumerManifest });
console.log(renderToStaticMarkup(arvore));Este arquivo roda sem a condição react-server — é o lado cliente da
história, e por isso importa o BotaoConfirmar de verdade, não o stub.
Olhe o <button>Confirmar Nina</button> nessa saída. Ele existe no HTML — e
esse HTML foi gerado por um Node, não por um navegador. Ou seja: o
BotaoConfirmar, que tem use client no topo, executou no servidor. É a
prova do primeiro mal-entendido que a seção sobre a diretiva prometeu mostrar:
use client nunca quis dizer “roda só no navegador”.
O payload tem 909 bytes; o HTML resultante, 537. Na prática o framework serve os dois: o HTML para a primeira pintura e o payload para a hidratação e para as navegações seguintes.
| etapa | quem faz | produz | quando acontece |
|---|---|---|---|
| render RSC | react-server-dom-webpack/server |
o payload de 909 bytes | por requisição, ou no build |
| SSR | react-dom/server |
os 537 bytes de HTML | por requisição |
| hidratação | react-dom/client |
a página interativa | no navegador |
SSG é a mesma sequência com o relógio adiantado: as duas primeiras etapas rodam no build e viram arquivo. E hidratação continua sendo hidratação — depois dela, o botão vira um componente comum, com o mesmo ciclo de re-render do React de sempre.
O payload chega em pedaços, e o Suspense é o furo
Como cada linha é independente, o servidor pode enviar a árvore antes de ter
todos os dados. Coloquei um componente async que demora — a consulta de exames
no laboratório — dentro de um <Suspense>, e li o stream carimbando o tempo de
cada pedaço. O componente lento é este, com o atraso explícito de 600 ms:
async function buscarExamesNoLaboratorio() {
await new Promise((r) => setTimeout(r, 600));
return [{ pet: 'Bidu', exame: 'Hemograma' }];
}
export default async function ExamesPendentes() {
const exames = await buscarExamesNoLaboratorio();
return <ul>{exames.map((e) => <li key={e.pet}>{e.pet}: {e.exame}</li>)}</ul>;
}E a página que o envolve:
import { Suspense } from 'react';
import { listarConsultasDoDia } from './banco.js';
import ExamesPendentes from './ExamesPendentes.js';
export default function PaginaClinica() {
const consultas = listarConsultasDoDia();
return (
<main>
<h2>Clínica Pata Amiga</h2>
<p>{consultas.length} consultas hoje</p>
<Suspense fallback={<p>Carregando exames…</p>}>
<ExamesPendentes />
</Suspense>
</main>
);
}Aos 11 ms a página inteira já saiu, com o fallback no lugar e um $L2
marcando o pedaço que falta. Aos 610 ms — o ExamesPendentes tem um atraso de
600 ms de propósito — chega a linha 2: e o buraco é preenchido.
Repare que aqui o $L2 não é um componente de cliente: é o mesmo mecanismo de
buraco usado para outra coisa. Suspense e Server Components dividem a mesma
peça.
Quanto JavaScript deixa de ser enviado
Até aqui, mecanismo. Agora o motivo comercial, medido.
A agenda da clínica precisa mostrar a observação que a veterinária deixou, que é
escrita em Markdown, e formatar o horário. Montei a mesma tela de duas
formas. A versão A joga tudo para o cliente — um PainelAgenda com a diretiva
no topo, que importa o marked (18.0.10), converte o Markdown, formata a hora e
ainda guarda o estado dos botões:
'use client';
import { useState } from 'react';
import { marked } from 'marked';
export default function PainelAgenda({ consultas }) {
const [confirmadas, setConfirmadas] = useState([]);
return (
<ul>
{consultas.map((c) => (
<li key={c.id}>
<strong>{new Intl.DateTimeFormat('pt-BR', { timeStyle: 'short' }).format(new Date(c.horario))}</strong>
{' — '}{c.pet}
<div dangerouslySetInnerHTML={{ __html: marked.parse(c.observacao) }} />
<button type="button" onClick={() => setConfirmadas([...confirmadas, c.id])}>
{confirmadas.includes(c.id) ? `${c.pet} confirmado` : `Confirmar ${c.pet}`}
</button>
</li>
))}
</ul>
);
}Na versão B o Server Component faz a conversão do Markdown e a formatação da
hora, e só o BotaoConfirmar — aquele mesmo do começo do artigo — tem
use client. O ponto de entrada de cada versão hidrata o que sobrou:
// bundle/b/main.jsx — em A, o mesmo arquivo hidrata o PainelAgenda
import { hydrateRoot } from 'react-dom/client';
import BotaoConfirmar from './BotaoConfirmar.jsx';
hydrateRoot(document.getElementById('raiz'), <BotaoConfirmar consultaId={1} pet="Nina" />);Cada versão foi empacotada duas vezes: uma com React e React DOM dentro, para ver o peso real do que o navegador baixa, e outra com os dois marcados como externos, para isolar o código da tela.
for v in a b; do
npx esbuild bundle/$v/main.jsx --bundle --minify --format=esm \
--define:process.env.NODE_ENV='"production"' --outfile=dist/$v-completo.js
npx esbuild bundle/$v/main.jsx --bundle --minify --format=esm \
--external:react --external:react-dom --outfile=dist/$v-so-app.js
done
for f in a-completo a-so-app b-completo b-so-app; do
printf '%-22s %7d bytes %9d bytes gzip\n' "$f.js" \
"$(wc -c < dist/$f.js)" "$(gzip -9 -c dist/$f.js | wc -c)"
doneTraduzindo as quatro linhas:
| o que foi medido | versão A (tudo cliente) | versão B (fronteira nas folhas) |
|---|---|---|
| só o código da tela, minificado | 43 029 bytes | 321 bytes |
| só o código da tela, com gzip | 13 015 bytes | 253 bytes |
| bundle completo, com React e React DOM | 236 426 bytes | 193 486 bytes |
| bundle completo, com gzip | 72 909 bytes | 60 239 bytes |
A leitura honesta é a última linha: 12 670 bytes de gzip a menos, cerca de 17% do bundle. O React e o React DOM continuam lá nos dois lados — nenhum Server Component apaga o runtime do React da sua página.
O que desaparece é a biblioteca que só o servidor precisava. Empacotei o
marked sozinho, do mesmo jeito, para confirmar de onde vem a diferença:
import { marked } from 'marked';
globalThis.converterObservacao = (md) => marked.parse(md);Os 42 708 bytes que a versão B economiza no código da tela são, quase inteiros, essa biblioteca. E é aí que a conta vira interessante: numa tela real, some a isso um formatador de datas, um sanitizador de HTML, um cliente de banco, um SDK — cada um desses fica no servidor de graça.
Quando isso ainda não é um problema seu
Server Components resolvem um problema específico: tela que depende de dado do servidor e de biblioteca pesada para exibir esse dado. Catálogo, blog, dashboard público, e-commerce, painel com relatório.
Eles resolvem bem menos, ou nada, quando:
- o projeto é um Vite + React Router clássico, sem bundler compatível — aí a
diretiva
use clienté decoração; - o app inteiro fica atrás de login e é uma SPA de formulários, onde quase todo
componente precisaria de
use clientde qualquer jeito; - você ainda está firmando
useState,useEffecte o fluxo de props. Estudar RSC antes disso é aprender a exceção antes da regra — e a maior parte do código de um Server Component é React comum, sem estado.
E há um custo real que ninguém anuncia: a fronteira vira parte do design do
componente. Toda prop passa a ter uma pergunta a mais (“isso serializa?”), o
stack trace passa a atravessar duas máquinas, e um console.log some do
DevTools e aparece no terminal do servidor.
O próximo passo
O caminho útil aqui é pequeno e direto: abra um projeto Next.js com App Router,
coloque um console.log num componente sem use client e confirme que ele
aparece no terminal, não no navegador. Depois adicione a diretiva no mesmo
arquivo e veja o log trocar de lugar. Esse experimento de trinta segundos fixa a
fronteira melhor do que qualquer diagrama.
Se useState, useEffect e composição ainda não estão automáticos, o
guia de React mostra a ordem de estudo que leva até aqui sem
pular etapa.
Perguntas frequentes
Dá para usar Server Components sem Next.js?
Preciso escrever 'use client' em todo componente do front?
Server Component pode ser async?
Server Actions são a mesma coisa que Server Components?
Server Component roda a cada requisição?
Posso usar Context API dentro de um Server Component?
O formato do payload é estável? Posso montar um cliente em cima dele?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 com React 19.2.8 e react-server-dom-webpack 19.2.8 (macOS 27, Apple M4 Pro), e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- React — Server Components — react.dev
- React — Diretiva 'use client' — react.dev
- Next.js — Server and Client Components — nextjs.org
- RFC React Server Components — github.com


