lenec ru

← все посты

Next.js: Hydration failed because the server rendered HTML didn't match

10K

Эта ошибка — родная сестра базового «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 — значит реально несовпадение.

Лечение всегда одно: понять, что именно отличается между сервером и клиентом, и сделать так, чтобы первый рендер у них совпадал. Дальше уже обновляй данные через состояние — это безопасно.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.