Серверный компонент, SSR и гидратация — три разные вещи

серверные≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

«Серверный компонент», «серверный рендер» и «гидратация» звучат как три названия одного и того же — и именно поэтому вокруг них столько путаницы. Это ответы на три независимых вопроса: где выполняется код, что приезжает в первом ответе сервера и что оживает в браузере. Разведите их — и половина странностей App Router перестанет быть странностями.

Разделы темы:·

В части II вы писали перерисовку из состояния своими руками, и вопрос был ровно один: когда перерисовать. Код жил в браузере, HTML был одним и тем же пустым каркасом, третьей сущности не существовало вовсе. App Router разбивает эту простоту на три оси, и дальше их придётся различать.

ВопросВозможные ответыЧем задаётся
Где выполняется код компонента?только на сервере / и на сервере, и в браузересерверный он или клиентский — то есть директива "use client"
Что приезжает в первом ответе сервера?готовая разметка / пустой <div id="root"> и тег скриптасерверный рендер (SSR) — свойство сборки, а не отдельного компонента
Что оживает в браузере?клиентские компоненты / ничего, пока не загрузился бандлгидратация: React обходит готовые узлы и вешает на них обработчики

Оси независимы, и ответ на одну ничего не говорит про две другие. Клиентский компонент выполняется на сервере. Серверный компонент не гидратируется. Страница может приехать готовой разметкой, и при этом ни одного серверного компонента в ней не будет — так работал React-рендер на сервере задолго до App Router.

Слово «серверный» в первых двух вопросах значит разное: в первом — «код не уехал в браузер», во втором — «HTML посчитан до отправки». Один и тот же корень для двух разных вещей — половина всей путаницы отсюда.

Серверный рендер ничего не знает про React и гораздо старше его: сервер отдаёт готовую разметку, браузер её показывает — так работали PHP и Rails. Противоположность — пустой каркас плюс бандл: разметку считает браузер. Именно так устроен каждый пример этой части в песочнице и любой проект на Vite: createRoot получает пустой div и наполняет его.

Рендер в браузереСерверный рендер
первый ответ серверапустой div и тег скриптаготовая разметка страницы
когда виден текстпосле загрузки и выполнения бандласразу, до всякого JavaScript
что видит поисковик или превью в мессенджерепустой каркас, если он не выполняет скриптыстраницу
что делает React в браузереcreateRoot — рисует дерево с нуляhydrateRoot — присоединяется к готовому

Проверяется это без инструментов: «Просмотр кода страницы» (Ctrl+U) показывает ровно тот HTML, который приехал с сервера, а панель «Элементы» из части II — уже живое дерево, изменённое скриптами. Два разных источника правды, и путать их дорого: страница с рендером в браузере в «Элементах» выглядит точно так же, как страница с SSR.

SSR не отменяет JavaScript. Бандл всё равно загрузится и выполнится — иначе не будет ни состояния, ни обработчиков. HTML — это первый кадр; код нужен для второго и всех остальных.

Проверка на две минуты: отключите в браузере JavaScript и перезагрузите страницу. Что осталось видно — то приехало готовым. Что исчезло — то рисовал бандл.

Гидратация — операция «оживить готовое»: React обходит уже существующие узлы, строит по ним своё внутреннее дерево и вешает обработчики, вместо того чтобы создавать узлы заново. Название неудачное, но прижилось; смысл в нём один — присоединиться, а не нарисовать.

Зачем понадобилась отдельная операция, лучше всего видно на её отсутствии. Положим в страницу «серверную» разметку руками и попробуем нажать кнопку до того, как React о ней узнает.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первый клик не сделал ничего: обработчика в разметке нет и быть не может — onClick не превращается в атрибут HTML, это функция, а функции по проводу не ездят. Дальше интереснее: alive === fromServer даёт false. createRoot не стал разбираться в чужой разметке — он стёр её и нарисовал свою.

В настоящем приложении это провал: сервер посчитал HTML, браузер его показал, а React тут же выбросил и нарисовал заново — мигание, сброшенный фокус, потерянная позиция прокрутки и двойная работа. Поэтому на странице с SSR вызывают не createRoot, а hydrateRoot: он идёт по существующим узлам и ничего не создаёт. Условие у сделки одно — разметка должна совпасть с тем, что React насчитает сам.

Разницу между этими двумя способами видно прямо здесь. Гидратации нужна не разметка, приехавшая по сети, а разметка, уже лежащая в контейнере — а положить её пример может и сам. Два одинаковых контейнера, два способа, один компонент:

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

createRoot отвечает false: он выбросил готовую кнопку и создал новую. hydrateRoot отвечает true — узел тот самый, что был в разметке, и клик после гидратации работает. Именно ради этого true серверный рендер и затевается: если бы React перерисовывал всё заново, готовый HTML не давал бы ничего, кроме картинки на долю секунды.

