Pular para o conteúdo
upgbp

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.

9 min de leitura

O erro tratado aqui

Hydration failed because the server rendered HTML didn't match the client.

Ambiente testado

  • Next.js 15.3
  • React 19.1
  • Node 22
Neste artigo (11)

Contexto: o que estava rodando

Um painel em Next.js 15 com App Router. A página lista transações com data, horário e um badge de status. Servidor renderiza, cliente hidrata, tudo simples.

Em produção o console enchia de erro de hidratação, e em algumas navegações o conteúdo piscava — aparecia uma versão, depois outra. O layout se recompunha sozinho meio segundo depois de carregar.

O erro

console do navegadorexit 1
Uncaught Error: Hydration failed because the server rendered HTML didn't match
the client. As a result this tree will be regenerated on the client. This can
happen if a SSR-ed Client Component used:

- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time
  it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.

...
-  22:04
+  19:04

O detalhe que resolve metade do caso está nas duas últimas linhas. O React mostra o que veio do servidor e o que o cliente calculou. Aqui: 22:04 contra 19:04. Três horas de diferença.

Diagnóstico

Hidratação é o momento em que o React pega o HTML que veio pronto do servidor e gruda nele a árvore de componentes do cliente. Para isso funcionar, a árvore gerada no cliente precisa ser idêntica à que o servidor gerou. Qualquer divergência e o React descarta o HTML do servidor e redesenha tudo — daí o pisca-pisca, o pico de trabalho na thread principal e a queda de INP.

Na prática, quase todo caso cai numa destas cinco origens.

1. Data e hora formatadas no fuso do ambiente

Esta era a minha. O servidor da Vercel roda em UTC. O navegador do usuário roda no fuso dele. toLocaleTimeString() sem fuso explícito lê o ambiente:

src/components/LinhaTransacao.tsx
// Servidor em UTC devolve 22:04. Navegador em São Paulo devolve 19:04.
<span>{new Date(transacao.criadaEm).toLocaleTimeString('pt-BR')}</span>

Note que isso é invisível para quem desenvolve com a máquina no mesmo fuso do servidor. Só aparece em produção — ou para o colega que está viajando.

2. Valor variável dentro da renderização

Math.random(), Date.now(), crypto.randomUUID(). O servidor calcula um valor, o cliente calcula outro. Costuma aparecer disfarçado, em geração de key, de id para acessibilidade ou de ordem embaralhada.

3. Ramificação por typeof window

// O servidor sempre entra no else. O cliente, no if. Divergência garantida.
const largura = typeof window !== 'undefined' ? window.innerWidth : 1024;

O mesmo vale para ler localStorage, matchMedia ou navigator durante a renderização.

4. Aninhamento de HTML inválido

Este é o mais difícil de enxergar, porque o código parece certo. Um <div> dentro de um <p>, ou um <p> dentro de outro <p>, faz o navegador corrigir a estrutura ao analisar o HTML — ele fecha o parágrafo antes da div. O React monta a árvore como você escreveu, e as duas não batem.

Sintoma característico: a mensagem cita elementos que você não colocou lado a lado, ou fala de um elemento “esperado” que sumiu.

5. Extensão de navegador mexendo no HTML

Grammarly, gerenciadores de senha e tradutores injetam atributos no body e nos campos de formulário antes do React hidratar. É a causa a descartar primeiro: se o erro some numa janela anônima com extensões desligadas, o código está certo.

