Реклама
Меморина
Меморина
Меморина

Как исправить hydration-ошибки RSC в Next.js: практический гид

Hydration mismatch в Next.js легко поймать в dev, но в production он превращается в минимизированный код ошибки. Разбираем типичные причины и рабочий процесс отладки.

Обложка: Как исправить hydration-ошибки RSC в Next.js: практический гид

Hydration mismatch в Next.js легко поймать в next dev, но в production он превращается в минимизированный код ошибки React и ссылку на декодер. В статье разбираем, почему ошибки гидратации особенно болезненны в приложениях с React Server Components, какие причины встречаются чаще всего и какой рабочий процесс помогает находить и предотвращать такие баги.

Речь пойдёт не о теории reconcile-алгоритма, а о практике: что проверять первым делом, как изолировать проблему через Suspense, куда смотреть в production-логах и как писать тесты, которые ловят регрессии до попадания к пользователям.

Что такое hydration mismatch

Гидратация — это процесс, при котором React берёт серверный HTML и навешивает на него обработчики событий, после чего страница становится интерактивной. React ожидает, что дерево, которое он отрендерил на клиенте, точно совпадёт с тем, что пришло с сервера. Если строки, атрибуты или структура различаются, возникает hydration mismatch.

В development-режиме React покажет предупреждение, текст расхождения и стек компонента. В production всё сводится к коротким кодам вроде #418 или #425. Без телеметрии вы не узнаете, на каком маршруте, в каком компоненте и из-за каких данных произошёл сбой.

Почему это больно именно в RSC

В классическом SSR обычно один серверный рендер и один клиентский проход гидратации. App Router добавляет Server Components, Client Components, потоковую передачу, Suspense-границы, динамические данные маршрута и RSC payload, который едет рядом с HTML.

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

Ключевые выводы

Ключевые выводы
  • Hydration mismatch в production без телеметрии почти невозможно локализовать: нужна инструментация на клиенте.
  • Основные причины: browser-only API, дата/время/локаль, состояние авторизации, невалидный HTML, расширения браузера, CSS-in-JS.
  • Граница 'use client' — это не только маркер сборки, но и граница гидратации: серверный рендер должен быть детерминирован.
  • Suspense-границы помогают изолировать сбой и не давать ему сломать всё дерево.
  • Проверяйте hydration только на production-сборке: next dev ведёт себя иначе.
  • Добавьте Playwright-smoke-тесты на критичные маршруты, чтобы ловить регрессии в CI.

Самые частые причины

Перед тем как копать RSC payload или сравнивать HTML, стоит проверить шесть типовых сценариев. Они покрывают подавляющее большинство production-инцидентов.

  • Browser-only API во время рендера: window, document, localStorage, navigator — сервер не знает об этих значениях.
  • Дата, время и локаль: Date, Intl.DateTimeFormat, относительное время — сервер обычно в UTC, пользователь в своей зоне.
  • Состояние авторизации: сервер рендерит выклогнутый UI, клиент сразу видит авторизованного пользователя.
  • Невалидный HTML: браузер чинит DOM до гидратации, а React сравнивает с исходным деревом.
  • Расширения и middleware: браузерные плагины и edge-переписывания меняют HTML.
  • CSS-in-JS и порядок классов: styled-components и Emotion могут генерировать разные имена классов при стриминге.

Browser-only API и сторонние провайдеры

Самая частая причина — не ваш собственный код, а сторонний провайдер, который читает localStorage, window или navigator при инициализации. Под это попадают аналитика, фичер-флаги, A/B-тесты, session replay и персонализация.

Проблемный вариант: провайдер читает localStorage прямо при рендере.

			// app/layout.tsx
import { FeatureFlagProvider } from '@acme/feature-flags';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {/* Провайдер обращается к localStorage уже на первом рендере */}
        <FeatureFlagProvider>{children}</FeatureFlagProvider>
      </body>
    </html>
  );
}
		

Исправление: серверные флаги передаются в Client Component, а локальные оверрайды применяются после гидратации.

			// components/feature-flag-provider-wrapper.tsx
'use client';
import { useEffect, useState } from 'react';
import { FeatureFlagProvider } from '@acme/feature-flags';

