Next.js: Hydration failed because the server rendered HTML didn't match
Эта ошибка — родная сестра базового «Hydration failed because the initial UI does not match», но в Next.js часто показывается с уточнением: «server rendered HTML didn't match the client». В консоли — длинное сообщение с подсказкой, в каком DOM-узле сервер сказал одно, а клиент другое.
Error: Hydration failed because the server rendered HTML didn't match the client.
As a result this tree will be regenerated on the client.Что я делаю каждый раз: открываю DevTools, читаю стек, нахожу конкретный узел и не пытаюсь «обмануть» React. У этой ошибки есть конкретные причины, и в Next.js они слегка отличаются от обычного React.
Что важно понимать про Next
App Router рендерит дерево на сервере, отправляет HTML, потом клиент догружает JS и пытается приклеиться к этому HTML — гидрация. Несовпадение значит: то, что вычислил сервер при рендере, отличается от того, что вычислил клиент при первом проходе.
В отличие от чисто клиентских React-приложений, в Next серверная и клиентская среда — это две разные среды. У них разные тайм-зоны (часто), разная локаль, разные глобальные объекты. И каждая «не совсем такая же» функция в JSX превращается в hydration mismatch.
Сценарии в Next, которые я видел чаще всего
1. cookies() или заголовки прочитал только сервер
В App Router можно прочитать куки на сервере через cookies(). На клиентской стороне их физически тоже видно, но через document.cookie. Если ты в одном и том же компоненте сначала рендерить с одним значением (пришедшим с сервера), потом перерендеришь с другим (полученным на клиенте) — гидрация рассыплется.
Лечится разделением: серверный компонент читает куку и передаёт значение в клиентский как проп. Дальше клиент работает с этим значением как с initial state.
// app/page.tsx (server component)
import { cookies } from 'next/headers';
import { Theme } from './Theme';
export default function Page() {
const theme = cookies().get('theme')?.value ?? 'light';
return <Theme initial={theme} />;
}// app/Theme.tsx
'use client';
import { useState } from 'react';
export function Theme({ initial }: { initial: string }) {
const [theme, setTheme] = useState(initial);
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}2. Дата с серверной тайм-зоной vs клиентской
Сервер у тебя в UTC, клиент в Москве. new Date().toLocaleDateString() на сервере и на клиенте дадут разные строки, потому что одна и та же миллисекунда выглядит как 31 декабря 23:00 UTC и 1 января 02:00 в Москве.
Решений два:
- отдавать с сервера в виде строки в нужном формате и отрисовывать как обычный текст;
- отрисовывать формат на клиенте после маунта (через
useEffect), а до этого показывать плейсхолдер.
3. Условный рендер по typeof window
В коде есть typeof window !== 'undefined' ? <A /> : <B />. На сервере это <B />, на клиенте сразу <A />. Расхождение в первом проходе клиента — гарантировано.
В Next правильный путь — пометить компонент как клиентский ('use client') и использовать useEffect для постмаунт-рендера, либо использовать dynamic(() => ..., { ssr: false }) для модулей, которые в принципе не должны рендериться на сервере.
4. CSS-in-JS, который вставляет другие классы
Старые версии styled-components без правильной интеграции с Next генерируют разные классы на сервере и клиенте. Симптом — после первого рендера React ругается на разные className. Лечится официальной интеграцией: для styled-components — app/registry.tsx с StyleRegistry; для emotion — свой провайдер.
Проще — использовать Tailwind или CSS-модули. Я давно отказался от CSS-in-JS в Next-проектах ровно из-за этих проблем с SSR.
5. Расширения браузера — Grammarly, ColorZilla, переводчики
Отдельный сорт hydration mismatch. Расширение успевает добавить атрибуты в DOM до того, как React закончит гидрацию. У тебя в JSX этих атрибутов нет, в фактическом DOM — есть, React ругается.
В консоли диагностируется по атрибутам типа data-new-gr-c-s-check-loaded, cz-shortcut-listen, data-translate.
Лечение — suppressHydrationWarning на корневых элементах. Точечно. Без энтузиазма раскидывать по всему дереву:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" suppressHydrationWarning>
<body suppressHydrationWarning>{children}</body>
</html>
);
}6. Тема через классы на html, поставленная без скрипта в head
Если ты хранишь тему в localStorage и переключаешь класс на <html>, между рендером сервера и установкой класса на клиенте есть зазор — мерцание + mismatch.
Решение — инлайн-скрипт в head, который ставит класс ещё до того, как React возьмётся за работу:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `try { var t = localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'; document.documentElement.classList.add(t); } catch(e) {}`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}Скрипт выполняется до гидрации, класс на html уже стоит. Сервер тоже не пишет тему в HTML — на нём элемент идёт без класса. Поэтому suppressHydrationWarning на html здесь как раз оправдан.
7. Math.random() и UUID-генерация в JSX
Каждый запуск даёт новое значение. На сервере один UUID, на клиенте — другой. В Next 14+ для устойчивых ID-в-JSX лучше использовать useId() — он создаёт значения, которые гарантированно одинаковые в SSR и при гидрации.
'use client';
import { useId } from 'react';
export function Form() {
const id = useId();
return (
<label htmlFor={id}>
Email
<input id={id} type="email" />
</label>
);
}Как искать причину пошагово
В Next с App Router ошибка показывается прямо на странице с указанием конкретного компонента. Всё, что нужно — открыть его и проверить:
- есть ли в JSX вычисления, зависящие от времени, среды, флагов клиента;
- не лезу ли я в
localStorage,document,windowв начале рендера; - не ставит ли расширение браузера атрибуты на корневые элементы;
- правильно ли подключён CSS-in-JS с SSR.
Если виновник — расширение браузера, лечение чаще всего косметическое (suppressHydrationWarning). Если виновник — твой код, его надо переписать так, чтобы серверная и клиентская среда давали одинаковый результат на первый рендер.
Что не делать
- Не переводить весь компонент в
{ ssr: false }«чтобы исчезла ошибка». Это просто отключение SSR для куска страницы. - Не ставить
suppressHydrationWarningповсюду. Это маскировка, а не решение. - Не считать себя умнее React в плане «когда рендерить». Если он сказал mismatch — значит реально несовпадение.
Лечение всегда одно: понять, что именно отличается между сервером и клиентом, и сделать так, чтобы первый рендер у них совпадал. Дальше уже обновляй данные через состояние — это безопасно.