Серверный компонент: код, который не уехал в браузер
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Вся эта часть держалась на одной картине: код уезжает в браузер и там выполняется. В App Router картина по умолчанию другая — компонент выполняется на сервере, один раз, и в браузер уезжает только то, что он вернул. Его собственного кода в браузере нет вовсе: ни в бандле, ни в исходниках страницы вы его не найдёте.
Положите файл app/page.tsx в проект Next.js, напишите в нём обычный компонент — без директив, без настроек, без единого нового слова — и он выполнится на сервере. Не «может выполниться», не «выполнится, если попросить»: сервер — это состояние по умолчанию для всего, что лежит в app. Серверным компонент делает место, а не отметка в коде.
Песочница учебника — это браузер: ни сервера, ни Next в ней нет. Поэтому файлы Next-проекта здесь — листинги: кнопки Run у них нет, а вывод, где он есть, записан с живого проекта. Всё, что можно проверить по-настоящему, дальше проверяется по-настоящему.
Первое, что сбивает с толку на живом проекте, — именно эта строчка с console.log. В консоли браузера её нет. Она печатается в терминале, где вы запустили next dev, вперемешку с логами запросов, — потому что компонент выполнился там же, где и всё остальное серверное. В проде такая строка уйдёт в логи сервера, и увидит её не посетитель, а тот, у кого есть доступ к машине.
Проверить, где именно выполнился код, можно одной строкой — и приём этот не учебный, а рабочий. Прежде чем жать Run, решите, что напечатает пример здесь, в песочнице.
app/page.tsx, напечатал бы «сервер», и напечатал бы это в терминале. Код один, а место разное — и место задаёт не код, а фреймворк.Умолчание — не мелочь оформления, а ставка на то, чего в проекте больше. Раньше по умолчанию был браузер: туда уезжало всё, включая заголовки, тексты статей, списки, подвалы и таблицы — то есть разметку, на которую никто никогда не нажмёт. Чтобы кусок работы вернулся на сервер, приходилось заводить отдельный механизм и отдельный слой данных.
App Router перевернул умолчание, потому что интерактивных мест на странице обычно меньшинство. Теперь наоборот: по умолчанию код никуда не едет, а в браузер вы отправляете точечно то, что там действительно нужно, — кнопку, поле, меню. Всё остальное остаётся дома бесплатно.
Из «компонент выполнился на сервере» следует главное: его код не попадает в бандл. Не «минифицируется получше», не «грузится попозже» — его там нет. Вместе с ним не едет и всё, чем он пользовался: разбор Markdown, подсветка кода, форматирование дат, драйвер базы.
Посчитаем один экран блога. Сколько килобайт из него доедет до телефона посетителя?
Это меняет расклад в спорах о зависимостях. Библиотека, которая нужна только чтобы собрать разметку, перестаёт быть строкой в бюджете бандла: её вес платит сервер, у которого этот килобайт ничего не стоит. А за библиотеку, попавшую в интерактивный кусок, платит трафиком каждый посетитель — и вот её вес считать стоит.
| Что есть в проекте | Уедет ли в браузер |
|---|---|
| Разбор Markdown в готовые теги | нет: превратился в разметку ещё на сервере |
| Драйвер базы и строка подключения | нет |
| Текст и теги, которые компонент вернул | да — как результат, а не как код |
Кнопка с onClick | да: без кода в браузере она не нажмётся |
| Ключ доступа к чужому API | нет — пока вы сами не поставите его в разметку |
Первое, куда смотреть не стоит, — вывод next build: колонки с весом скриптов там больше нет. Next убрал её в шестнадцатой версии, и как раз из-за серверных компонентов: числа расходились с правдой, потому что сборщики по-разному решали, чей это вес. Таблица маршрутов осталась, но она про другое — про способ отдачи и сроки кеша.
Мерить надо там, где груз приезжает, — в самом браузере. Вкладка Network показывает, какие файлы приехали, а поиск по всем загруженным файлам (в DevTools это Search, отдельная панель) — что в них лежит. Если имени вашей серверной функции или строки подключения там нет, значит, код правда остался дома. Не верить на слово — привычка из части I, и здесь она особенно кстати.
Если код не уехал, то что уехало? Результат вызова. Сервер обращается с компонентом как с обычной функцией: вызывает, получает элемент React — тот самый обычный объект из статьи «JSX — это не HTML» — и превращает его в текст, который можно передать по сети.
Проделаем это руками. В компоненте ниже есть внутренняя переменная и обработчик клика; посмотрите на код и решите, что из этого окажется в отправленном тексте.
Осталось число 1400 и два куска текста. Переменной markup в отправленном нет, потому что после вызова функции её не существует — она была нужна ровно на одно умножение. А onClick исчез тише: JSON.stringify молча выбрасывает свойства-функции. Функцию нельзя записать текстом так, чтобы на другой стороне она ожила.
Настоящий формат, которым Next передаёт результат, богаче JSON — он умеет и ссылки на клиентские компоненты, и куски, которые приедут позже. Но суть та же: по проводу идут данные. Отсюда весь список того, чего серверный компонент «не умеет», — и это не запреты, придуманные авторами, а следствия одного факта.
| Чего нет у серверного компонента | Почему так |
|---|---|
useState | состояние живёт между отрисовками, а отрисовка здесь одна: второй уже не будет |
useEffect | эффект синхронизирует с внешним миром после коммита — коммита в браузере не было |
onClick и другие обработчики | функция не переживает превращение в текст |
window, document, localStorage | в Node таких имён нет: это браузерные объекты из части II |
useContext | провайдер — клиентская вещь, и серверная отрисовка в его дерево не попадает |
Компилятор про всё это молчит, и к такому вы не привыкли. Вот файл, в котором с точки зрения TypeScript безупречно всё.
Ноль диагностик — и это правда так: ошибка здесь не в типах, а в месте. Файл лежит в app без отметки, значит серверный, а useState и onClick в серверном компоненте невозможны по причинам из таблицы выше. Next остановит сборку и прямо предложит поставить 'use client'. Как ставится эта отметка и почему она расползается на соседние файлы — следующая статья главы.
«Типы сошлись, а работать не будет» — новый для вас класс ошибок. Место выполнения компилятор не проверяет: для него это просто функция. Проверяет его сборка.
Пока компонент выполнялся в браузере, дорога к данным была длинной: компонент отправлял fetch("/api/tasks"), на другом конце стоял ваш обработчик, он шёл в базу и возвращал JSON, компонент раскладывал ответ по состояниям «грузится / ошибка / готово». Половина этой дороги существовала ровно затем, чтобы браузер мог спросить у сервера то, что сервер и так знал.
Серверный компонент стоит на том же конце провода, что и база. Спрашивать через посредника ему не у кого.
Ни useEffect, ни трёх состояний, ни маршрута /api/tasks, который писали только ради самих себя. Компонент — асинхронная функция, и await стоит в ней там же, где стоял бы в любом серверном коде. Что при этом происходит с ожиданием и порядком отрисовки, разбирают следующие статьи главы; здесь важно только, почему это вообще возможно: код никуда не уехал.
И то же самое объясняет вторую половину выгоды — что осталось дома. Строка подключения, пароль от базы, ключ платёжного API, чтение файла с диска, внутренний адрес соседнего сервиса: всё это код серверного компонента, а его в браузере нет.
Next по умолчанию не отдаёт браузеру ничего из окружения — кроме переменных, имя которых начинается на NEXT_PUBLIC_. Такое имя и есть подпись под решением: «эту переменную увидят все». Значение подставляется в бандл на этапе сборки, и спрятать его потом уже нельзя.
Переменная без приставки доступна только серверному коду. Если прочитать её в клиентском компоненте, получится undefined — и это не ошибка настройки, а ровно то поведение, ради которого правило придумано. Отсюда же практический вывод: ключ, случайно названный с приставкой, считается утёкшим, и менять его надо, а не переименовывать.
Обратная сторона у этой лёгкости ровно одна, и она стоит отдельного абзаца. Код остался — а данные уехали. Всё, что попало в возвращённую разметку, едет к посетителю как есть.
true — хеш пароля уехал в браузер вместе с описанием страницы. Ни ошибки, ни предупреждения: вы попросили — вам отдали. Раньше эту границу охранял сам её вид: между компонентом и базой стоял маршрут, и в нём был виден момент «что именно я отдаю наружу». Серверный компонент убирает границу из кода, но не убирает её из головы.Открывая незнакомый файл в app, теперь смотрят не на содержимое, а на первую строку. Её отсутствие — это тоже ответ.
Без этой строки — сервер. С ней — браузер, и тогда возвращается всё, что вы знаете из этой части: состояние, обработчики, эффекты, ссылки на узлы. Вместе с ними возвращается и вес в бандле, поэтому строка не бесплатная.
| Серверный (по умолчанию) | Клиентский ('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.
Ключ уехал не как код, а как данные: он оказался внутри строки, которую компонент положил в разметку. Серверный код при этом действительно остался на сервере — граница проходит не по файлу, а по тому, что попало в результат.
Лечится сменой того, что возвращает компонент: запрос с ключом делает сам сервер, а в разметку едет уже готовый ответ.
Если картинку всё-таки нужно показать по прямой ссылке, ключ прячут за своим адресом-посредником: браузер идёт к вам, а к чужому API с ключом ходит сервер.
Почему этот файл не соберётся
Файл лежит в app/page.tsx, отметок в нём нет. Компилятор молчит: типы сходятся, JSX верный. Найдите в коде два признака, из-за которых Next остановит сборку, и объясните каждый — не ссылкой на правило, а причиной из этой статьи.
Признак первый — useState. Состояние существует, чтобы пережить отрисовку и попасть в следующую. У серверного компонента следующей отрисовки не бывает: сервер вызвал функцию один раз, забрал результат и забыл про неё. Хранить между отрисовками нечего и негде.
Признак второй — onClick. В браузер от серверного компонента едет описание разметки, то есть данные. Функцию в такое описание не положить — она исчезнет по дороге, как исчезает при JSON.stringify. Кнопка приедет, а нажатие обрабатывать будет некому.
Оба признака говорят одно: этому куску нужен браузер. Отметка 'use client' первой строкой файла его туда и переносит — со всем, что к ней прилагается. Про саму отметку и про то, насколько широко она действует, — следующая статья главы.
Где напечатается каждая строка
Три вызова console.log в двух файлах. Страница серверная, панель помечена как клиентская. Для каждой строки скажите, где вы увидите вывод при запущенном next dev: в терминале, в консоли браузера или в обоих местах.
A — только терминал. Серверный компонент выполняется на сервере, и его вывод уходит в поток процесса Node. В браузере этого кода нет, значит и вызова там не будет.
C — только консоль браузера. Обработчик живёт в клиентском компоненте и срабатывает от настоящего клика, которого на сервере не происходит.
B — в обоих местах, и это самый интересный пункт. Клиентский компонент отличается не тем, что он «не бывает на сервере»: при первой загрузке Next отрисовывает его на сервере тоже, чтобы посетитель увидел готовую разметку сразу, — отсюда строка в терминале. Потом тот же компонент оживает в браузере, тело функции выполняется ещё раз, и строка появляется в консоли. Почему так и чем это отличается от серверного компонента, разбирает статья «Серверный компонент, SSR и гидратация — три разные вещи»; пока достаточно запомнить, что 'use client' означает «этот код поедет в браузер», а не «этот код не выполняется на сервере».