Серверный компонент: код, который не уехал в браузер

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

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

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

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

Положите файл app/page.tsx в проект Next.js, напишите в нём обычный компонент — без директив, без настроек, без единого нового слова — и он выполнится на сервере. Не «может выполниться», не «выполнится, если попросить»: сервер — это состояние по умолчанию для всего, что лежит в app. Серверным компонент делает место, а не отметка в коде.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал next dev
собираю страницу, задач: 2

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

Первое, что сбивает с толку на живом проекте, — именно эта строчка с console.log. В консоли браузера её нет. Она печатается в терминале, где вы запустили next dev, вперемешку с логами запросов, — потому что компонент выполнился там же, где и всё остальное серверное. В проде такая строка уйдёт в логи сервера, и увидит её не посетитель, а тот, у кого есть доступ к машине.

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

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«Браузер» — и никак иначе: песочница и есть браузер, серверу тут взяться неоткуда. Тот же самый компонент, положенный в app/page.tsx, напечатал бы «сервер», и напечатал бы это в терминале. Код один, а место разное — и место задаёт не код, а фреймворк.

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

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

Из «компонент выполнился на сервере» следует главное: его код не попадает в бандл. Не «минифицируется получше», не «грузится попозже» — его там нет. Вместе с ним не едет и всё, чем он пользовался: разбор Markdown, подсветка кода, форматирование дат, драйвер базы.

Посчитаем один экран блога. Сколько килобайт из него доедет до телефона посетителя?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
227 килобайт кода на экран — и 2 килобайта в браузер. Сто восемьдесят килобайт разбора Markdown отработали на сервере и превратились в готовые теги; сорок два килобайта драйвера базы остались рядом с базой. Уехала одна кнопка «нравится» — единственное место, где кому-то надо нажать.

Это меняет расклад в спорах о зависимостях. Библиотека, которая нужна только чтобы собрать разметку, перестаёт быть строкой в бюджете бандла: её вес платит сервер, у которого этот килобайт ничего не стоит. А за библиотеку, попавшую в интерактивный кусок, платит трафиком каждый посетитель — и вот её вес считать стоит.

Что есть в проектеУедет ли в браузер
Разбор Markdown в готовые тегинет: превратился в разметку ещё на сервере
Драйвер базы и строка подключениянет
Текст и теги, которые компонент вернулда — как результат, а не как код
Кнопка с onClickда: без кода в браузере она не нажмётся
Ключ доступа к чужому APIнет — пока вы сами не поставите его в разметку

Первое, куда смотреть не стоит, — вывод next build: колонки с весом скриптов там больше нет. Next убрал её в шестнадцатой версии, и как раз из-за серверных компонентов: числа расходились с правдой, потому что сборщики по-разному решали, чей это вес. Таблица маршрутов осталась, но она про другое — про способ отдачи и сроки кеша.

Мерить надо там, где груз приезжает, — в самом браузере. Вкладка Network показывает, какие файлы приехали, а поиск по всем загруженным файлам (в DevTools это Search, отдельная панель) — что в них лежит. Если имени вашей серверной функции или строки подключения там нет, значит, код правда остался дома. Не верить на слово — привычка из части I, и здесь она особенно кстати.

Если код не уехал, то что уехало? Результат вызова. Сервер обращается с компонентом как с обычной функцией: вызывает, получает элемент React — тот самый обычный объект из статьи «JSX — это не HTML» — и превращает его в текст, который можно передать по сети.

Проделаем это руками. В компоненте ниже есть внутренняя переменная и обработчик клика; посмотрите на код и решите, что из этого окажется в отправленном тексте.

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

Осталось число 1400 и два куска текста. Переменной markup в отправленном нет, потому что после вызова функции её не существует — она была нужна ровно на одно умножение. А onClick исчез тише: JSON.stringify молча выбрасывает свойства-функции. Функцию нельзя записать текстом так, чтобы на другой стороне она ожила.

Настоящий формат, которым Next передаёт результат, богаче JSON — он умеет и ссылки на клиентские компоненты, и куски, которые приедут позже. Но суть та же: по проводу идут данные. Отсюда весь список того, чего серверный компонент «не умеет», — и это не запреты, придуманные авторами, а следствия одного факта.

