O 'use client' que dobrou o bundle: onde fica a fronteira
A diretiva não marca um arquivo, marca uma fronteira. Tudo que ela importa desce junto para o navegador — inclusive o que você jurava ser código de servidor.
Ambiente testado
- Next.js 15.3
- React 19.1
- @next/bundle-analyzer 15.3
Contexto: o que estava rodando
Um painel administrativo em App Router. A página tinha uma tabela renderizada no
servidor e, ao lado, um gráfico interativo. Para o gráfico funcionar, coloquei
'use client' no topo do componente da página — parecia o caminho óbvio, já que
a página inteira precisava “ser cliente” para conter algo interativo.
Depois desse commit, o painel ficou visivelmente mais lento no celular.
O sintoma
Não é uma exceção, é uma medição. O próprio next build mostra:
Route (app) Size First Load JS
┌ ○ / 1.8 kB 104 kB
├ ○ /painel 186 kB 402 kB
└ ○ /configuracoes 2.1 kB 106 kB
+ First Load JS shared by all 102 kB402 KB de JavaScript para a primeira carga de uma página cujo conteúdo principal é uma tabela estática. As outras rotas, com o mesmo layout, ficaram em 104 KB.
Diagnóstico
A diretiva marca uma fronteira, não um arquivo
Esta é a parte que o nome esconde. 'use client' não significa “este arquivo
roda no cliente”. Significa “a partir daqui para baixo, tudo roda no
cliente”.
Todo módulo importado por um componente cliente — e todo módulo que esses importam, recursivamente — entra no pacote enviado ao navegador. A diretiva marca o ponto de entrada da árvore cliente, e a árvore inteira desce junto.
Ao colocar a diretiva no topo da página, eu transformei a tabela, os utilitários de formatação, a camada de acesso a dados e tudo que eles importavam em código de navegador.
O que estava descendo junto
O @next/bundle-analyzer responde isso em um comando:
import withBundleAnalyzer from '@next/bundle-analyzer';
const analisar = withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' });
export default analisar({});ANALYZE=true npm run build
Três coisas apareceram no pacote do cliente sem terem razão para estar lá:
A biblioteca de gráficos inteira. Esta era esperada — o gráfico é interativo. Mas ela estava sendo carregada no primeiro byte, e não sob demanda.
Uma biblioteca de datas completa. Usada por uma função de formatação que a tabela chamava. A tabela virou cliente, a função foi junto, a biblioteca foi junto.
Um módulo de acesso a dados. Este era o mais grave. Ele importava o cliente
do banco. Não vazou credencial porque o Next barra process.env sem prefixo
público, mas todo o código foi empacotado e enviado.
O arquivo-barril multiplica o estrago
O projeto tinha um src/components/index.ts reexportando tudo. Um componente
cliente que importasse um item desse arquivo puxava a árvore de dependências
de todos os outros.
// Um único import daqui, num componente cliente, arrasta os três.
export { Tabela } from './Tabela';
export { Grafico } from './Grafico'; // biblioteca de gráficos
export { EditorRico } from './EditorRico'; // editor de textoA solução
-
Empurrar a diretiva para as folhas.
A página volta a ser Server Component. Só o que precisa de interatividade ganha a diretiva.
src/app/painel/page.tsx// Sem 'use client': isto roda só no servidor. import { Tabela } from '@/components/Tabela'; import { GraficoInterativo } from '@/components/GraficoInterativo'; import { buscarVendas } from '@/lib/vendas'; export default async function Painel() { const vendas = await buscarVendas(); return ( <main> <Tabela linhas={vendas} /> <GraficoInterativo dados={vendas} /> </main> ); }src/components/GraficoInterativo.tsx'use client'; // A fronteira começa aqui, e só a biblioteca de gráficos cruza. -
Passar conteúdo de servidor como
children.Quando o invólucro precisa ser cliente — abas, acordeão, arrastar e soltar — o conteúdo dentro dele não precisa virar cliente junto. Um Server Component passado como
childrenchega já renderizado.src/app/painel/page.tsximport { Abas } from '@/components/Abas'; // cliente import { RelatorioPesado } from '@/components/RelatorioPesado'; // servidor export default function Painel() { return ( <Abas> {/* Renderizado no servidor, entregue como HTML pronto. A diretiva em Abas não contamina o que vem por children. */} <RelatorioPesado /> </Abas> ); }src/components/Abas.tsx'use client'; import { useState, type ReactNode } from 'react'; export function Abas({ children }: { children: ReactNode }) { const [aberta, setAberta] = useState(0); return ( <div> <button onClick={() => setAberta(0)}>Relatório</button> {aberta === 0 && children} </div> ); }A regra:
childrenatravessa a fronteira já pronto;importa cruza como código. -
Carregar o que é pesado só quando for preciso.
Um gráfico abaixo da dobra não precisa estar no primeiro byte:
src/components/GraficoInterativo.tsx'use client'; import dynamic from 'next/dynamic'; // Sai do pacote inicial e vira um pedaço separado, buscado ao renderizar. // ssr: false porque a biblioteca lê window na montagem. const Grafico = dynamic(() => import('./GraficoBase'), { ssr: false, loading: () => <div className="h-72 animate-pulse rounded bg-neutral-100" />, }); export function GraficoInterativo({ dados }: { dados: Venda[] }) { return <Grafico dados={dados} />; }O
loadingcom a altura final evita que a chegada do gráfico empurre a página — trocar peso de JavaScript por CLS não é ganho. -
Fazer o build falhar quando código de servidor vazar.
Auditar na mão não escala. O pacote
server-onlytransforma o vazamento em erro de compilação:src/lib/vendas.tsimport 'server-only'; import { prisma } from './prisma'; export async function buscarVendas() { return prisma.venda.findMany({ orderBy: { data: 'desc' }, take: 200 }); }Qualquer componente cliente que importe este arquivo passa a quebrar o build, com mensagem apontando o arquivo culpado. É a única defesa que não depende de alguém lembrar.
-
Abandonar o arquivo-barril na fronteira do cliente.
Importe do caminho direto, não do índice:
- import { Tabela } from '@/components'; + import { Tabela } from '@/components/Tabela';Perde-se um pouco de conveniência e ganha-se previsibilidade sobre o que entra no pacote.
Como confirmar que resolveu
Compare o First Load JS antes e depois. É a métrica direta:
Route (app) Size First Load JS
├ ○ /painel 8.4 kB 112 kBDe 402 KB para 112 KB, com o gráfico agora num pedaço próprio carregado depois.
Confira o que sobrou na rota. Rodando o analisador de novo, nenhum bloco deve ser código que só o servidor deveria ver.
Meça o efeito real, não só o número do build. Bundle menor só vale se o usuário sentir. No painel do Chrome, aba Performance, com CPU limitada em 4x: o bloco de trabalho da thread principal durante o carregamento é o que precisa encolher — é ele que também define o seu INP.
Armadilhas que sobram depois disso
ssr: false tira o componente do HTML inicial. Se ele contém o maior
elemento visível da tela, você melhorou o bundle e piorou o LCP. Use para
widgets secundários, não para o conteúdo principal.
Um componente cliente importado por vários lugares não duplica. Ele vira um pedaço compartilhado. O problema nunca é o número de fronteiras, é o peso da árvore atrás de cada uma.
Provedores de contexto no layout raiz contaminam tudo. Um provider com
'use client' no layout.tsx faz toda página descendente carregar aquela
árvore. Envolva o menor escopo possível, ou coloque o provider em volta apenas
do children, que continua vindo do servidor.
Biblioteca de datas grande raramente é necessária. Intl.DateTimeFormat é
nativo, custa zero byte e resolve formatação e fuso. Trocar a biblioteca por ela
costuma render mais que qualquer reorganização de fronteira.
Continue por aqui
Performance Web
Hydration mismatch no App Router: as 5 causas reais
O erro de hidratação do React quase sempre vem de uma destas cinco origens. Como identificar qual é a sua em minutos, em vez de tentar no escuro.
Performance Web
Streaming e Suspense: eliminando o waterfall que trava a página
Um await depois do outro num Server Component soma latências. Como paralelizar e, melhor ainda, entregar a página antes dos dados chegarem.
Performance Web
Bundle analyzer: as dependências que sempre estouram o JavaScript
Quando o peso está no pacote compartilhado, o problema não é uma rota — é uma biblioteca. As seis que mais aparecem e o que colocar no lugar.