A solução

  1. Formate datas com fuso explícito, sempre.

    A correção não é mover a formatação para o cliente — é remover a ambiguidade. Se o servidor e o cliente recebem a mesma instrução de fuso, produzem a mesma string.

    src/lib/formato.ts
    // Um formatador criado uma vez, com fuso e locale fixos. Sem depender do
    // ambiente, servidor e cliente chegam ao mesmo resultado.
    const hora = new Intl.DateTimeFormat('pt-BR', {
      timeZone: 'America/Sao_Paulo',
      hour: '2-digit',
      minute: '2-digit',
    });
    
    export const formatarHora = (data: Date | string) =>
      hora.format(typeof data === 'string' ? new Date(data) : data);

    Quando o horário precisa mesmo ser o do usuário — e não o do negócio — aí sim ele só pode ser calculado no cliente. Renderize o valor absoluto no HTML e ajuste depois:

    src/components/HoraLocal.tsx
    'use client';
    import { useEffect, useState } from 'react';
    
    export function HoraLocal({ iso }: { iso: string }) {
      // O primeiro render, no servidor e no cliente, produz o mesmo texto.
      // O ajuste para o fuso do usuário acontece depois da hidratação.
      const [texto, setTexto] = useState(() => iso.slice(11, 16));
    
      useEffect(() => {
        setTexto(
          new Date(iso).toLocaleTimeString('pt-BR', {
            hour: '2-digit',
            minute: '2-digit',
          }),
        );
      }, [iso]);
    
      return <time dateTime={iso}>{texto}</time>;
    }
  2. Mova o valor variável para fora da renderização.

    Ids únicos têm API própria no React, justamente porque este problema é comum:

    src/components/CampoBusca.tsx
    'use client';
    import { useId } from 'react';
    
    export function CampoBusca() {
      // Estável entre servidor e cliente, ao contrário de randomUUID().
      const id = useId();
      return (
        <>
          <label htmlFor={id}>Buscar</label>
          <input id={id} type="search" />
        </>
      );
    }

    Para dados verdadeiramente aleatórios, gere no servidor e passe por props, ou gere dentro de useEffect.

  3. Leia estado do navegador com a API que o React oferece.

    useSyncExternalStore foi feita para isto: ela recebe um valor para o servidor e outro para o cliente, sem enganar o reconciliador.

    src/hooks/useTelaLarga.ts
    'use client';
    import { useSyncExternalStore } from 'react';
    
    const consulta = () => window.matchMedia('(min-width: 1024px)');
    
    function assinar(callback: () => void) {
      const mq = consulta();
      mq.addEventListener('change', callback);
      return () => mq.removeEventListener('change', callback);
    }
    
    export function useTelaLarga() {
      return useSyncExternalStore(
        assinar,
        () => consulta().matches, // no cliente
        () => false,              // no servidor: valor declarado, não adivinhado
      );
    }
  4. Conserte o aninhamento em vez de silenciar o aviso.

    Os pares que mais aparecem: div dentro de p, p dentro de p, a dentro de a, ul dentro de p, e qualquer bloco dentro de <Link> que envolva texto. O caminho mais rápido para achar é rodar o HTML gerado por um validador:

    curl -s https://seu-app.vercel.app/pagina > pagina.html
    npx html-validate pagina.html
  5. Silencie apenas o nó que você sabe ser diferente.

    suppressHydrationWarning não conserta nada — ele manda o React ignorar a diferença de conteúdo daquele elemento. É legítimo em um caso: o valor é inerentemente diferente entre servidor e cliente e isso está correto.

    <time dateTime={iso} suppressHydrationWarning>
      {formatarHora(iso)}
    </time>

    Ele não se propaga para os filhos e não vale para atributos. Usar no <body> inteiro para calar o console é trocar um bug visível por um invisível.

Como confirmar que resolveu

Console limpo em janela anônima. Sem extensões, sem aviso de hidratação, em navegação direta e em navegação interna pelo <Link>.

Force um fuso diferente do seu. É o teste que reproduz o bug original sem precisar viajar:

# Simula um servidor em UTC enquanto o navegador está em São Paulo
TZ=UTC npm run build && TZ=UTC npm run start

Compare os dois HTMLs. Se ainda houver divergência, este comando mostra exatamente onde:

curl -s http://localhost:3000/pagina | npx prettier --parser html > servidor.html
# copie o HTML do painel Elements depois da hidratação para cliente.html
diff servidor.html cliente.html

Armadilhas que sobram depois disso

suppressHydrationWarning espalhado esconde regressões. Se estiver em mais de dois ou três lugares no projeto, provavelmente virou tapa-buraco. Cada uso merece um comentário dizendo por que a diferença é esperada.

O erro pode vir de biblioteca. Componentes de terceiros que leem window no render provocam o mesmo sintoma. Nesses casos, next/dynamic com ssr: false é a saída — ao custo de o componente não existir no HTML inicial, o que tira ele do LCP.

Custo de performance mesmo sem erro visível. Quando a hidratação falha, o React redesenha a subárvore inteira no cliente. Isso é trabalho na thread principal em cima do carregamento, e aparece como INP ruim — mesmo que visualmente ninguém perceba o pisca.

Cache pode mascarar. Uma página com revalidate longo serve HTML gerado horas antes. Um mismatch de data pode sumir e voltar conforme o cache expira, dando a impressão de intermitência aleatória.

Continue por aqui