А совпасть она обязана потому, что рендеров два и оба — вызовы одной и той же функции: первый на сервере, второй в браузере. Если функция не чистая, два вызова дадут разное.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компонент между вызовами не менялся, а разметка разошлась. Ровно это и происходит при ошибке гидратации: React находит в DOM не то, что насчитал, пишет в консоль сообщение о несовпадении и перерисовывает поддерево на клиенте. Страница не ломается — но пользу от готового HTML на этом куске вы потеряли.

Список короткий и повторяется из проекта в проект: Math.random() и случайные идентификаторы; new Date() и форматирование времени (у сервера свой часовой пояс, у читателя свой); чтение window, localStorage или куки прямо в теле компонента; ветвление по ширине экрана.

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

Серверный компонент отвечает на первый вопрос — где выполняется код. Он не отменяет ни серверный рендер, ни гидратацию: страница на App Router делает все три вещи сразу, просто с разными компонентами по-разному.

Серверный компонентКлиентский компонент
код в бандле браузеранетда
где выполняется телона сервере, один разна сервере (первый рендер) и в браузере (все остальные)
useState, onClickнельзяможно
попадает в первый HTMLдада
гидратируетсянет — оживлять нечегода

Четвёртая и пятая строки — самое важное в таблице. Серверный компонент попадает в HTML: его разметка приезжает вместе со всеми остальными. Но оживлять его не нужно и нечем — кода в браузере нет, состояния нет, обработчиков нет. Гидратация трогает только клиентские острова, и в этом вся экономия: чем меньше островов, тем меньше кода браузер грузит и выполняет.

В браузер от серверного компонента приезжает не код, а результат — описание готового дерева. Оно нужно React отдельно от HTML: по HTML нельзя понять, где кончается серверная разметка и начинается клиентский остров, а при переходе по Link новую страницу надо вставить в уже живое дерево, а не перезагружать целиком.

Описание можно увидеть глазами. В исходнике страницы на App Router рядом с разметкой лежат длинные строки в вызовах self.__next_f.push — это и есть дерево. Строки вида 4:I[98521,["/_next/static/chunks/…"],"ThemeToggle"] — дырки под клиентские компоненты: номер модуля, файлы, которые придётся загрузить, и имя экспорта. Всё остальное дерево приехало уже посчитанным.

Проверьте прямо здесь: Ctrl+U на этой странице и поиск по __next_f. Учебник выложен статикой, HTML посчитан заранее, а не на каждый запрос, — на форму описания это не влияет.

Читатель открыл страницу приложения на App Router. Вот что происходит и какая из трёх вещей за что отвечает.

ШагЧто происходитЧья это работа
1сервер выполняет серверные компоненты: они ходят в базу, ждут ответы, возвращают разметкусерверные компоненты
2получается дерево, где на месте клиентских компонентов стоят ссылки: «здесь будет вот этот модуль с такими пропсами»серверные компоненты
3по этому дереву React строит HTML — и выполняет тела клиентских компонентов, чтобы вместо ссылок оказалась разметкасерверный рендер
4в ответ уезжают HTML и описание дерева; читатель видит страницу, а JavaScript ещё не выполнялсясерверный рендер
5браузер загружает бандл — в нём код только клиентских компонентовграница "use client"
6React проходит по готовым узлам и присоединяется к ним: появляются состояние, обработчики, эффектыгидратация
7дальше всё как в обычном React: клик, состояние, рендер в браузереклиентские компоненты

Между четвёртым шагом и шестым есть окно, в котором страница выглядит рабочей, но не отвечает: разметка на экране, обработчиков ещё нет. Это не баг, а физика — и ровно то, что напечатал первый пример строкой «клик до оживления». Окно тем шире, чем больше кода в клиентском бандле: вот практическая причина держать границу "use client" в листьях, а не поднимать её к корню.

Одна и та же страница, собранная тремя способами, отвечает на три вопроса по-разному.

Как собраноКод компонентовПервый ответЧто гидратируется
Vite и createRootвесь уезжает в браузерпустой каркасничего — обычный первый рендер
React с серверным рендером (Pages Router, свой сервер с renderToString)весь уезжает в браузерготовый HTMLвся страница целиком
App Routerуезжает только код клиентских компонентовготовый HTML и описание дереватолько клиентские острова

Польза от развода трёх понятий не теоретическая: она в том, что по симптому сразу видно, в какую сторону копать.