Чего нет у серверного компонентаПочему так
useStateсостояние живёт между отрисовками, а отрисовка здесь одна: второй уже не будет
useEffectэффект синхронизирует с внешним миром после коммита — коммита в браузере не было
onClick и другие обработчикифункция не переживает превращение в текст
window, document, localStorageв Node таких имён нет: это браузерные объекты из части II
useContextпровайдер — клиентская вещь, и серверная отрисовка в его дерево не попадает

Компилятор про всё это молчит, и к такому вы не привыкли. Вот файл, в котором с точки зрения TypeScript безупречно всё.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал next dev
⨯ ./app/page.tsx:3:10
Error: You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
> 3 | import { useState } from "react";
| ^^^^^^^^

Ноль диагностик — и это правда так: ошибка здесь не в типах, а в месте. Файл лежит в app без отметки, значит серверный, а useState и onClick в серверном компоненте невозможны по причинам из таблицы выше. Next остановит сборку и прямо предложит поставить 'use client'. Как ставится эта отметка и почему она расползается на соседние файлы — следующая статья главы.

«Типы сошлись, а работать не будет» — новый для вас класс ошибок. Место выполнения компилятор не проверяет: для него это просто функция. Проверяет его сборка.

Пока компонент выполнялся в браузере, дорога к данным была длинной: компонент отправлял fetch("/api/tasks"), на другом конце стоял ваш обработчик, он шёл в базу и возвращал JSON, компонент раскладывал ответ по состояниям «грузится / ошибка / готово». Половина этой дороги существовала ровно затем, чтобы браузер мог спросить у сервера то, что сервер и так знал.

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

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

Ни useEffect, ни трёх состояний, ни маршрута /api/tasks, который писали только ради самих себя. Компонент — асинхронная функция, и await стоит в ней там же, где стоял бы в любом серверном коде. Что при этом происходит с ожиданием и порядком отрисовки, разбирают следующие статьи главы; здесь важно только, почему это вообще возможно: код никуда не уехал.

И то же самое объясняет вторую половину выгоды — что осталось дома. Строка подключения, пароль от базы, ключ платёжного API, чтение файла с диска, внутренний адрес соседнего сервиса: всё это код серверного компонента, а его в браузере нет.

Next по умолчанию не отдаёт браузеру ничего из окружения — кроме переменных, имя которых начинается на NEXT_PUBLIC_. Такое имя и есть подпись под решением: «эту переменную увидят все». Значение подставляется в бандл на этапе сборки, и спрятать его потом уже нельзя.

Переменная без приставки доступна только серверному коду. Если прочитать её в клиентском компоненте, получится undefined — и это не ошибка настройки, а ровно то поведение, ради которого правило придумано. Отсюда же практический вывод: ключ, случайно названный с приставкой, считается утёкшим, и менять его надо, а не переименовывать.

Обратная сторона у этой лёгкости ровно одна, и она стоит отдельного абзаца. Код остался — а данные уехали. Всё, что попало в возвращённую разметку, едет к посетителю как есть.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
true — хеш пароля уехал в браузер вместе с описанием страницы. Ни ошибки, ни предупреждения: вы попросили — вам отдали. Раньше эту границу охранял сам её вид: между компонентом и базой стоял маршрут, и в нём был виден момент «что именно я отдаю наружу». Серверный компонент убирает границу из кода, но не убирает её из головы.

Открывая незнакомый файл в app, теперь смотрят не на содержимое, а на первую строку. Её отсутствие — это тоже ответ.

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

Без этой строки — сервер. С ней — браузер, и тогда возвращается всё, что вы знаете из этой части: состояние, обработчики, эффекты, ссылки на узлы. Вместе с ними возвращается и вес в бандле, поэтому строка не бесплатная.

Серверный (по умолчанию)Клиентский ('use client')
Где выполняется кодтолько на серверев браузере — и на сервере при первой отрисовке
Код в бандле браузеранетда
Сколько раз выполняетсяодин раз на запроспри каждой перерисовке
useState, useEffect, onClickнетда
База, файлы, секретыданет
window, localStorageнетда

