Pular para o conteúdo
upgbp

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.

9 min de leitura

Ambiente testado

  • Next.js 15.3
  • React 19.1
  • @next/bundle-analyzer 15.3
Neste artigo (9)

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:

npm run build
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 kB

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

next.config.mjs
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.

src/components/index.ts
// 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 texto

A solução

  1. 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.
  2. 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 children chega já renderizado.

    src/app/painel/page.tsx
    import { 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: children atravessa a fronteira já pronto; import a cruza como código.

  3. 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 loading com a altura final evita que a chegada do gráfico empurre a página — trocar peso de JavaScript por CLS não é ganho.

  4. Fazer o build falhar quando código de servidor vazar.

    Auditar na mão não escala. O pacote server-only transforma o vazamento em erro de compilação:

    src/lib/vendas.ts
    import '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.

  5. 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:

npm run build
Route (app)                              Size     First Load JS
├ ○ /painel                             8.4 kB          112 kB

De 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