Перейти к содержимому

12 минут чтения

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

несколько сообщений, например Text content does not match server-rendered HTML

React 19

одно сообщение Hydration failed because the server rendered HTML didn't match the client с диффом сервер/клиент

Vue 3.4+

Hydration text content mismatch, Hydration class mismatch и т. д. с указанием DOM-узла; проверяются текст, class, style и динамические атрибуты

Что 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

Текст

заменён клиентским, узел тот же

без <Suspense> пересоздан весь корень; внутри <Suspense> – только граница

class

остался серверным

остался серверным

style

остался серверным до следующей перерисовки компонента

–

Динамический атрибут title

исправлен при гидрации

остался серверным

Тип элемента

серверный узел удалён, создан новый, соседи не тронуты

–

Что видно в production

одна строка Hydration completed but contains mismatches. без указания элемента

о текстовой ошибке – код 418 через reportError; об атрибутах – ничего

Поведение 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 сообщение покажет серверную и клиентскую строку. Если они выглядят одинаково, сравните коды символов:

JavaScript
[...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:
Vue
<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) перестраивает типичные ошибки:

Сервер отдал

Браузер построил

<p><div>hi</div></p>

<p></p><div>hi</div><p></p>

<table><tr>…</tr></table>

<table><tbody><tr>…</tr></tbody></table>

<a>x<a>y</a></a>

<a>x</a><a>y</a>

<p>one<p>two</p></p>

<p>one</p><p>two</p><p></p>

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::

HTML
<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:
Vue
<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/schema 4.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 после загрузки.

Как включить подробности на время отладки:

TypeScript
// 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+: data-allow-mismatch

React: suppressHydrationWarning

Область

элемент; можно ограничить типом: text, children, class, style, attribute

элемент, один уровень вложенности

Текст на странице

всё равно заменяется клиентским, отключается только предупреждение

не исправляется, остаётся серверным до следующего рендера

Коротко

  • 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 подходят только для неизбежной разницы в значении, не в структуре.

Источники

  1. 01Server-Side Rendering (SSR)Vue.js
  2. 02runtime-core/src/hydration.tsGitHub, vuejs/coreпроверено по сборке 3.5.44
  3. 03hydrateRootreact.dev
  4. 04React v19react.dev · 2024
  5. 05Text content does not match server-rendered HTMLNext.js
  6. 06Nuxt and HydrationNuxt