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.
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
- O erro
- Diagnóstico
- 1. Data e hora formatadas no fuso do ambiente
- 2. Valor variável dentro da renderização
- 3. Ramificação por typeof window
- 4. Aninhamento de HTML inválido
- 5. Extensão de navegador mexendo no HTML
- A solução
- Como confirmar que resolveu
- Armadilhas que sobram depois disso
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
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:04O 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:
// 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
-
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>; } -
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. -
Leia estado do navegador com a API que o React oferece.
useSyncExternalStorefoi 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 ); } -
Conserte o aninhamento em vez de silenciar o aviso.
Os pares que mais aparecem:
divdentro dep,pdentro dep,adentro dea,uldentro dep, 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 -
Silencie apenas o nó que você sabe ser diferente.
suppressHydrationWarningnã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
Performance Web
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.
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
INP acima de 200 ms: achando a long task que trava o clique
O INP mede o clique mais lento da sessão, não a média. Como achar exatamente qual interação e qual linha de código estão travando a thread principal.