Ошибка с гидратацией в Next.js остаётся одной из самых раздражающих проблем для разработчиков, перешедших на App Router. Симптом всегда узнаваем: в консоли всплывает красная надпись Text content does not match server-rendered HTML, страница на мгновение мигает, и React честно признаётся, что серверная разметка не совпала с тем, что собрался отрендерить клиент. Часть команд решает проблему стандартным движением - оборачивают подозрительный участок в suppressHydrationWarning и закрывают вкладку с консолью. Беда в том, что это атрибут-обманка. Он не чинит расхождение, а только глушит предупреждение, и в большинстве случаев скрывает за собой реальный архитектурный дефект, который потом всплывает в продакшене при первой же нестандартной нагрузке.

Чтобы пользоваться suppressHydrationWarning разумно, нужно сначала понять, что вообще происходит во время гидратации, какие классы расхождений существуют, и где этот атрибут уместен, а где он лишь маскирует надвигающуюся катастрофу.

Что делает React во время гидратации и почему он так чувствителен к расхождениям

Гидратация это процесс, в котором браузер получает готовый HTML с сервера, отображает его сразу же, а параллельно React загружает JavaScript-бандл и пробегается по DOM-дереву, привязывая обработчики событий и оживляя статичную разметку. Ключевое условие здесь жёсткое: разметка, которую React собирается построить на клиенте при первом рендере, должна совпасть с тем, что прислал сервер, до последнего символа. Любое расхождение и React выбрасывает предупреждение в дев-режиме, а в продакшене либо чинит расхождение через перерендеринг, либо ломается с непредсказуемыми визуальными эффектами.

Жёсткость требования объясняется природой задачи. React не сравнивает деревья по смыслу, он сверяет их по структуре - тип элемента, атрибуты, текстовое содержимое, порядок дочерних узлов. Если сервер отдал параграф с текстом "Загружено в 14:32", а клиент во время первого рендера решил написать "Загружено в 14:33", потому что прошла минута, это уже мисматч. Если сервер вывел div, а клиент почему-то возвращает section, это мисматч. Если сервер собрал три карточки, а клиент решил, что нужно четыре, потому что в localStorage сохранена другая настройка - снова мисматч. Никаких полутонов нет, либо побайтовое совпадение, либо ошибка.

App Router усугубил ситуацию по сравнению со старым Pages Router тем, что разделение на серверные и клиентские компоненты стало явной частью архитектуры. Серверные компоненты выполняются один раз на сервере и не имеют доступа к window, localStorage, navigator или любому другому браузерному API. Клиентские компоненты с директивой "use client" исполняются и на сервере при первом рендере для SSR, и потом на клиенте при гидратации. Понимание того, какой компонент когда выполняется, оказалось для многих неочевидным, и львиная доля мисматчей в App Router возникает именно из-за неправильного представления о границе между этими двумя мирами.

Типичная причина первая - время, дата и случайные значения

Самый частый источник мисматчей это работа с датой и временем. Достаточно невинного кода, чтобы получить нерабочую страницу:

export default function Footer() {
  return (
    <footer>
      <p>© {new Date().getFullYear()} Компания</p>
      <p>Сейчас: {new Date().toLocaleString()}</p>
    </footer>
  );
}

Год тут безопасен только до момента, пока сервер и клиент не оказываются по разные стороны полуночи 31 декабря по часовому поясу сервера - в этот момент серверный рендер вернёт уходящий год, а клиент уже новый. Но toLocaleString убивает компонент гарантированно. Сервер отрендерит время в часовом поясе хостинга, обычно UTC, а клиент покажет локальное время пользователя, и эти строки никогда не совпадут. То же касается Math.random для генерации случайных идентификаторов, Date.now для меток времени, любых вычислений, опирающихся на текущий момент.

Правильное решение зависит от того, что именно нужно вывести. Если значение должно совпадать на сервере и клиенте, его передают сверху как пропс из серверного компонента или из API:

import dynamic from "next/dynamic";

export default async function Page() {
  const serverTime = new Date().toISOString();
  return <Footer initialTime={serverTime} />;
}

Если значение принципиально клиентское, как локальное время пользователя, его получают уже после монтирования через useEffect, а до этого рендерят пустое место или плейсхолдер:

"use client";

import { useEffect, useState } from "react";

export function LocalTime() {
  const [time, setTime] = useState<string | null>(null);

  useEffect(() => {
    setTime(new Date().toLocaleString());
  }, []);

  if (time === null) {
    return <span>--:--</span>;
  }

  return <span>{time}</span>;
}

Главное здесь это сохранение структуры. Сервер и первый клиентский рендер выдают одинаковый узел с одинаковым содержимым "--:--", а реальное локальное время появляется уже после хука useEffect, который на сервере не выполняется. Никакого расхождения React не видит, потому что обновление пришло уже после успешной гидратации.

Типичная причина вторая - проверка ширины окна и медиазапросы в JSX

Второй классический случай - попытка отрендерить разную разметку в зависимости от размера окна. Логика выглядит вполне разумной для разработчика, привыкшего к чисто клиентским React-приложениям:

"use client";

export function ResponsiveNav() {
  const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
  return isMobile ? <MobileMenu /> : <DesktopMenu />;
}

На сервере window не существует, проверка typeof возвращает undefined, и условие даёт false. Сервер отдаёт DesktopMenu. Клиент на маленьком экране при первом рендере видит mobile-устройство и хочет отрендерить MobileMenu. Дерево не сходится, мисматч.

Правильный подход тут вообще не использует JavaScript для определения размера экрана. Адаптивность строится через CSS с media-запросами, а компоненты отдают разметку, готовую к показу в любом размере. Если по каким-то причинам это невозможно и нужна именно ветвление в JSX, придерживаются той же тактики, что и со временем - до монтирования рендерится один универсальный вариант, после useEffect компонент перерисовывается под актуальный размер:

"use client";

import { useEffect, useState } from "react";

export function ResponsiveNav() {
  const [mounted, setMounted] = useState(false);
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    const check = () => setIsMobile(window.innerWidth < 768);
    check();
    window.addEventListener("resize", check);
    setMounted(true);
    return () => window.removeEventListener("resize", check);
  }, []);

  if (!mounted) {
    return <DesktopMenu />;
  }

  return isMobile ? <MobileMenu /> : <DesktopMenu />;
}

Принципиальная идея проста: первый рендер на клиенте обязан повторить серверный, а уже потом, после успешной гидратации, компонент свободен меняться как угодно.

Типичная причина третья - данные из localStorage и cookies на клиенте

Темы оформления, языковые предпочтения, состояние корзины, токены сессии - всё это часто хранится в localStorage или sessionStorage и читается при инициализации компонентов. Серверный рендер о существовании этих хранилищ не знает в принципе:

"use client";

export function ThemedHeader() {
  const theme = localStorage.getItem("theme") || "light";
  return <header className={`theme-${theme}`}>...</header>;
}

Этот код сломается сразу в двух местах. Сервер выкинет ReferenceError на localStorage. Если обернуть в проверку typeof window, сервер вернёт "light", клиент с сохранённой тёмной темой вернёт "dark", и снова мисматч. Шаблон решения остаётся тем же:

"use client";

import { useEffect, useState } from "react";

export function ThemedHeader() {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  useEffect(() => {
    const saved = localStorage.getItem("theme") as "light" | "dark" | null;
    if (saved) setTheme(saved);
  }, []);

  return <header className={`theme-${theme}`}>...</header>;
}

Минус подхода - вспышка светлой темы перед тем, как клиент успеет прочитать localStorage и применить тёмную. Эту проблему называют FOUC, flash of unstyled content, и её решают через инлайн-скрипт в head, который выполняется синхронно до начала рендера React и проставляет нужный класс на html-элемент:

export default function RootLayout({ children }) {
  return (
    <html lang="ru" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              try {
                const t = localStorage.getItem("theme") || "light";
                document.documentElement.classList.add("theme-" + t);
              } catch (e) {}
            `,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

Скрипт меняет атрибуты html до того, как React к нему прикоснётся, и именно здесь впервые появляется законное применение suppressHydrationWarning на корневом элементе - React при гидратации увидит, что класс на html не совпадает с серверной версией, но мы заранее предупредили его об этом.

Когда suppressHydrationWarning действительно уместен и его реальные ограничения

Атрибут suppressHydrationWarning придуман как escape hatch для ситуаций, когда расхождение неизбежно по природе задачи и контролируется разработчиком. Из документации React следует жёсткое правило: атрибут работает только на один уровень в глубину и не предназначен для маскировки структурных проблем. Если повесить его на div, React проигнорирует расхождения непосредственно в этом div, но не в его потомках.

Подходящих кейсов на практике три. Первый - элементы, содержимое которых заведомо отличается между сервером и клиентом, и это так и задумано. Классический пример это вывод текущего времени с динамическим обновлением, где значение на сервере и клиенте принципиально разное:

"use client";

import { useEffect, useState } from "react";

export function LiveClock() {
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <time suppressHydrationWarning>
      {now.toLocaleTimeString()}
    </time>
  );
}

Второй случай - атрибуты, которые добавляются в DOM сторонними агентами до того, как React успеет провести гидратацию. Это могут быть браузерные расширения вроде менеджеров паролей, систем перевода или утилит для разработчиков. Расширение Grammarly, например, добавляет на body атрибуты вроде data-new-gr-c-s-check-loaded, ColorZilla подсыпает cz-shortcut-listen, и в чистом продакшене на странице конкретного пользователя могут оказаться десятки таких модификаций. На html и body имеет смысл всегда ставить suppressHydrationWarning именно из этих соображений:

export default function RootLayout({ children }) {
  return (
    <html lang="ru" suppressHydrationWarning>
      <body suppressHydrationWarning>
        {children}
      </body>
    </html>
  );
}

Это безопасно ровно потому, что подавление работает только на один уровень - расхождения внутри children всё равно будут выявлены и зарепортены. Сам же html и body не должны меняться в зависимости от рендера, и любой реальный мисматч на этом уровне всё равно был бы признаком чего-то очень нездорового.

Третий уместный случай - контейнеры, в которые сторонний код инжектит контент через dangerouslySetInnerHTML или прямую работу с DOM. Реклама, виджеты соцсетей, embedded-плееры - всё это после первой загрузки страницы меняет своё содержимое, и притворяться, что разметка статична, бессмысленно:

export function AdSlot({ slotId }) {
  return (
    <div 
      id={slotId} 
      suppressHydrationWarning 
      data-ad-slot
    />
  );
}

Во всех остальных ситуациях атрибут это симптом, что разработчик решил спрятать проблему вместо того, чтобы её решить. Особенно опасны попытки повесить suppressHydrationWarning на крупные секции страницы или на корневой элемент клиентского компонента в надежде "просто пусть работает". Это не работает по двум причинам. Во-первых, подавление действует только на один уровень, и реальные ошибки внутри всё равно проявятся. Во-вторых, если ошибки и подавляются, React при мисматче выкидывает соответствующий узел и перерендеривает его на клиенте, что ломает все вложенные ref, переключает фокус, рвёт анимации и теряет состояние компонентов в этой ветке.

Когда нужен не suppressHydrationWarning, а dynamic с ssr false

Если компонент по своей природе не может быть отрендерен на сервере - например, использует canvas, обращается к WebRTC, инициализирует библиотеку, которая опирается на window в момент импорта - правильное решение это не подавление предупреждения, а полный отказ от серверного рендеринга для этого узла. Next.js предоставляет специальный механизм:

import dynamic from "next/dynamic";

const ChartComponent = dynamic(
  () => import("./HeavyChart"),
  { ssr: false, loading: () => <div>Загрузка графика...</div> }
);

export default function Dashboard() {
  return (
    <main>
      <h1>Аналитика</h1>
      <ChartComponent />
    </main>
  );
}

Опция ssr: false означает, что компонент вообще не будет отрендерен на сервере, и его место займёт loading-плейсхолдер. После загрузки JavaScript на клиенте dynamic подгрузит реальный компонент и заменит плейсхолдер. Никакого мисматча тут возникнуть не может в принципе, потому что нечему расходиться - сервер плейсхолдер прислал, клиент его и увидел.

Минус подхода это потеря SSR-преимуществ для конкретного компонента. Контент появится позже, чем остальная страница, и поисковые роботы его не увидят при первом проходе. Поэтому dynamic с ssr: false разумно применять только к тем частям, которые либо не имеют SEO-ценности, либо физически не могут работать на сервере. Для текстового контента, заголовков, ссылок этот метод не подходит.

Диагностика мисматча и поиск истинной причины

Когда мисматч возникает в реальном проекте, первое, что хочется сделать, это найти конкретное место расхождения. Сообщение React в консоли обычно показывает участок DOM-дерева вокруг проблемы, но не всегда указывает на точный компонент. Пара приёмов помогает сузить поиск.

Сравнение источника страницы и реального DOM. Через View Page Source в браузере смотрят на исходный HTML, который прислал сервер. Через DevTools видят итоговое состояние DOM после гидратации. Различия часто видны невооружённым глазом - где-то изменился класс, где-то атрибут, где-то добавился узел. Это первое место, куда смотреть.

Изоляция через постепенное упрощение. Если мисматч ловится в большом компоненте, его временно сокращают до минимума, постепенно возвращая части обратно, пока мисматч не воспроизведётся. Это скучный, но надёжный путь, особенно если ошибка появляется только в продакшене и не воспроизводится в дев-режиме.

Сравнение поведения в инкогнито-режиме. Если ошибка пропадает в режиме инкогнито без расширений, причина с высокой вероятностью кроется во внешней модификации DOM каким-то браузерным аддоном. В этом случае не нужно ничего исправлять в коде, кроме как поставить suppressHydrationWarning на body. Многие новички тратят дни на поиск проблемы в собственном коде, тогда как реальная причина это плагин для проверки орфографии в браузере конкретного пользователя.

Включение строгого режима. В режиме разработки React Strict Mode вызывает компоненты дважды, что помогает обнаружить нечистые рендеры и побочные эффекты в местах, где их быть не должно. Активируется через next.config.js:

module.exports = {
  reactStrictMode: true,
};

Многие потенциальные мисматчи всплывают именно в строгом режиме, ещё до того, как успеют проявиться в продакшене.

Стратегия профилактики на уровне архитектуры

Точечные исправления отдельных мисматчей решают проблему здесь и сейчас, но не уменьшают вероятность появления новых. Долгосрочное решение лежит в области архитектурных принципов, которыми руководствуется команда при работе с App Router.

Полезные правила выглядят следующим образом:

  1. серверные компоненты пишут так, чтобы их вывод был детерминистичен - никаких Math.random, никаких обращений к Date.now без явной передачи значения сверху, никаких ветвлений по локали без явного параметра;
  2. клиентские компоненты, которым нужны браузерные API, выводят браузерно-зависимый контент только после useEffect, а до этого рендерят нейтральный плейсхолдер;
  3. адаптивность строится на CSS, а не на ветвлении JSX по window.innerWidth - это и быстрее, и не создаёт мисматчей;
  4. сторонние виджеты и компоненты, которые что-то делают с DOM, изолируются в client-only слоты через dynamic с ssr: false;
  5. на корневых html и body всегда стоит suppressHydrationWarning - это страховка от расширений браузера, и она не маскирует реальные ошибки.

Главное понимание, которое стоит зафиксировать, это разница между подавлением предупреждения и исправлением проблемы. Атрибут suppressHydrationWarning легитимен только для случаев, где расхождение между сервером и клиентом является намеренной частью архитектуры, а не результатом ошибки. Каждый раз, когда возникает желание повесить этот атрибут на компонент, стоит остановиться и задать себе вопрос: понимаю ли я, что именно отличается между сервером и клиентом, и почему. Если внятного ответа нет, suppressHydrationWarning превратится в кляп, заглушающий тревожный звонок, и через несколько релизов команда будет ловить странные баги, причину которых уже никто не сможет восстановить.

Гидратация в Next.js это контракт между двумя средами выполнения, и работает он ровно до тех пор, пока обе стороны соблюдают условия. Сделать сервер и клиент детерминированно одинаковыми в первый момент жизни страницы это та цена, которую платят за серверный рендеринг, и платить её приходится осмысленно, а не через волшебные атрибуты, которые делают вид, что проблемы нет. Команда, которая принимает этот принцип, через несколько месяцев перестаёт натыкаться на мисматчи как класс ошибок, потому что начинает видеть их структуру ещё на этапе написания компонента.