export function FeatureFlagProviderWrapper({
  children,
  serverFlags,
}: {
  children: React.ReactNode;
  serverFlags: Record<string, boolean>;
}) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return <FeatureFlagProvider flags={serverFlags}>{children}</FeatureFlagProvider>;
  }

  return (
    <FeatureFlagProvider flags={serverFlags} enableLocalOverrides>
      {children}
    </FeatureFlagProvider>
  );
}
		
Принцип: серверный и первый клиентский рендер должны получить одинаковый результат. Браузерные оверрайды включаются в useEffect после монтирования.

Дата, время и локаль

Сервер часто работает в UTC, а пользователь — в своей зоне. Date.toLocaleString(), Intl.DateTimeFormat и функции вроде formatDistanceToNow() дают разные строки в зависимости от часового пояса и времени между рендером и гидратацией.

Проблемный вариант: относительное время считается на сервере.

			// components/post-meta.tsx
export async function PostMeta({ post }: { post: Post }) {
  const timeAgo = formatDistanceToNow(new Date(post.publishedAt), { addSuffix: true });
  return (
    <div className="post-meta">
      <span className="author">{post.author.name}</span>
      <span className="timestamp">{timeAgo}</span>
    </div>
  );
}
		

Исправление: сервер отдаёт стабильную абсолютную дату, клиент заменяет её на относительную после монтирования.

			// components/relative-time.tsx
'use client';
import { useEffect, useState } from 'react';
import { format, formatDistanceToNow } from 'date-fns';

export function RelativeTime({ dateTime }: { dateTime: string }) {
  const [relativeTime, setRelativeTime] = useState<string | null>(null);
  const date = new Date(dateTime);

  useEffect(() => {
    setRelativeTime(formatDistanceToNow(date, { addSuffix: true }));
    const interval = setInterval(() => {
      setRelativeTime(formatDistanceToNow(date, { addSuffix: true }));
    }, 60_000);
    return () => clearInterval(interval);
  }, [dateTime]);

  return (
    <time dateTime={dateTime} suppressHydrationWarning>
      {relativeTime ?? format(date, 'd MMMM yyyy')}
    </time>
  );
}
		

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

Состояние авторизации

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

Решение — читать куки на сервере через cookies() из next/headers.

			// components/site-header.tsx
import { cookies } from 'next/headers';
import { validateSession } from '@/lib/auth';

export async function SiteHeader() {
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get('session_token')?.value;
  const session = sessionToken ? await validateSession(sessionToken) : null;

  return (
    <header>
      <nav>
        {session ? <UserAvatar user={session.user} /> : <LoginButton />}
      </nav>
    </header>
  );
}
		

С cookies() маршрут становится динамическим — это обычно правильный компромисс для UI, зависящего от авторизации. Начиная с Next.js 15, cookies() асинхронный и требует await. В Next.js 16 синхронный доступ к request-time API полностью уходит.

Невалидный HTML

Браузер молча чинит невалидную вёрстку. React же гидратирует не исходную строку, а DOM, который построил браузер. Классический пример —

внутри

: браузер закроет параграф до div, и React увидит другое дерево.

			<p>
  Great product.
  <div class="callout">Note: Ships in 3–5 days.</div>
</p>
		

Браузер превратит это в примерно такую структуру:

			<p>Great product.</p>
<div class="callout">Note: Ships in 3–5 days.</div>
<p></p>
		

Если HTML приходит из CMS или редактора, валидируйте и нормализуйте его на сервере. Для собственных компонентов следите за предупреждениями validateDOMNesting в DevTools.

Расширения браузера и middleware

Менеджеры паролей, переводчики, грамматические плагины и другие расширения могут вставлять или переупорядочивать узлы до гидратации. Edge middleware и CDN-трансформации тоже могут переписывать HTML в пути.

Если несовпадение затрагивает только <html> или <body>, сначала подозревайте расширения. React 19 стал терпимее к инъекциям в head/body, но на старых версиях такие ошибки особенно шумные.

			// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ru" suppressHydrationWarning>
      <body suppressHydrationWarning>{children}</body>
    </html>
  );
}
		

suppressHydrationWarning на корневых элементах — документированный escape hatch, но он работает только на один уровень вглубь и не должен использоваться для сокрытия неизвестных расхождений в продуктовом UI.

CSS-in-JS и порядок классов

В проектах со styled-components или Emotion имена классов могут зависеть от порядка рендера. Стриминг и Suspense меняют этот порядок, поэтому нужен registry с useServerInsertedHTML.

			// app/styled-components-registry.tsx