Границу между колонками проводите вы, и проводить её выгодно как можно ниже по дереву. Пометить 'use client' страницу целиком технически можно — но тогда в браузер уедет и список, и текст, и всё, что вы старательно оставляли на сервере, ради одной кнопки внизу. Куда именно ставить отметку и почему она задевает больше файлов, чем кажется, — тема следующей статьи.

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

  • В App Router компонент по умолчанию выполняется на сервере: серверным его делает место в дереве, а не отметка в коде.
  • Кода серверного компонента в бандле нет — вместе с ним дома остаются и разбор Markdown, и драйвер базы, и всё, что нужно только для сборки разметки.
  • В браузер едет результат, а не код: это данные, и функция в них не помещается — отсюда отсутствие обработчиков, состояния и эффектов.
  • Компонент стоит на одном конце провода с базой и секретами, поэтому маршрут /api ради собственных данных больше не нужен.
  • Код остался, но данные уехали: всё, что попало в возвращённую разметку, увидит посетитель — поля выбирают руками.
  • Место выполнения компилятор не проверяет; отметку 'use client', которая переносит файл в браузер, разбирает следующая статья.

1/4В проекте на Next.js лежит файл app/reports/page.tsx с обычным компонентом: ни директив, ни специальных импортов. Где выполнится его код?

2/4Серверный компонент вернул <button onClick={() => console.log("клик")}>Купить</button>. Что произойдёт с обработчиком по дороге в браузер?

3/4В теле серверного компонента стоит console.log("собираю страницу"). Где вы увидите эту строку при запущенном next dev?

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

Ключ, который уехал в браузер

Серверный компонент собирает адрес картинки с погодой и возвращает его в разметке. Кода компонента в браузере нет — а ключ там оказался. Объясните, как так вышло, и переделайте пример так, чтобы includes напечатал false.

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

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

Лечится сменой того, что возвращает компонент: запрос с ключом делает сам сервер, а в разметку едет уже готовый ответ.

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

Если картинку всё-таки нужно показать по прямой ссылке, ключ прячут за своим адресом-посредником: браузер идёт к вам, а к чужому API с ключом ходит сервер.

Почему этот файл не соберётся

Файл лежит в app/page.tsx, отметок в нём нет. Компилятор молчит: типы сходятся, JSX верный. Найдите в коде два признака, из-за которых Next остановит сборку, и объясните каждый — не ссылкой на правило, а причиной из этой статьи.

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

Признак первый — useState. Состояние существует, чтобы пережить отрисовку и попасть в следующую. У серверного компонента следующей отрисовки не бывает: сервер вызвал функцию один раз, забрал результат и забыл про неё. Хранить между отрисовками нечего и негде.

Признак второй — onClick. В браузер от серверного компонента едет описание разметки, то есть данные. Функцию в такое описание не положить — она исчезнет по дороге, как исчезает при JSON.stringify. Кнопка приедет, а нажатие обрабатывать будет некому.

Оба признака говорят одно: этому куску нужен браузер. Отметка 'use client' первой строкой файла его туда и переносит — со всем, что к ней прилагается. Про саму отметку и про то, насколько широко она действует, — следующая статья главы.

Где напечатается каждая строка

Три вызова console.log в двух файлах. Страница серверная, панель помечена как клиентская. Для каждой строки скажите, где вы увидите вывод при запущенном next dev: в терминале, в консоли браузера или в обоих местах.

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

A — только терминал. Серверный компонент выполняется на сервере, и его вывод уходит в поток процесса Node. В браузере этого кода нет, значит и вызова там не будет.

C — только консоль браузера. Обработчик живёт в клиентском компоненте и срабатывает от настоящего клика, которого на сервере не происходит.

B — в обоих местах, и это самый интересный пункт. Клиентский компонент отличается не тем, что он «не бывает на сервере»: при первой загрузке Next отрисовывает его на сервере тоже, чтобы посетитель увидел готовую разметку сразу, — отсюда строка в терминале. Потом тот же компонент оживает в браузере, тело функции выполняется ещё раз, и строка появляется в консоли. Почему так и чем это отличается от серверного компонента, разбирает статья «Серверный компонент, SSR и гидратация — три разные вещи»; пока достаточно запомнить, что 'use client' означает «этот код поедет в браузер», а не «этот код не выполняется на сервере».