Что видитеВ какую из трёх вещей смотреть
«Просмотр кода страницы» показывает пустой каркассерверный рендер: разметки в первом ответе нет вовсе
текст на месте, кнопка секунду не реагируетгидратация: бандл ещё не загрузился или не дошёл до этого острова
в консоли ошибка о несовпадении разметкигидратация: два рендера дали разный HTML — ищите нечистую функцию
window is not defined при сборкесерверный рендер: тело клиентского компонента лезет в браузер там, где браузера нет
ключ из process.env нашёлся в бандлеграница: файл оказался по клиентскую сторону "use client"
текста нет в HTML, хотя данные приходятсерверный рендер: данные запрашивает эффект, а он выполняется только в браузере

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

  • Три вопроса независимы: где выполняется код (серверный или клиентский компонент), что приехало в первом ответе (серверный рендер) и что ожило в браузере (гидратация).
  • Серверный рендер старше React и не про компоненты: это просто готовая разметка в первом ответе вместо пустого каркаса.
  • Гидратация — присоединение к готовым узлам, а не рисование заново; createRoot стёр бы разметку, hydrateRoot её сохраняет.
  • Условие гидратации — совпадение разметки: тело компонента выполняется дважды, и нечистая функция даёт два разных HTML.
  • Клиентский компонент попадает в первый HTML так же, как серверный; разница в том, что его код уезжает в браузер и он гидратируется.
  • Между приходом HTML и концом гидратации страница видна, но не отвечает: чем ниже граница "use client", тем это окно короче.

1/4Компонент лежит в файле с "use client" и держит состояние в useState. Сколько раз выполнится его тело, пока читатель открывает страницу и ничего не нажимает?

2/4Гидратируется ли серверный компонент?

3/4В браузере отключили JavaScript и открыли страницу App Router: список постов отдаёт серверный компонент, кнопка «лайк» — клиентский. Что увидит читатель?

4/4В консоли ошибка о несовпадении разметки. Компонент печатает new Date().toLocaleTimeString() прямо в теле. Какая из трёх вещей сломалась?

Три вопроса про два компонента

В проекте два файла. app/post/page.tsx — обычный компонент страницы без директив: он await-ит пост из базы и показывает заголовок. components/like-button.tsx начинается с "use client" и экспортирует кнопку с useState; страница показывает её под заголовком.

Ответьте про каждый из двух компонентов на три вопроса: попадёт ли его код в бандл браузера; будет ли его разметка в первом HTML; будет ли он гидратироваться. Шесть ответов — и, если хотя бы один вызывает сомнение, найдите его в таблицах статьи.

Компонент страницы: кода в бандле нет (он серверный, файл остаётся на сервере); разметка в первом HTML есть; гидратации нет — оживлять нечего.

Кнопка: код в бандле есть (директива увезла файл в браузер); разметка в первом HTML есть — её посчитал серверный рендер, выполнив тело кнопки на сервере; гидратация есть — именно ради неё код и уехал.

Общее место, на котором чаще всего ошибаются, — второй вопрос у кнопки. «Клиентский» описывает, куда уехал код, а не то, чего нет в HTML: разметка клиентского компонента приезжает готовой, просто до конца гидратации она мертва.

Починить разъезжающуюся разметку

Два корня здесь изображают два рендера одного компонента — серверный и браузерный. Сейчас они дают разный HTML, а значит, в настоящем приложении на этом месте была бы ошибка гидратации. Сделайте так, чтобы сравнение печатало true, — и при этом номер должен остаться случайным.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Считать значение внутри тела нельзя: тело выполняется дважды. Значит, номер должен родиться один раз и приехать в компонент готовым — пропсом.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В App Router роль «одного раза снаружи» играет серверный компонент: он считает значение и передаёт его клиентскому пропсом, а тот на обоих рендерах видит одно и то же. Если же значение принципиально браузерное — время читателя, содержимое localStorage, ширина окна, — пропсом его не передать: на первом рендере показывают заглушку, а настоящее значение подставляет эффект.

Что попадёт в HTML

Список статей выводят двумя способами. В первом это клиентский компонент: он держит массив в useState и заполняет его в useEffect. Во втором — серверный компонент: он await-ит те же данные прямо в теле.

Что окажется в первом ответе сервера в каждом случае и что увидит превью ссылки в мессенджере, которое не выполняет скрипты? Ответ на два абзаца, кода писать не нужно.

Первый способ: тело компонента выполнится и на сервере, но эффекты на сервере не запускаются — данных ещё нет, и в HTML уедет пустой список (или заглушка «загружаем»). Превью ссылки увидит именно этот пустой список: скрипты оно не выполняет, а текст появляется только после бандла и ответа сервера.

Второй способ: сервер дождался данных до того, как построил HTML, — в первом ответе уже лежат готовые заголовки. Превью увидит статьи, читатель увидит их до всякого JavaScript, а гидратации на этом куске не будет вовсе: серверному компоненту нечего оживлять.

Обратите внимание, что различает эти два случая: не «сервер против браузера» вообще, а конкретно второй вопрос — что успело попасть в первый ответ.