Hydration mismatch в Next: 5 типичных причин
Ошибка Hydration failed because the initial UI does not match what was rendered on the server — самое раздражающее в Next.js. На первый взгляд кажется случайной, и каждый раз приходится копаться. На самом деле причин обычно пять, и они повторяются от проекта к проекту. Расскажу, как их распознавать и чинить.
Что такое hydration mismatch
Когда Next.js рендерит страницу на сервере, он отдаёт HTML. Браузер получает его, начинает парсить, и тут React пытается «оживить» этот HTML — присвоить event listeners, восстановить состояние. Это и есть гидрация.
Если HTML на сервере и React на клиенте рисуют разную разметку, React понимает, что что-то не так, и валит ошибку. В режиме разработки видно подробное сообщение, в проде — просто страница перерисовывается полностью, что плохо для UX и метрик.
Причина 1. Date.now() и Math.random()
Самый частый и очевидный случай. Сервер вызывает Date.now(), получает 1700000000000. Через 200 мс клиент гидрируется, вызывает Date.now(), получает 1700000000200. Разные значения — mismatch.
// плохо
function Component() {
return <p>Загружено в {new Date().toLocaleString()}</p>;
}Решение — рендерить динамическое значение только на клиенте через useEffect:
'use client';
function Component() {
const [now, setNow] = useState<string | null>(null);
useEffect(() => {
setNow(new Date().toLocaleString());
}, []);
return <p>Загружено в {now ?? '...'}</p>;
}Или использовать suppressHydrationWarning, если разница допустима:
<p suppressHydrationWarning>{new Date().toLocaleString()}</p>Но это flag «я знаю, что делаю». Не злоупотребляй.
Причина 2. Локализация дат и чисел
Этот случай коварнее. Date.toLocaleString() зависит от локали окружения. На сервере (Node) одна локаль, у пользователя в браузере — другая. Результат: «1.05.2026» на сервере и «5/1/2026» на клиенте.
Та же проблема с Number.toLocaleString() и Intl.NumberFormat без явной локали.
// плохо
const formatted = (1234.5).toLocaleString();
// хорошо
const formatted = (1234.5).toLocaleString('ru-RU');Всегда указывай локаль явно. И помни: если у тебя локаль в URL (/ru/...) — на сервере она доступна, на клиенте через useRouter. Пробрасывай в компонент пропсом, не вытаскивай асинхронно.
Причина 3. Размеры окна и медиа-запросы
Сервер не знает ширину окна пользователя. Если ты рендеришь по-разному для мобильного и десктопа на основе window.innerWidth или matchMedia — будет mismatch.
// плохо
function Component() {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return isMobile ? <Mobile /> : <Desktop />;
}Решение зависит от задачи. Если разница только в стилях — используй CSS-медиа-запросы:
.desktop { display: none; }
@media (min-width: 768px) {
.mobile { display: none; }
.desktop { display: block; }
}Если разница в логике — рендери оба варианта на сервере, а на клиенте показывай нужный после mount. Или используй cookie с user-agent (детект на сервере, отдаём правильный HTML).
Причина 4. localStorage и cookies на клиенте
Если компонент читает localStorage для определения темы или языка — на сервере этого нет. На сервере отрисуется один вариант, на клиенте — другой.
// плохо
function ThemeToggle() {
const isDark = typeof window !== 'undefined' && localStorage.getItem('theme') === 'dark';
return <div className={isDark ? 'dark' : 'light'}>...</div>;
}Решения:
- Хранить тему в cookie, читать на сервере, передавать в компонент.
- Рендерить «нейтральный» вариант на сервере, обновлять после mount.
- Использовать inline-скрипт в
<head>, который ставит класс наhtmlдо первого рендера.
Я предпочитаю комбинацию: cookie + inline-скрипт. Cookie ловит большинство случаев, inline-скрипт страхует на первый визит.
Причина 5. HTML, который браузер «исправляет»
Это самый сложный случай. Если ты пишешь невалидный HTML, браузер его сам нормализует, и React после этого не может сопоставить.
Классические примеры:
<!-- <p> внутри <p> — браузер закрывает первый -->
<p>Текст с <p>вложенным абзацем</p> внутри</p>
<!-- <a> внутри <a> — браузер закрывает первый -->
<a href="/outer">
Внешняя ссылка с <a href="/inner">вложенной</a>
</a>
<!-- <tr> вне <table> — браузер выкидывает -->
<div>
<tr>Это не работает</tr>
</div>
<!-- <li> вне <ul>/<ol> -->
<div>
<li>Без обёртки</li>
</div>На сервере React генерирует HTML с этими ошибками. Браузер при парсинге их «чинит». В итоге дерево DOM не совпадает с тем, что React ожидает, и гидрация валится.
Решение — писать валидный HTML. Используй ESLint с eslint-plugin-jsx-a11y, он ловит часть таких ошибок. На крайний случай — открой DevTools, посмотри Elements и сравни с тем, что видишь в JSX.
Бонусная причина: разные внешние данные
Если на сервере и клиенте делается отдельный fetch одних и тех же данных, и они приходят разные (например, кеш на сервере и не закешировано на клиенте) — будет mismatch.
В App Router этого редко возникает, потому что данные приходят с серверного компонента. Но если у тебя клиентский useQuery запрашивает то же самое — может расходиться.
Решение — Server Components передают данные пропсами или через HydrationBoundary от TanStack Query. Тогда клиент стартует с теми же данными.
Как искать причину
В режиме разработки React 18+ показывает «дифф» дерева:
Hydration failed because the initial UI does not match what was rendered on the server.
+ <p>1.05.2026</p>
- <p>5/1/2026</p>Эту разницу полезно искать в коде поиском по форматам. В большинстве случаев виновник находится за пять минут.
Если разница не очевидна — попробуй console.log внутри компонента. Лог покажет, что именно рендерится на сервере (в терминале Next dev) и на клиенте (в DevTools).
Как предотвращать
- Любые серверо-зависимые данные передавай пропсами от Server Components.
- Динамическую логику с
window,document,localStorageзапускай только вuseEffect. - Локали — всегда явные.
- Темы и язык — через cookie + inline-скрипт.
- HTML-валидность — ESLint и регулярная проверка.
suppressHydrationWarning: когда оно нормально
Этот атрибут — не «выключатель ошибки», а «я понимаю причину и принимаю последствия». Используй для:
- Времени, которое заведомо отличается на сервере и клиенте (но визуально неважно).
- Кастомных значений, которые рендерятся только на клиенте.
- Сторонних виджетов, которые модифицируют DOM до гидрации.
В остальных случаях — фикси причину, не глуши предупреждение.
Что копать дальше
Hydration mismatch — это всегда симптом несоответствия между серверным и клиентским рендером. Лекарство одно — синхронизировать источники истины. На небольших проектах за неделю можно полностью избавиться от этих ошибок и больше с ними не сталкиваться. Главное — поймать привычку проверять дерево после каждой правки SSR-логики.