'use client';
import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';

export function StyledComponentsRegistry({ children }: { children: React.ReactNode }) {
  const [sheet] = useState(() => new ServerStyleSheet());

  useServerInsertedHTML(() => (
    <style dangerouslySetInnerHTML={{ __html: sheet.instance.toString() }} />
  ));

  if (typeof window !== 'undefined') {
    return <>{children}</>;
  }

  return <StyleSheetManager sheet={sheet.instance}>{children}</StyleSheetManager>;
}
		

Production workflow для отладки

Не начинайте с diff-а огромных HTML-документов. Работайте по порядку, сужая область поиска.

  1. Настройте клиентскую телеметрию: перехватывайте console.error и отправляйте hydration-ошибки на свой endpoint.
  2. Воспроизводите баг на production-сборке: next build && next start, а не next dev.
  3. Используйте Suspense-границы, чтобы изолировать проблемный участок и понять, в каком поддереве ошибка.
  4. Сравнивайте серверный HTML (curl) и клиентский DOM после гидратации.
  5. Когда найдёте расходящийся элемент, проверьте: browser-only API, дата/время, авторизацию, HTML-вложенность, расширения, стили.

Инструментация: instrumentation-client.ts

Файл instrumentation-client.ts в Next.js запускается после загрузки документа, но до гидратации React. Это удобная точка для лёгкой клиентской телеметрии.

			// instrumentation-client.ts
const originalConsoleError = console.error;

console.error = (...args: unknown[]) => {
  const message = typeof args[0] === 'string' ? args[0] : '';
  const isHydrationError =
    message.includes('418') ||
    message.includes('425') ||
    message.includes('Hydration') ||
    message.includes('hydration') ||
    message.includes('did not match');

  if (isHydrationError) {
    const payload = JSON.stringify({
      message: args.map(String).join(' '),
      url: window.location.href,
      timestamp: Date.now(),
      userAgent: navigator.userAgent,
    });

    const blob = new Blob([payload], { type: 'application/json' });
    navigator.sendBeacon('/api/telemetry/hydration-error', blob);
  }

  originalConsoleError.apply(console, args);
};
		

Если используете LogRocket, Sentry или другой инструмент, прикрепите тот же payload к текущей сессии — тогда можно будет увидеть, как выглядела страница в момент сбоя.

Изоляция через Suspense

Suspense-границы не только показывают fallback при загрузке. Если hydration mismatch случается внутри границы, React может ограничить восстановление этим поддеревом, а не переключать весь root на клиентский рендер.

			// app/products/[slug]/page.tsx
import { Suspense } from 'react';

export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const product = await fetchProduct(slug);

  return (
    <main>
      <Suspense fallback={<ProductHeroSkeleton />}>
        <ProductHero product={product} />
      </Suspense>
      <Suspense fallback={<ProductDetailsSkeleton />}>
        <ProductDetails product={product} />
      </Suspense>
    </main>
  );
}
		

Оберните поочерёдно основные секции. Если страничная ошибка исчезает после оборачивания конкретной секции, баг почти наверняка внутри неё.

Сравнение HTML и DOM

Получите серверный HTML через curl с нужными куками:

			curl -s "https://yourapp.com/products/running-shoes-v2" \
  -H "Cookie: session_token=YOUR_SESSION_TOKEN" \
  > /tmp/server-render.html
		

В Chrome DevTools после загрузки страницы скопируйте живой DOM:

			copy(document.documentElement.outerHTML)
		

Сохраните результат в /tmp/client-render.html и сравните:

			diff /tmp/server-render.html /tmp/client-render.html | head -100
		

Этот метод хорошо ловит HTML-слой, но может пропустить расхождения на уровне reconciler-а и RSC payload. Если raw HTML совпадает, проверяйте Client Components и браузерное состояние.

Профилактика

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

  1. Server Component должен быть детерминированным: одни и те же входные данные дают одни и те же выходные HTML и RSC payload.
  2. Все browser-only API, куки, заголовки, локаль и время должны оставаться за границей 'use client' или читаться через request-time API на сервере.
  3. Для клиентского UI сначала рендерите стабильный baseline, а улучшения добавляйте в useEffect после гидратации.
  4. Валидируйте HTML из внешних источников до того, как React его увидит.
  5. Тестируйте hydration на production-сборке в CI.

