Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
ConceitoAvançadocódigo testado

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.

Rodolfo Mori15 min de leitura

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.

js
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:

jsx
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:

jsx
'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:

bash
node preparar.mjs
NODE_ENV=production node --conditions=react-server gerar-payload.mjs

A 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:

js
// 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.

js
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ó:

js
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:

1:I["cliente/BotaoConfirmar.js",["cliente","cliente.js"],"default"] 0:["$","section",null,{"children":[["$","h2",null,{"children":"Agenda de hoje — Clínica Pata Amiga"}],["$","ul",null,{"children":[["$","li","1",{"children":[["$","strong",null,{"children":"08:30"}]," — ","Nina"," (","Ana Prado",") com ","Dra. Helena",["$","$L1",null,{"consultaId":1,"pet":"Nina"}]]}],["$","li","2",{"children":[["$","strong",null,{"children":"09:15"}]," — ","Thor"," (","Bruno Lima",") com ","Dr. Marcelo",["$","$L1",null,{"consultaId":2,"pet":"Thor"}]]}],["$","li","3",{"children":[["$","strong",null,{"children":"10:00"}]," — ","Bidu"," (","Carla Nunes",") com ","Dra. Helena",["$","$L1",null,{"consultaId":3,"pet":"Bidu"}]]}],["$","li","4",{"children":[["$","strong",null,{"children":"11:20"}]," — ","Pipoca"," (","Diego Castro",") com ","Dr. Marcelo",["$","$L1",null,{"consultaId":4,"pet":"Pipoca"}]]}]]}]]}]

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:

text
["$","$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

servidor (condição react-server) o que trafega: 909 bytes navegador AgendaDoDia Server Component node:sqlite listarConsultasDoDia() marked, chave de API, .env nada disso cruza a linha 0: a árvore inteira, já renderizada $L1 = o buraco 1:I["BotaoConfirmar.js"] o endereço de quem preenche o buraco árvore pronta para pintar sem recalcular nada BotaoConfirmar.js o único .js de tela baixado useState e onClick só existem aqui o código da coluna da esquerda nunca é enviado — só o resultado dele

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.

jsx
'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:

jsx
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>
  );
}
1:I["cliente/Painel.js",["cliente","cliente.js"],"default"] 0:["$","$L1",null,{"titulo":"Agenda de hoje","children":["$","ul",null,{"children":[["$","li","1",{"children":["08:30"," — ","Nina"," com ","Dra. Helena"]}],["$","li","2",{"children":["09:15"," — ","Thor"," com ","Dr. Marcelo"]}]]}]}]

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:

js
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:

useState -> undefined useEffect -> undefined useRef -> undefined use -> function cache -> function createContext -> undefined

E com node normal, que é o que o navegador enxerga:

useState -> function useEffect -> function useRef -> function use -> function cache -> function createContext -> function

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:

jsx
import * as React from 'react';

export default function AgendaComEstado() {
  const [filtro, setFiltro] = React.useState('');
  return <input value={filtro} onChange={(e) => setFiltro(e.target.value)} />;
}
TypeError: React.useState is not a function or its return value is not iterable at AgendaComEstado (file:///private/tmp/clinica/build/AgendaComEstado.js:4:37) at renderFunctionComponent (/private/tmp/clinica/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.production.js:1175:11) at renderElement (/private/tmp/clinica/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.production.js:1230:12)

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:

jsx
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')}
    />
  );
}
[onError] Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported. 1:I["cliente/BotaoConfirmar.js",["cliente","cliente.js"],"default"] 2:[["08:30","Nina"]] 3:["Dra. Helena"] 0:["$","$L1",null,{"texto":"Nina","numero":4,"data":"$D2026-08-04T08:30:00.000Z","lista":["Nina","Thor"],"mapa":"$Q2","conjunto":"$W3","grande":"$n9007199254740993","promessa":"$@4","instancia":"$5"}] 5:E{"digest":""} 4:"Bidu"

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:

jsx
import BotaoConfirmar from './BotaoConfirmar.js';

export default function AgendaComFuncao() {
  return (
    <BotaoConfirmar
      consultaId={1}
      pet="Nina"
      aoConfirmar={() => console.log('confirmada')}
    />
  );
}

E recebe isto:

Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it. {consultaId: 1, pet: "Nina", aoConfirmar: function aoConfirmar} ^^^^^^^^^^^^^^^^^^^^ at renderModelDestructive (/private/tmp/clinica/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.production.js:1791:11) at Object.toJSON (/private/tmp/clinica/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.production.js:1320:40)

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.

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

