Hydration mismatch в Nuxt и Next: 7 причин и как их найти
Hydration mismatch в Nuxt и Next.js: семь причин ошибки гидрации и как найти каждую. Плюс эксперимент на Vue 3.5 и React 19: что фреймворки делают с несовпавшим DOM и какие расхождения в продакшене остаются молча.
Hydration mismatch – это расхождение между HTML, который отрисовал сервер, и разметкой, которую фреймворк получает при первом рендере в браузере. Почти всегда причина одна из семи: даты и часовые пояса, случайные значения и id, рендер по ширине экрана, невалидная вложенность HTML, кэш на CDN, расширения браузера и ветки по typeof window. Красная ошибка в консоли – не худший вариант. Я проверил на Vue 3.5.44 и React 19.3.1, что происходит с несовпавшим DOM, и оказалось: несовпавший class во Vue и несовпавшие атрибуты в React остаются в DOM с серверными значениями, а в продакшене о них никто не сообщает.
Что такое гидрация и чем опасна ошибка гидрации
При SSR сервер выполняет компоненты и отдаёт готовый HTML. В браузере фреймворк выполняет те же компоненты ещё раз, сопоставляет каждый с уже существующим DOM-узлом и вешает обработчики событий. Этот шаг и называется гидрацией. Условие у него одно: первый клиентский рендер обязан дать ту же разметку, что и сервер.
Документация React прямо называет расхождения багами: в лучшем случае они замедляют страницу, в худшем обработчики событий привязываются не к тем элементам. Документация Nuxt добавляет ещё последствия: растёт время до интерактивности, контент мигает и сдвигается, а поисковик может проиндексировать не то, что видит пользователь.
Текст ошибки зависит от версии:
Фреймворк | Как выглядит ошибка |
|---|---|
React 18 | несколько сообщений, например |
React 19 | одно сообщение |
Vue 3.4+ | |
Что Vue и React делают с несовпавшим DOM: эксперимент
Документация описывает восстановление после ошибки общими словами: Vue пытается подстроить DOM под клиентское состояние, React восстанавливается после части ошибок. Чтобы понять, что именно остаётся на странице, я поставил эксперимент.
Методика эксперимента
- Версии: Vue 3.5.44, React и react-dom 19.3.1, jsdom 30.1.3, Node.js 22.23.4.
- Один компонент рендерится на сервере через
renderToStringс параметромside = 'server', затем гидрируется в jsdom сside = 'client'. Расходятся текст абзаца,class,style, атрибутtitleи, во Vue, тип элемента (spanна сервере,emна клиенте). Рядом лежат статичный абзац без расхождений и кнопка-счётчик. - До гидрации я сохраняю ссылки на DOM-узлы, после неё проверяю, остался ли на странице тот же узел и какое значение в нём записано. Затем кликаю по кнопке, чтобы проверить обработчики.
- Каждый сценарий прогоняется в development- и production-сборке (
NODE_ENV=production). - jsdom не браузер, и время здесь не измеряется. Но решение, какой узел оставить, какой пересоздать и какое значение записать, принимает JavaScript фреймворка, а не браузер, поэтому для этого вопроса jsdom подходит. Скорость гидрации в реальном браузере – отдельная тема.
Результаты
Что разошлось | Vue 3.5.44 | React 19.3.1 |
|---|---|---|
Текст | заменён клиентским, узел тот же | без |
| остался серверным | остался серверным |
| остался серверным до следующей перерисовки компонента | – |
Динамический атрибут | исправлен при гидрации | остался серверным |
Тип элемента | серверный узел удалён, создан новый, соседи не тронуты | – |
Что видно в production | одна строка | о текстовой ошибке – код 418 через |
Поведение Vue одинаковое в development и production. У React в development расхождение атрибутов попадает в console.error с пометкой, что оно не будет исправлено, а в production не попадает никуда: ни в консоль, ни в onRecoverableError.
Почему Vue исправляет title, но не class
Для class, style и атрибутов Vue сравнивает значения и пишет предупреждение, которое в исходном коде помечено как «check-only», то есть проверка без исправления DOM. Отдельно Vue вызывает патч для свойств, которые компилятор шаблона пометил как динамические пропсы. Привязка :title в этот список попадает, а :class и :style компилируются с другими флагами и не попадают.
style в эксперименте исправился после клика по кнопке: компонент перерисовался, а при обновлении Vue заново выставляет все свойства объекта стилей. class при перерисовке не меняется, пока не изменится его значение.
Что из этого следует
- В React цена одной текстовой ошибки зависит от ближайшей границы
<Suspense>. Без неё пересоздаётся весь корень, включая узлы, с которыми пользователь уже мог взаимодействовать до загрузки JS. - Мониторинг, который слушает
console.error, ошибки гидрации React 19 пропустит.reportErrorдоставляет ошибку в глобальный обработчик событияerror, как необработанное исключение.
Семь причин hydration mismatch
1. Даты, время и часовой пояс
Механизм. new Date(), toLocaleString() и Intl.DateTimeFormat без явных параметров берут часовой пояс и локаль окружения. Сервер и пользователь часто находятся в разных поясах, а Vue прямо пишет, что часовой пояс пользователя на сервере надёжно не узнать.
Для России это заметнее, чем кажется: часовых поясов много, и расходится не только час, но и дата. Мой расчёт в Node.js 22.23.4 для одного момента 2026-10-28T22:30:00Z и локали ru-RU:
Где рендерится | Результат |
|---|---|
Сервер в UTC | 28.10.2026, 22:30 |
Пользователь в Москве | 29.10.2026, 01:30 |
Пользователь во Владивостоке | 29.10.2026, 08:30 |
Есть второй, менее очевидный слой: результат Intl зависит от версии библиотеки ICU и данных CLDR внутри движка. В ICU 72 для английской локали между временем и AM/PM стали ставить узкий неразрывный пробел U+202F вместо обычного. Node.js получил это изменение в 19.1, и среди жалоб пользователей были в том числе ошибки гидрации. В V8 и Firefox в итоге добавили замену U+202F обратно на обычный пробел, чтобы не ломать сайты. Урок общий: одинаковый код с одинаковой локалью на разных версиях среды даёт разные байты, и на глаз этой разницы не видно.
Как найти. В development сообщение покажет серверную и клиентскую строку. Если они выглядят одинаково, сравните коды символов:
[...str].map((c) => c.charCodeAt(0).toString(16))Как исправить.
- Задавать
timeZoneиlocaleявно в параметрахIntl.DateTimeFormatна сервере и на клиенте. Это убирает зависимость от часового пояса окружения, но не от версии ICU: для мест, где важен каждый символ, надёжнее форматировать на клиенте. - Переводить в локальное время пользователя только на клиенте, после монтирования, как советует документация Vue.
- В Nuxt 3.17+ использовать
<NuxtTime>: компонент рендерит дату с одинаковым результатом на сервере и клиенте. - Если расхождение неизбежно, например «5 минут назад», подавить его точечно (об этом ниже).
2. Случайные значения и сгенерированные id
Механизм. Math.random(), crypto.randomUUID() и Date.now() в рендере вычисляются дважды, на сервере и в браузере, и дают разные значения. Чаще всего это всплывает в id для связки label for и aria-*.
Отдельный случай – счётчик на уровне модуля вроде let nextId = 0. Модули на сервере инициализируются один раз при старте и живут между запросами, поэтому серверный счётчик растёт от запроса к запросу, а клиентский каждый раз начинается с нуля.
Как найти. Сообщение об атрибуте id, for или aria-*, причём значения выглядят как сгенерированные.
Как исправить.
- Для id –
useId(): он есть во Vue с 3.5 и в React, а в Nuxt есть собственныйuseIdс 3.10, которому нужен компонент с одним корневым элементом. - Для случайного контента – сгенерировать значение на сервере и передать на клиент в состоянии. Документация Vue предлагает генератор с seed, который передаётся вместе с состоянием. В Nuxt достаточно
useState:
<script setup lang="ts">
const tip = useState('tip', () => Math.floor(Math.random() * tips.length))
</script>3. Рендер по ширине экрана и данным браузера
Механизм. Сервер не знает ширину окна, тему системы, содержимое localStorage и cookies, которые выставил клиентский скрипт. Код вроде v-if="window.innerWidth > 768" или чтение темы из localStorage в setup даёт на сервере одно дерево, в браузере другое. React в списке частых причин отдельно называет window.matchMedia в рендере.
Здесь работает находка из эксперимента. Если от ширины зависит только класс (:class="{ compact: isMobile }"), Vue в продакшене оставит серверный класс и ничего не скажет. Если от ширины зависит структура (v-if), Vue пересоздаст узлы, и пользователь увидит мигание.
Как найти. Откройте страницу в узком и широком окне: расхождение появляется только при одном из размеров.
Как исправить.
- Раскладку делать через CSS media queries, а не через JS. Это первый совет и в документации Nuxt.
- Тему хранить в cookie, а не в
localStorage: cookie доступна серверу, и в Nuxt она читается черезuseCookie. - Если логике действительно нужна ширина, явно сказать серверу, под какую ширину рендерить. В VueUse для этого есть опция
ssrWidthуuseMediaQueryиprovideSSRWidthдля всего приложения. - Двухпроходный рендер: на сервере и при гидрации показывать вариант по умолчанию, после монтирования переключаться. React предупреждает, что это замедляет гидрацию, потому что компоненты рендерятся дважды.
4. Невалидная вложенность HTML
Механизм. Сервер отдаёт HTML строкой, а браузер строит из неё дерево по правилам парсера из спецификации HTML и молча исправляет недопустимую вложенность. Фреймворк ждёт одну структуру, а находит другую. Так парсер jsdom (parse5 реализует спецификацию WHATWG) перестраивает типичные ошибки:
Сервер отдал | Браузер построил |
|---|---|
| |
| |
| |
| |
Next.js перечисляет те же случаи: <p> в <p>, <div> и списки в <p>, интерактивные элементы друг в друге. Например, так бывает, когда HTML из CMS со своими абзацами вставляется внутрь компонента, который сам оборачивает его в <p>.
Как найти. Сравните «Просмотр кода страницы» (исходный HTML) с вкладкой Elements в DevTools. Если узлы переставлены или появился tbody, которого нет в исходнике, причина в парсере. Сообщение обычно говорит о несовпадении узлов или числа дочерних элементов.
Как исправить. Только исправить разметку. Подавлять такое расхождение бессмысленно: разошлась структура, а не значение.
5. Кэш на CDN и устаревший HTML
Механизм. Здесь рендер правильный, но между сервером и браузером что-то меняет HTML или подменяет его другим. Вариантов три.
- Прокси переписывает HTML. Next.js приводит в пример Cloudflare Auto Minify. Сама функция удалена 5 августа 2024 года, но класс проблемы остался: любая оптимизация HTML на стороне CDN или прокси меняет то, что получит гидрация.
- Кэш не учитывает то, от чего зависит рендер. Если сервер рендерит страницу по cookie (тема, город, вариант A/B-теста), а CDN кэширует один HTML на URL, пользователь получит чужой вариант, а его браузер отрендерит свой. Кэш различает ответы по заголовкам, перечисленным в
Vary, и по ключу, который настроен на CDN. Всё, что влияет на рендер, должно в этот ключ попасть. - HTML и данные из разных моментов или сборок. В Nuxt для пререндеренных страниц данные по умолчанию выносятся в отдельный файл payload. Если HTML и payload кэшируются с разным сроком, они могут разойтись. В Next.js документация
deploymentIdописывает похожую ситуацию при поэтапном деплое: пользователь получает смесь старых и новых файлов.
Как найти. Ошибка воспроизводится только в продакшене, только у части пользователей или только после деплоя. Запросите страницу в обход кэша и через CDN и сравните HTML. Посмотрите заголовки ответа о попадании в кэш.
Как исправить.
- Не рендерить на сервере персональное в кэшируемых страницах, переносить это на клиент.
- Если персональное нужно в HTML, включить соответствующие cookie или заголовки в ключ кэша.
- Выдавать HTML и данные с согласованным сроком жизни. В Next.js задавать
deploymentId. - Отключить на CDN функции, которые переписывают HTML.
6. Расширения браузера и сам браузер
Механизм. Расширения браузера могут изменить DOM до того, как загрузится JavaScript. Next.js и сам текст ошибки React называют это одной из причин. React 19 стал терпимее: неожиданные теги в <head> и <body> при гидрации пропускаются, а стили, вставленные расширениями, сохраняются, даже если React перерисовывает документ целиком. Во Vue и Nuxt гидрация идёт только внутри корневого контейнера приложения (в Nuxt по умолчанию #__nuxt), поэтому вставки в body вне него на гидрацию не влияют.
Отдельный случай – сам браузер. Safari на iOS распознаёт в тексте телефоны, адреса и email и превращает их в ссылки, и Next.js называет это источником расхождений. Для сайтов с телефоном в шапке и подвале это актуально.
Как найти. Ошибка не воспроизводится в режиме инкогнито без расширений или воспроизводится только на iPhone.
Как исправить. Расширения вы не контролируете, поэтому сначала убедитесь, что причина в них, и не тратьте время на поиск бага в коде. В React 19 теги, вставленные в <head> и <body>, гидрацию уже не ломают. Для iOS Next.js советует мета-тег, а телефон на странице лучше сразу оформить ссылкой tel::
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />7. Условия по typeof window и import.meta.client
Механизм. Ветка if (typeof window !== 'undefined') или if (import.meta.client) прямо в рендере по определению даёт на сервере одно дерево, а в браузере другое. React ставит эту причину первой в тексте ошибки, документация Nuxt показывает её на примере инициализации библиотеки в setup. Проблема в моменте: на клиенте условие уже истинно во время гидрации, хотя DOM ещё серверный.
Как найти. Поиск по проекту: typeof window, typeof document, process.client, import.meta.client вне хуков монтирования.
Как исправить.
- Переносить браузерный код в
onMountedво Vue или вuseEffectв React. На сервере эти хуки не вызываются, а во время гидрации ещё не выполнены. - Оборачивать браузерные компоненты в
<ClientOnly>в Nuxt или подключать черезdynamic(..., { ssr: false })в Next.js. Актуальная документация React для компонентов, которые должны рендериться только в браузере, предлагаетuse(browser())вместо ожидания эффекта. - Заглушке на сервере задавать тот же размер, что и у будущего компонента, иначе вместо ошибки гидрации получится сдвиг макета. У
<ClientOnly>для этого есть слот#fallback:
<ClientOnly>
<PriceChart />
<template #fallback>
<div class="chart-placeholder" />
</template>
</ClientOnly>Как найти hydration mismatch в продакшене
Development-сборка показывает подробности, production почти молчит. Что можно сделать:
- Vue без фреймворка. Флаг сборки
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__(Vue 3.4+, по умолчанию выключен) включает подробные сообщения в production. Он добавляет код в бандл, поэтому включать его стоит на время отладки. В Vite флаг задаётся черезdefine. - Nuxt. С версии 3.16 опция
debugпринимает объект, и в нём есть ключhydration. В исходниках@nuxt/schema4.6.3 я проверил, чтоdebug: { hydration: true }илиdebug: trueвключает тот же флаг. Модуль Nuxt Hints показывает в DevTools серверный и клиентский HTML рядом, с подсветкой разницы. - React и Next.js. В
hydrateRootможно передатьonRecoverableErrorи отправлять ошибки в мониторинг. Если корень создаёт фреймворк, как в Next.js, ловите глобальное событиеerror: по умолчанию React 19 отправляет ошибку черезreportError. Атрибутные расхождения в production не видны вообще, их можно поймать только в development. - Автотест. E2E-тест слушает консоль браузера и падает, если там появилось сообщение о гидрации. Тест должен идти по development-сборке, иначе атрибутные расхождения React и подробности Vue пропадут, а для React нужно слушать и событие
error, не только консоль. - Вручную. Отключите JavaScript, сохраните HTML страницы, включите JavaScript и сравните с DOM после загрузки.
Как включить подробности на время отладки:
// nuxt.config.ts
export default defineNuxtConfig({
debug: { hydration: true },
})
// vite.config.ts, Vue без Nuxt
export default defineConfig({
define: { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true' },
})Когда расхождение можно подавить
Иногда разница неизбежна: «обновлено 3 минуты назад», случайный совет дня. Для таких мест есть точечное подавление, но работает оно в двух фреймворках по-разному.
| Vue 3.5+: | React: |
|---|---|---|
Область | элемент; можно ограничить типом: | элемент, один уровень вложенности |
Текст на странице | всё равно заменяется клиентским, отключается только предупреждение | не исправляется, остаётся серверным до следующего рендера |
Коротко
- Hydration mismatch почти всегда сводится к одной из семи причин: время, случайность, данные браузера, невалидная вложенность, кэш, расширения, ветки по окружению.
- Vue 3.5 исправляет текст и структуру, но оставляет серверный
class; React 19 не исправляет атрибуты, а из-за одной текстовой ошибки без<Suspense>пересоздаёт весь корень. - В production и Vue, и React почти ничего не сообщают: включайте
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__илиdebug.hydrationв Nuxt на время отладки, а в React ловитеonRecoverableErrorили глобальное событиеerror. - Даты форматируйте с явными
timeZoneиlocale, id получайте черезuseId, раскладку по ширине делайте в CSS. - На кэшируемых страницах ничего персонального не рендерите на сервере или включайте это в ключ кэша.
data-allow-mismatchиsuppressHydrationWarningподходят только для неизбежной разницы в значении, не в структуре.
Источники
- 01Server-Side Rendering (SSR)Vue.js
- 02runtime-core/src/hydration.tsGitHub, vuejs/coreпроверено по сборке 3.5.44
- 03hydrateRootreact.dev
- 04React v19react.dev · 2024
- 05Text content does not match server-rendered HTMLNext.js
- 06Nuxt and HydrationNuxt