Пример: стабильный baseline + клиентское улучшение

			// components/recently-viewed.tsx
'use client';
import { useEffect, useState } from 'react';

export function RecentlyViewed({ currentProductId }: { currentProductId: string }) {
  const [products, setProducts] = useState<Product[] | null>(null);

  useEffect(() => {
    setProducts(getRecentlyViewed(currentProductId));
  }, [currentProductId]);

  if (products === null) {
    return <RecentlyViewedSkeleton />;
  }

  if (products.length === 0) {
    return null;
  }

  return (
    <section aria-label="Недавно просмотренные">
      <h2>Недавно просмотренные</h2>
      <div className="product-grid">
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </section>
  );
}
		

Скелетон — это стабильная базовая разметка. Карточки товаров появляются после монтирования. Сервер и первый клиентский рендер совпадают, а пользователь получает нужный контент чуть позже.

Smoke-тесты в CI

Ручное тестирование пропустит регрессии. Добавьте Playwright-тест на критичные маршруты.

			// tests/hydration.spec.ts
import { test, expect } from '@playwright/test';

const CRITICAL_ROUTES = ['/', '/products/test-product-slug', '/dashboard', '/checkout'];

test.describe('Hydration smoke tests', () => {
  for (const route of CRITICAL_ROUTES) {
    test(`no hydration errors on ${route}`, async ({ page }) => {
      const hydrationErrors: string[] = [];

      page.on('console', (msg) => {
        if (msg.type() !== 'error') return;
        const text = msg.text();
        if (
          text.includes('418') ||
          text.includes('425') ||
          text.includes('Hydration') ||
          text.includes('hydration') ||
          text.includes('did not match')
        ) {
          hydrationErrors.push(text);
        }
      });

      await page.goto(route, { waitUntil: 'networkidle' });
      await page.waitForTimeout(500);
      expect(hydrationErrors).toEqual([]);
    });
  }
});
		

Запускайте такой тест только против production-сборки, никогда против next dev. Сделайте его обязательной проверкой для маршрутов, где hydration failure критичен: продуктовые страницы, дашборды, чекаут, личный кабинет.

FAQ

Часто задаваемые вопросы
1
Что такое hydration mismatch в React?

Это ситуация, когда HTML, отрендеренный на сервере, не совпадает с деревом, которое React строит на клиенте при первой гидратации. React ожидает идентичности, иначе он может отбросить серверную разметку и перерендерить UI на клиенте.

2
Почему в production hydration mismatch сложнее диагностировать, чем в development?

В development React выводит читаемое предупреждение, текст расхождения и стек компонента. В production остаются только минимизированные коды ошибок, например #418 или #425, без указания на компонент или маршрут.

3
Можно ли просто добавить suppressHydrationWarning и забыть?

Нет. Этот проп допустим только там, где расхождение ожидаемо и управляется явно, — например, для текущего времени в leaf-элементе. Если обернуть им большой контейнер, вы замаскируете баг, который продолжит портить производительность и корректность.

4
Какая разница между HTML-слоем и RSC payload?

HTML — это то, что видит браузер. RSC payload — сериализованное состояние Server Components, которое React использует для гидратации. HTML может выглядеть правильно, но payload всё равно может расходиться с тем, что клиентский компонент рендерит при гидратации.

5
Почему next dev не подходит для проверки hydration?

next dev использует другой путь сборки и показывает ошибки иначе, чем production. Многие hydration-баги проявляются только после next build && next start. Также локальное окружение может отличаться по таймзоне, локали, кукам и фичер-флагам.

Выводы

Hydration mismatch — не придирка React, а реальный баг производительности и корректности. Каждое несовпадение означает, что приложение могло отбросить серверный HTML, за который уже заплатило ресурсами.

В приложениях с React Server Components, где цель — меньше клиентского JavaScript и более ранний стриминг полезного UI, hydration failures тихо отменяют эти преимущества. Поэтому важно держать браузерную логику за границей 'use client', рендерить стабильные серверные baseline и проверять гидратацию в production-условиях до того, как ошибку найдут пользователи.

Hydration errors should be fixed, not ignored — официальная позиция React.
React Teamreact.dev

Источник: LogRocket Blog — How to fix RSC hydration mismatches in Next.js.

Проверьте свои критичные маршруты на production-сборке, настройте телеметрию и не давайте hydration-ошибкам копиться в консоли.