Next.js изнутри. Часть 3. App Router: от запроса до гидратации.
Введение
В этой части мы посмотрим на App Router, который появился в Next.js начиная с 13 версии и построен на React Server Components.
App Router — это не надстройка над Pages Router, а принципиально другая модель рендеринга со своими структурами данных, своим протоколом передачи данных между сервером и клиентом и своим клиентским роутером.
Два рантайма React
Самое важное, что нужно держать в голове про App Router: в нём одновременно работают две разные сборки React. Они представляют из себя два разных набора модулей с разными точками входа, которые загружаются в один процесс.
Первая сборка — RSC-рантайм (React Server Components). Она умеет исполнять серверные компоненты (в том числе async-компоненты с await внутри) и сериализовать результат не в HTML, а в специальный бинарный поток. Эта сборка не умеет работать с DOM и у неё нет useState и useEffect. Её задача — превратить дерево серверных компонентов в поток данных.
Вторая сборка — SSR/клиентский рантайм. Это обычный React, который мы знаем: react-dom/server на сервере и react-dom/client в браузере. Он умеет рендерить в HTML и гидрировать.
Разделение реализовано через условный экспорт react-server в package.json самого React. Если заглянуть в копию React внутри Next.js, там лежит такая карта:
"exports": {
".": {
"react-server": "./react.react-server.js",
"default": "./index.js"
},...
}
Это штатный механизм резолвинга модулей, описанный в документации Node. Поле exports задаёт определённую карту. Когда что-то импортирует react, резолвер идёт по этой карте: если в текущем контексте активно условие react-server — берётся серверная сборка react.react-server.js (урезанный React без useState/useEffect), иначе срабатывает default — обычный index.js.
Далее уже сам Next.js решает, для какого кода условие react-server активно. Оно включается точечно для «слоя» в бандлере. Модули серверных компонентов попадают в RSC-слой, и для него резолверу прописывается активное условие react-server; клиентские компоненты и SSR-обвязка живут в других слоях, где этого условия нет. Какой слой получит модуль, определяется границей "use client", детали этого процесса относятся к процессу сборки приложения (Webpack/Turbopack).
Как описывается маршрут
Прежде чем что-то рендерить, Next.js собирает из директории app/ структуру, которую внутри называют loader tree. Это рекурсивное дерево, где каждый узел — сегмент маршрута, а лист — страница.
Каждый узел loader tree — это массив примерно такой формы:
[
segment, // имя сегмента: 'children', '[id]', '__PAGE__' и т.д.
parallelRoutes, // { [parallelRouteKey]: LoaderTree } — вложенные сегменты
modules // { layout?, page?, loading?, error?, 'not-found'?, template?, ... }
]
В modules лежат ленивые ссылки на модули файлов-конвенций: layout.tsx, page.tsx, loading.tsx, error.tsx и так далее. А parallelRoutes — это объект, ключи которого соответствуют параллельным маршрутам. Обычный вложенный сегмент лежит под ключом children; именованные слоты (например, @modal, @sidebar) — под своими ключами. Именно брагодаря такой структуре в App Router существует встроенная поддержка вложенных layout'ов и параллельных маршрутов.
Рендеринг
Loader tree — это статическое описание маршрута. Рендеринг же превращает его в RSC Payload — сериализованный результат, который поедет на клиент.
Рассмотрим процесс рендеринга на примере.
Сегмент — это одна ступенька URL-пути, которой соответствует одна папка в app/. Пусть есть такой проект:
app/
layout.tsx // корневой layout: <html>, шапка
dashboard/
layout.tsx // боковое меню
settings/
page.tsx // страница "/dashboard/settings"
URL /dashboard/settings состоит из трёх сегментов — корень → dashboard → settings, — и каждому соответствует своя папка. Вот что рендеринг выдаёт для этого маршрута:
- Структура маршрута — дерево имён сегментов и их вложенность, без контента:
корень └─ dashboard └─ settings
- Контент каждого сегмента — то, во что отрендерился его layout или page, где каждый оставляет слот под следующий сегмент:
корень → <html>… <Шапка/> [ сюда вложится dashboard ] …</html> dashboard → <БоковоеМеню/> [ сюда вложится settings ] settings → <ФормаНастроек/>
Вложив контент друг в друга по той же иерархии, что и в структуре, получаем финальную страницу.
Рендер идёт рекурсивно по loader tree, сегмент за сегментом, от корня вглубь. С каждым сегментом происходит примерно одно и то же:
- берётся его layout или page и рендерится;
- рендер спускается во вложенные сегменты — причём не только в обычный дочерний, но и во все параллельные слоты, если они есть;
- вокруг каждого перехода к дочернему сегменту ставится клиентский компонент-прокладка
LayoutRouter— точка, внутри которой клиентский роутер позже сможет подменить контент при навигации, не трогая остальное дерево.
Результат каждого сегмента — узел, который знает свой отрендеренный контент и ссылки на дочерние узлы. Сложенные вместе, эти узлы образуют дерево контента.
Вот зачем структуру и контент держат отдельно. Представим переход с /dashboard/settings на /dashboard/billing. Структуры двух маршрутов различаются только последним сегментом, а часть корень → dashboard у них общая. Тогда клиент сравнивает лёгкие структуры, видит, что поменялся только хвост, и серверу достаточно прислать новый контент лишь для billing. Контент корня и дашборда (вместе с состоянием бокового меню и позицией скролла) переиспользуется как есть. Если бы контент и структура были слеплены в одно, пришлось бы пересылать и перерисовывать всё дерево целиком, как в Pages Router.
Ключевой момент рендера — что происходит на границе "use client". Серверный React не исполняет клиентские компоненты, поэтому, встречая один из них, он не рендерит его, а вставляет в поток ссылку на модуль. Какому файлу и чанку соответствует ссылка, сервер берёт из page_client-reference-manifest.js — карты, которую бандлер генерирует на этапе сборки.
Так граница «серверное / клиентское» проходит ровно по "use client": всё, что выше границы, уже отрендерено в данные на сервере и как код в браузер не поедет; всё, что помечено "use client", превращается в ссылку, по которой клиент догрузит нужный JavaScript и оживит этот кусок. Это и есть механизм, благодаря которому код серверных компонентов не утекает в клиентский бандл.
Итог рендера — RSC Payload: компактный сериализованный пакет, в котором лежат отрендеренный контент серверных компонентов, ссылки на клиентские компоненты с их пропсами и структура маршрута. Формат намеренно экономный (ключи в нём буквально однобуквенные), потому что пакет едет по сети и инлайнится в HTML.
Дальше с этим пакетом происходят две вещи параллельно: его превращают в HTML для первого ответа и инлайнят в страницу, чтобы клиент мог гидрировать дерево.
Flight Protocol
Протокол, по которому сервер и клиент App Router обмениваются деревом, сообщество неформально называет Flight. По сути это формат тех самых структуры маршрута и контента сегментов, упакованных так, чтобы их можно было стримить и склеивать по частям.
Первое — дерево структуры маршрута (FlightRouterState). Это всё то же лёгкое описание «из каких сегментов состоит текущий URL и как они вложены». Важная особенность: этой структурой клиент и сервер обмениваются в обе стороны. При навигации клиент отправляет серверу своё дерево, а сервер по нему решает, что именно досылать. Вдобавок к форме дерево несёт небольшие пометки, которыми клиент аннотирует отдельные сегменты: например, «этот пересобери заново» или «по этому пришли только метаданные для <head>, контент не нужен». Так клиент управляет тем, что сервер сделает с каждой веткой.
Вторая сущность — контент сегментов, нарезанный на «срезы». Один срез — это компактная четвёрка: сегмент → как обновить структуру на этом месте → его отрендеренный контент → его данные для <head>. Контент здесь — результат серверного рендера: готовый к вставке вывод серверных компонентов плюс ссылки на клиентские. Нарезка на срезы нужна для стриминга: сервер присылает сначала каркас, а контент отдельных веток досылает позже, по мере готовности.
Как RSC превращается в HTML
При первичной загрузке оба рантайма работают в паре, и порядок такой.
Сначала RSC-рантайм рендерит дерево и сериализует его в Flight-поток. Этот поток раздваивается на две одинаковые копии: одна пойдёт в генерацию HTML, вторая будет инлайнена в страницу как данные.
Дальше включается SSR-рантайм — обычный React, рендерящий в HTML. Здесь SSR-проход сам является потребителем Flight-потока. Он не рендерит дерево заново, а берёт первую копию Flight-потока, десериализует её обратно в React-элементы и уже из них собирает HTML. Тот же payload заодно используется, чтобы засеять начальное состояние клиентского роутера.
Зачем так сложно? Затем, что один и тот же Flight-поток обслуживает сразу несколько задач:
- из него генерится HTML для первого показа;
- из него же клиентский React восстанавливает дерево и понимает, где сидят клиентские компоненты, которые надо гидрировать;
- и он же присылается при навигации — уже без HTML.
При этом во всех случаях он несёт только результат серверных компонентов, а не их код. Прямой проход сразу в HTML не дал бы клиенту структуру, чтобы оживить клиентские острова.
Аналог NEXT_DATA
В Pages Router весь стейт укладывался в один тег <script id="__NEXT_DATA__"> с JSON внутри. В App Router так сработает, потому что данные стримятся. Поэтому Flight-поток режется на чанки, и каждый чанк инлайнится в страницу отдельным маленьким <script>, который дописывает данные в глобальный массив self.__next_f.
Каждая запись в этот массив помечена типом: инициализация, обычный текстовый чанк или бинарный кусок, который кодируют в base64. Клиент читает этот массив и восстанавливает из него Flight-поток.
Смысл всей конструкции: HTML и данные для гидратации едут одним потоком. Пользователь видит разметку сразу, а данные подтягиваются теми же чанками по мере готовности сервера, поэтому не нужно ждать, пока соберётся весь стейт, чтобы начать отдавать страницу.
Гидратация
Когда браузер наконец получил HTML с инлайн-данными, пользователь уже видит страницу. Теперь её нужно оживить.
Клиент собирает чанки из self.__next_f обратно в Flight-поток и десериализует его в то же дерево, что было на сервере. Тонкость в том, что часть чанков уже лежит в массиве к моменту старта клиентского кода (они приехали с HTML раньше), а часть ещё подъедет — стрим мог не завершиться. Поэтому клиент сперва проигрывает накопленное, а затем перехватывает дозагружаемые чанки. Полученное дерево скармливается React для гидратации.
При этом серверные компоненты не гидрируются вообще. Гидрации подвергаются только клиентские компоненты — те, что были вставлены в поток как ссылки на модули. React по этим ссылкам догружает нужный JavaScript и привязывает к нему обработчики и состояние. Если 80% страницы — серверные компоненты, то 80% кода просто не уезжает в браузер. В Pages Router вместо этого весь JS всех компонентов всегда попадал в бандл.
Итого
Первичный рендеринг в App Router держится на разделении труда между двумя рантаймами React и на промежуточном формате между ними. RSC-рантайм исполняет серверные компоненты и сериализует их результат в Flight-поток. SSR-рантайм потребляет этот же поток, чтобы выдать HTML для первого показа. Тот же поток инлайнится в страницу через self.__next_f, и из него клиент восстанавливает дерево и гидрирует только интерактивные острова — клиентские компоненты, — не получая кода серверных. Loader tree задаёт форму маршрута, Flight несёт его структуру и контент по отдельности, а границы LayoutRouter заранее размечают места, где контент потом можно будет подменять.
В следующей части посмотрим, как клиент переходит между страницами, не перезагружая их, как Next.js заранее подтягивает нужные сегменты через кеш и как Server Actions замыкают круг, позволяя вызывать серверные функции прямо из клиента.