<section><h2>Agenda de hoje — Clínica Pata Amiga</h2><ul><li><strong>08:30</strong> — Nina (Ana Prado) com Dra. Helena<button type="button">Confirmar Nina</button></li><li><strong>09:15</strong> — Thor (Bruno Lima) com Dr. Marcelo<button type="button">Confirmar Thor</button></li><li><strong>10:00</strong> — Bidu (Carla Nunes) com Dra. Helena<button type="button">Confirmar Bidu</button></li><li><strong>11:20</strong> — Pipoca (Diego Castro) com Dr. Marcelo<button type="button">Confirmar Pipoca</button></li></ul></section>

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:

jsx
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:

jsx
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>
  );
}
[ 11 ms] 1:"$Sreact.suspense" 0:["$","main",null,{"children":[["$","h2",null,{"children":"Clínica Pata Amiga"}],["$","p",null,{"children":[4," consultas hoje"]}],["$","$1",null,{"fallback":["$","p",null,{"children":"Carregando exames…"}],"children":"$L2"}]]}] [ 610 ms] 2:["$","ul",null,{"children":[["$","li","Bidu",{"children":["Bidu",": ","Hemograma"]}]]}]

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:

jsx
'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:

jsx
// 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.

bash
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)"
done
a-completo.js 236426 bytes 72909 bytes gzip a-so-app.js 43029 bytes 13015 bytes gzip b-completo.js 193486 bytes 60239 bytes gzip b-so-app.js 321 bytes 253 bytes gzip

Traduzindo 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:

js
import { marked } from 'marked';

globalThis.converterObservacao = (md) => marked.parse(md);
so-marked.js 42420 bytes 12702 bytes gzip

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 client de qualquer jeito;
  • você ainda está firmando useState, useEffect e 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.

  • react
  • server components
  • rsc
  • use client
  • next.js

Perguntas frequentes

Dá para usar Server Components sem Next.js?
A API é do React, não do Next. O que falta fora de um framework é o bundler: alguém precisa entender a diretiva use client, gerar as referências de cliente e servir os chunks certos. Hoje quem faz isso pronto é o App Router do Next.js, o React Router 7 em modo framework, o Waku e o plugin oficial do Parcel. A bancada deste artigo faz esse papel na mão, em 36 linhas, só para mostrar o mecanismo.
Preciso escrever 'use client' em todo componente do front?
Não. A diretiva marca a porta de entrada, não a sala inteira. Tudo que um arquivo com use client importa já vira código de cliente por contágio, e repetir a diretiva nos filhos não muda nada além do ruído no diff.
Server Component pode ser async?
Pode, e é metade do motivo de ele existir. Uma função de componente declarada com async pode dar await direto em banco, arquivo ou API, sem estado de carregando e sem useEffect. Componente de cliente não aceita async — nele o await entra por use() ou por um hook.
Server Actions são a mesma coisa que Server Components?
Não, são o caminho de volta. Server Component é servidor para cliente: dado que já veio renderizado. Server Action, marcada com 'use server', é cliente para servidor: uma função que o navegador chama por rede como se fosse local. As duas diretivas existem juntas porque marcam as duas pontas da mesma fronteira.
Server Component roda a cada requisição?
Depende do que o framework faz com o resultado. Sem cache nenhum, roda a cada pedido de página. Com a rota marcada como estática, roda uma vez no build e o payload vira arquivo. Esse controle é do framework: o React só sabe transformar a árvore em payload.
Posso usar Context API dentro de um Server Component?
Não, e nem é questão de estilo — createContext simplesmente não existe no build de servidor do React, como o typeof deste artigo mostra. Contexto precisa viver dentro da parte cliente da árvore; o provider é um componente com use client que recebe os dados já prontos como props.
O formato do payload é estável? Posso montar um cliente em cima dele?
Não conte com isso. O formato serializado do RSC é detalhe interno do React e já mudou entre versões — o payload deste artigo é o do React 19.2.8. Ele serve para entender o mecanismo e para depurar, não para ser contrato de integração.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. React — Server Components — react.dev
  2. React — Diretiva 'use client' — react.dev
  3. Next.js — Server and Client Components — nextjs.org
  4. RFC React Server Components — github.com

Continue por aqui