Next.js изнутри
Часть 1. Архитектура Next.js.
Введение
Next.js — один из самых популярных фреймворков в экосистеме React, которому в этом году исполняется 10 лет. Начавшись как простое решение для серверного рендеринга, он превратился в полноценную платформу со своим компилятором, бандлером, двумя серверными рантаймами и собственным протоколом.
В этом цикле мы не будем разбирать, как построить приложение на Next.js — вместо этого разберёмся, как он устроен под капотом. Зачем: чтобы понимать, почему что-то работает именно так, знать, куда смотреть в исходниках, когда документация не даёт ответа, и видеть архитектурные решения и компромиссы за каждой абстракцией. В следующих статьях мы будем разбирать каждый слой, а начнём с верхнеуровневого обзора.
Краткая история
Next.js появился в октябре 2016 года как ответ на конкретную проблему: в React не существовало полноценного Server Side Rendering из коробки. Для каждого нового проекта нужно было реализовывать свой кастомный SSR: вручную настраивать Webpack, писать серверный код, разбираться с гидратацией. Next.js спрятал это за простыми конвенциями — файловый роутинг через pages/, единственная функция getInitialProps для получения данных на сервере и клиенте, автоматическая сборка.
На этом этапе архитектура была минимальной: тонкая прослойка между React и Node.js HTTP-сервером. Сервер рендерил компоненты в HTML, клиент их гидрировал. Компиляция происходила стандартно с помощью Webpack и Babel.
С версии 9.3 монолитный getInitialProps уступил место раздельным getStaticProps / getServerSideProps. Версия 12 заменила Babel на SWC, что позволяло ускорить компиляцию в десятки раз. А в версии 13 произошёл перелом — App Router, React Server Components, Streaming. По факту внутри Next.js появилась вторая архитектура.
Сегодня Turbopack стал бандлером по умолчанию, кеширование переработано с нуля, Partial Prerendering объединяет статику и динамику на одной странице. Next.js превратился из тонкой обёртки над React в полноценную серверную платформу с собственным бандлером на Rust, двумя средами выполнения и собственным протоколом для передачи данных между сервером и клиентом.
Компиляция и сборка
Прежде чем говорить о рендеринге, разберёмся с тем, что происходит с кодом до того, как он попадёт на сервер или в браузер.
SWC — компилятор
SWC (Speedy Web Compiler) — компилятор на Rust, который отвечает за трансформацию отдельных файлов. Он убирает TypeScript-типы, превращает JSX в вызовы React-рантайма, выполняет минификацию. В репозитории Next.js SWC живёт как нативный модуль next-swc (директория packages/next-swc/), который интегрируется с Node.js через N-API — механизм нативных аддонов, позволяющий вызывать Rust-код напрямую из JavaScript без промежуточных процессов.
SWC — это трансформатор отдельных файлов. Он не знает о графе зависимостей между модулями, не занимается бандлингом и не принимает решений о том, какой код попадёт на клиент, а какой останется на сервере. Эти задачи — зона ответственности бандлера.
Turbopack — бандлер
Turbopack — тоже написан на Rust, но решает другую задачу. Если SWC работает с одним файлом за раз, то Turbopack видит всё приложение целиком и собирает из него граф зависимостей.
В основе Turbopack лежит система мемоизации и инкрементальных вычислений. Вместо того чтобы пересобирать всё при каждом изменении, Turbopack моделирует сборку как граф. Каждый файл и каждая трансформация — узел графа, рёбра — зависимости. При изменении файла инвалидируется только затронутый подграф, а не всё приложение.
В dev-режиме Turbopack использует lazy bundling: собирается только то, что запросил браузер. Если пользователь открыл /dashboard, маршруты /settings и /profile не компилируются вообще — они будут собраны, когда (и если) к ним обратятся. Ещё одно архитектурное решение — unified graph: один граф зависимостей для всех целевых сред (клиент, сервер). Webpack использовал отдельные компиляторы для каждой среды и потом сшивал результаты — Turbopack делает это в рамках единого графа.
Код Turbopack живёт в директории turbopack/crates/ монорепозитория Next.js. SWC при этом является инструментом, который Turbopack использует для трансформации отдельных файлов.
Серверный слой
Когда приложение собрано и запущено, запросы обрабатывает серверный слой. Его архитектура строится вокруг абстрактного класса BaseServer (файл packages/next/src/server/base-server.ts), который содержит общую логику обработки запросов: парсинг URL, матчинг маршрутов по манифесту, управление кешем, принятие решений о рендеринге.
От BaseServer наследуются две реализации. NextNodeServer — для запуска на полноценном Node.js (стандартный next start или self-hosted сервер). Он имеет доступ ко всем Node.js API: fs, crypto, нативные модули, работа с базами данных через постоянные соединения. NextWebServer — для Edge Runtime, облегчённой среды на основе Web API (Request/Response). Edge Runtime ограничен: нет файловой системы, нет нативных модулей, лимит на размер кода 1–4 МБ, нет постоянных соединений. Зато на платформе Vercel Edge Runtime распределяется глобально, обеспечивая минимальную задержку. При self-hosting Edge Runtime всё ещё работает, но без глобального распределения его основное преимущество — низкая задержка — во многом теряется.
Middleware всегда выполняется на Edge Runtime — это код, который перехватывает запрос до маршрутизации и может редиректить, переписывать URL, проверять авторизацию. Server Components, Route Handlers и Server Actions по умолчанию используют Node.js Runtime, но могут быть переключены на Edge через export const runtime = 'edge'.
При сборке Next.js анализирует директорию с маршрутами и создаёт манифест — структуру данных, описывающую, какие маршруты существуют и какие модули им соответствуют. На каждый входящий запрос BaseServer использует этот манифест для поиска нужного модуля маршрута и передаёт управление слою рендеринга.
Pages Router
Pages Router — первая архитектура рендеринга Next.js, основанная на директории pages/. Несмотря на то что App Router позиционируется как замена, Pages Router по-прежнему поддерживается, активно используется в production и не планируется к удалению. Многие крупные проекты до сих пор работают на нём, а два роутера могут сосуществовать в одном приложении (хотя и не взаимодействуют друг с другом).
Страница как единица рендеринга
В Pages Router единицей рендеринга является страница. Файл pages/about.tsx — это маршрут /about. Файл pages/posts/[id].tsx — динамический маршрут /posts/:id. Каждая страница — это React-компонент, который экспортируется по умолчанию, плюс опционально одна из функций получения данных.
Два специальных файла определяют каркас приложения. _app.tsx — обёртка вокруг всех страниц: здесь живут глобальные провайдеры (тема, авторизация, стейт-менеджер), layout'ы и общая логика. При навигации между страницами _app сохраняется, а меняется только внутренний компонент страницы. _document.tsx — кастомизация серверного HTML: тут можно добавить мета-теги в <head>, изменить <html> и <body>. Этот файл рендерится только на сервере и не имеет доступа к клиентским API.
Стратегии получения данных
В Pages Router получение данных жёстко привязано к уровню страницы — нельзя получить данные в отдельном компоненте внутри дерева. Есть три стратегии, каждая с чётко определённым моментом выполнения.
getStaticProps выполняется на этапе сборки (next build). Результат — данные, которые сохраняются рядом с предрендеренным HTML. При навигации Next.js загружает эти данные (а не перезапрашивает их с сервера) и передаёт их компоненту страницы. Это самый быстрый вариант — по сути, статический сайт. ISR (Incremental Static Regeneration) добавляет возможность фоновой перегенерации через параметр revalidate: страница отдаётся из кеша, а в фоне запускается пересборка с обновлёнными данными.
getServerSideProps выполняется на каждый запрос, строго на сервере. Даже при клиентской навигации Next.js делает запрос на сервер и выполняет функцию. В отличие от getInitialProps, этот код гарантированно никогда не попадает в клиентский бандл, поэтому можно безопасно использовать серверные секреты, прямой доступ к БД и другие серверные API.
getStaticPaths работает в паре с getStaticProps для динамических маршрутов. Она определяет, какие конкретно пути должны быть предрендерены при сборке — например, для блога это список ID всех постов. Параметр fallback управляет поведением для путей, которые не были предрендерены: false — отдать 404, true — показать loading-состояние и сгенерировать страницу в фоне, 'blocking' — подождать генерации и отдать готовую страницу.
Ещё одна важная деталь — Automatic Static Optimization. Если страница не экспортирует ни getServerSideProps, ни getInitialProps, Next.js автоматически генерирует её как статическую на этапе сборки. Это означает, что простая страница без получения данных обслуживается как статический HTML без какой-либо серверной работы.
Рендеринг и гидратация
При первом заходе на страницу с SSR сервер выполняет getServerSideProps (или использует закешированный результат getStaticProps), передаёт данные как пропсы React-компоненту и рендерит всё дерево в HTML через renderToReadableStream. Браузер получает готовый HTML, отображает его, затем загружает JavaScript-бандл и гидрирует — привязывает обработчики событий, восстанавливает состояние, делает страницу интерактивной.
Ключевой момент: в Pages Router гидрируется вся страница целиком. Даже если 90% контента — это статический текст, React должен пройти по всему дереву компонентов на клиенте, чтобы привязать обработчики и убедиться, что серверный HTML совпадает с клиентским рендером. Весь JavaScript всех компонентов страницы попадает в клиентский бандл. Это одна из фундаментальных проблем, которую App Router решает с помощью Server Components.
При клиентской навигации (переход по <Link> или router.push()) страница не перезагружается. Вместо этого Next.js запрашивает данные с сервера (для getServerSideProps) или берёт предрендеренные данные (для getStaticProps), загружает JavaScript-бандл новой страницы и рендерит её на клиенте. _app при этом сохраняется — обновляется только внутренний компонент страницы.
Ограничения Pages Router
Pages Router хорошо работает для множества сценариев, но у него есть архитектурные ограничения, которые и мотивировали создание App Router. Получение данных привязано к уровню страницы — нельзя получить данные в layout'е или в отдельном компоненте без клиентского fetch. Layout'ы не являются встроенной концепцией — _app один на всё приложение, а для вложенных layout'ов приходится писать обёртки вручную. Весь JavaScript всех компонентов страницы попадает в клиентский бандл, потому что нет механизма разделения на серверные и клиентские компоненты.
App Router: новая архитектура
App Router, появившийся в Next.js 13 и ставший стабильным в 13.4, — это не обновление Pages Router, а параллельная архитектура с другой моделью рендеринга. Она построена на React Server Components — фиче, которая стала стабильной в React 19.
Компонент как единица рендеринга
Если в Pages Router единицей рендеринга была страница, то в App Router — отдельный компонент. Каждый компонент может быть серверным (по умолчанию) или клиентским (помеченным "use client"), и это определяет, где он выполняется, попадает ли его код в клиентский бандл и может ли он использовать состояние и браузерные API.
Маршруты описываются через вложенные директории в app/, но теперь каждый сегмент маршрута — это не просто страница, а набор файлов-конвенций: page.tsx (содержимое), layout.tsx (каркас, сохраняющий состояние при навигации), loading.tsx (UI загрузки, автоматически обёрнутый в <Suspense>), error.tsx (граница ошибок), not-found.tsx. Layout'ы вкладываются друг в друга: корневой layout оборачивает layout раздела, который оборачивает layout подраздела — и каждый из них сохраняет своё состояние при навигации между дочерними маршрутами.
Два React-рантайма и Flight Protocol
Когда запрос попадает в App Router, Next.js загружает не один, а два разных рантайма React. Первый — для RSC-рендеринга — умеет исполнять async-компоненты, сериализовать результат в специальный бинарный формат и вставлять плейсхолдеры для клиентских компонентов. Второй — для SSR — берёт результат RSC-рендеринга и превращает его в HTML, который можно сразу отправить в браузер.
Ключевая функция в рендеринг-пайплайне — renderToHTMLOrFlight() в packages/next/src/server/app-render/. Она принимает решение о формате ответа на основе заголовков запроса. Если браузер загружает страницу впервые (обычный GET), сервер отдаёт HTML с инлайн-вставками RSC Payload. Если это клиентская навигация (запрос содержит заголовок RSC: 1), сервер возвращает только RSC Payload — компактную бинарную структуру, которую клиентский React использует для обновления DOM без перезагрузки страницы.
RSC Payload — это сердце протокола, который сообщество неформально называет Flight. Внутри него — отрендеренный контент серверных компонентов (уже готовый для вставки в DOM), ссылки на модули клиентских компонентов (указатели вида «здесь должен быть модуль X с пропсами Y»), пропсы, переданные от серверных компонентов клиентским, и структура Suspense-границ. На клиенте React десериализует этот поток, рекурсивно восстанавливает дерево компонентов и заменяет ссылки на модули реальные клиентские компоненты, загружая их JavaScript по мере необходимости.
Streaming и избирательная гидратация
В отличие от Pages Router, где сервер рендерил всю страницу целиком перед отправкой, App Router использует потоковую передачу через ReadableStream. Каждая <Suspense>-граница — потенциальная точка разделения потока. Сервер рендерит всё, что может, до первого Suspense — это каркас страницы. Он отправляется клиенту и пользователь уже видит контент. По мере завершения async-операций (получение данных, вычисления) сервер досылает оставшиеся чанки, каждый из которых — <script> тег, который React на клиенте вшивает в нужное место DOM.
Гидратация тоже работает принципиально иначе. Серверные компоненты не гидрируются вообще — они остаются статическим HTML, и их код никогда не попадает в клиентский бандл. Гидрации подвергаются только клиентские компоненты (помеченные "use client"), к которым нужно привязать обработчики событий и инициализировать состояние. Если на странице 80% контента — это серверные компоненты, то 80% JavaScript просто не отправляется в браузер.
Клиентский роутер
На стороне клиента в App Router работает собственный AppRouter (packages/next/src/client/components/app-router.tsx), и он устроен совсем не так, как роутер Pages Router. При навигации через <Link> или useRouter() роутер отправляет запрос с заголовком RSC: 1, получает RSC Payload (не HTML), передаёт его React'у, который обновляет DOM. Layout'ы, которые не изменились, сохраняют своё состояние — это означает, например, что позиция скролла в боковом меню или открытые аккордеоны не сбрасываются при переходе между вложенными маршрутами.
Роутер также управляет клиентским кешем RSC Payload'ов. При наведении на <Link> происходит prefetch — payload загружается заранее, делая последующую навигацию мгновенной.
Кеширование
Система кеширования — пожалуй, самая запутанная часть архитектуры Next.js, прошедшая через несколько радикальных переосмыслений. В Next.js 14 кеширование работало неявно: fetch-запросы кешировались по умолчанию, и чтобы получить свежие данные, нужно было явно указывать { cache: 'no-store' }. Это приводило к трудноотлаживаемым багам.
В Next.js 16 с включёнными Cache Components парадигма перевернулась: по умолчанию ничего не кешируется. Чтобы включить кеш, разработчик явно использует директиву "use cache" на уровне компонента или функции, а cacheLife() задаёт время жизни.
Partial Prerendering
PPR (Partial Prerendering) — логическое продолжение идеи streaming. Вместо выбора «страница либо статическая, либо динамическая» PPR позволяет совмещать оба подхода на одной странице. На этапе сборки Next.js рендерит всё, что может, в статический HTML, который мгновенно отдаётся с CDN. Динамические части (например, персонализированный контент за <Suspense>) стримятся в рантайме, заполняя «дырки» в HTML по мере готовности. Таким образом, маркетинговая страница с одним виджетом «Привет, %username%» отдаётся за миллисекунды из статики, а персонализация подгружается следом.
Server Actions
Server Actions — механизм вызова серверных функций из клиентского кода без написания API-маршрутов. Функция с директивой "use server" на этапе компиляции получает уникальный action ID. На клиенте вызов этой функции заменяется на POST-запрос с сериализованными аргументами. На сервере Next.js по action ID находит нужную функцию и выполняет её, после чего может запустить инвалидацию кеша и вернуть обновлённый RSC Payload. Формы с Server Actions работают даже без JavaScript в браузере.
Заключение
Next.js — это не фреймворк в привычном смысле, а скорее платформа, объединяющая компилятор на Rust, инкрементальный бандлер, два серверных рантайма, собственный протокол для передачи данных между сервером и клиентом, многоуровневую систему кеширования и клиентский роутер, работающий поверх всего этого. Сложность системы отражает сложность задачи, которую она решает — дать разработчику инструменты для создания быстрых, SEO-friendly приложений с минимальным количеством JavaScript в браузере. Но за эту мощь приходится платить высоким порогом входа и необходимостью понимать, что происходит внутри.
В следующих статьях цикла мы будем погружаться в каждый из слоёв, рассмотренных здесь: от внутреннего устройства Flight Protocol до механики Server Actions.
Часть 2. Как работает Pages Router.
Введение
В первой части мы посмотрели на архитектуру Next.js с высоты — из каких слоёв он состоит и как они связаны. Теперь проследим, как Pages Router обрабатывает страницу от начала до конца: что создаёт next build, как сервер рендерит HTML, как браузер оживляет его через гидратацию и что происходит, когда пользователь кликает по ссылке.
Сборка для продакшена
Прежде чем говорить о рендеринге, нужно понять, что вообще появляется после сборки для продакшена. Когда вы запускаете next build, Next.js проходит по директории pages/, компилирует каждый файл и создаёт набор артефактов в директории .next/. Эти артефакты — не только JavaScript, но и система манифестов, которая описывает, как приложение устроено и как его обслуживать.
Манифесты
В .next/ после сборки появляется несколько JSON-файлов, которые играют роль служебных карт для сервера и клиента.
.next/server/pages-manifest.json — маппинг маршрутов на серверные модули. Ключ — URL-паттерн, значение — путь к скомпилированному серверному файлу. Когда на сервер приходит запрос, Next.js ищет нужный модуль именно через этот манифест.
{
"/": "pages/index.html",
"/_app": "pages/_app.js",
"/_document": "pages/_document.js",
"/_error": "pages/_error.js",
"/api/hello": "pages/api/hello.js",
"/posts/[id]": "pages/posts/[id].json",
"/404": "pages/404.html",
...
}
.next/build-manifest.json — маппинг маршрутов на клиентские JavaScript-чанки. Для каждой страницы указано, какие JS-файлы нужно загрузить в браузере, чтобы страница стала интерактивной. Сюда входят и общие чанки (React, фреймворковый код), и чанки, специфичные для конкретной страницы. Когда сервер формирует HTML, он использует этот манифест, чтобы вставить правильные <script> теги — именно за это отвечает компонент <NextScript /> в _document.
{
"pages": {
"/": [
"static/chunks/0yk2rv91-thjg.js",
"static/chunks/0-ujd6kb7x1xa.js",
"static/chunks/0cdz4mh8oziua.js",
"static/chunks/0apqosxrma2cd.css",
"static/chunks/turbopack-0q2p_rmwhdtnh.js"
],
"/_app": [
"static/chunks/15bye611qo.h4.js",
"static/chunks/0-ujd6kb7x1xa.js",
"static/chunks/0cdz4mh8oziua.js",
"static/chunks/05~30wuay1au-.css",
"static/chunks/turbopack-0i~wcygbrw9og.js"
],
"/posts/[id]": [
"static/chunks/14zh8xwet8_if.js",
"static/chunks/0-ujd6kb7x1xa.js",
"static/chunks/0cdz4mh8oziua.js",
"static/chunks/17zyc9w4lgxk2.css",
"static/chunks/turbopack-014.yrp7tenhs.js"
],
...
},
"lowPriorityFiles": [
"static/Cvdy_qwv7xXS5CV3eexd8/_buildManifest.js",
"static/Cvdy_qwv7xXS5CV3eexd8/_ssgManifest.js",
"static/Cvdy_qwv7xXS5CV3eexd8/_clientMiddlewareManifest.js"
],
...
}
.next/prerender-manifest.json — описание статически сгенерированных страниц. Для каждой страницы, использующей getStaticProps, здесь указаны параметры: была ли она предрендерена при сборке, значение initialRevalidateSeconds и initialExpireSeconds (для ISR), fallback-стратегия для динамических маршрутов. Сервер обращается к этому манифесту, чтобы понять, можно ли отдать закешированный HTML или нужно запустить перегенерацию.
{
"version": 4,
"routes": {
"/isr": {
"initialRevalidateSeconds": 15,
"initialExpireSeconds": 31536000,
"srcRoute": null,
"dataRoute": "/_next/data/Cvdy_qwv7xXS5CV3eexd8/isr.json",
"allowHeader": [
"host",
"x-matched-path",
"x-prerender-revalidate",
"x-prerender-revalidate-if-generated",
"x-next-revalidated-tags",
"x-next-revalidate-tag-token"
]
},
"/posts/1": {
"initialRevalidateSeconds": false,
"srcRoute": "/posts/[id]",
"dataRoute": "/_next/data/Cvdy_qwv7xXS5CV3eexd8/posts/1.json",
"allowHeader": [
...
]
},
"/posts/2": {
...
},
"/posts/3": {
...
}
},
"dynamicRoutes": {
"/posts/[id]": {
"routeRegex": "^/posts/([^/]+?)(?:/)?$",
"dataRoute": "/_next/data/Cvdy_qwv7xXS5CV3eexd8/posts/[id].json",
"fallback": false,
"dataRouteRegex": "^/_next/data/Cvdy_qwv7xXS5CV3eexd8/posts/([^/]+?)\\\\.json$",
"allowHeader": [
...
]
}
},
...
}
.next/routes-manifest.json — полная карта маршрутов приложения: статические, динамические, их приоритет при матчинге, а также rewrites, redirects и headers из next.config.js.
{
"version": 3,
"appType": "pages",
"redirects": [
{
"source": "/:path+/",
"destination": "/:path+",
"internal": true,
"priority": true,
"statusCode": 308,
"regex": "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))/$"
}
],
...
"dynamicRoutes": [
{
"page": "/posts/[id]",
"regex": "^/posts/([^/]+?)(?:/)?$",
"routeKeys": {
"nxtPid": "nxtPid"
},
"namedRegex": "^/posts/(?<nxtPid>[^/]+?)(?:/)?$"
}
],
"staticRoutes": [
{
"page": "/",
"regex": "^/(?:/)?$",
"routeKeys": {},
"namedRegex": "^/(?:/)?$"
},
{
"page": "/api/hello",
"regex": "^/api/hello(?:/)?$",
"routeKeys": {},
"namedRegex": "^/api/hello(?:/)?$"
},
...
],
"dataRoutes": [
{
"page": "/about",
"dataRouteRegex": "^/_next/data/6ENUwtsxmX3loz\\\\-Fz6Ybh/about\\\\.json$"
},
...
],
"rsc": {
"header": "rsc",
...
},
...
}
Серверные и клиентские файлы
Помимо манифестов, сборка создаёт две группы файлов. В .next/server/pages/ лежат серверные модули — скомпилированные версии ваших страниц, предназначенные для выполнения на Node.js. Для страниц с getStaticProps здесь же лежат предрендеренные HTML-файлы и JSON-файлы с данными. Например, для pages/about.tsx без data-fetching функций появится about.html — результат Automatic Static Optimization; а для pages/posts/[id].tsx с getStaticProps и getStaticPaths — набор posts/1.html, posts/1.json, posts/2.html, posts/2.json и так далее, по числу путей, определённых в getStaticPaths.
В .next/static/chunks/ лежат клиентские JS-бандлы. Next.js автоматически разбивает код на чанки: общий фреймворковый код (React, сам Next.js), общие модули, используемые несколькими страницами, и отдельный чанк для каждой страницы. Это code splitting по маршрутам — при загрузке страницы /about браузер не скачивает JavaScript для /posts/[id].
Как определяется стратегия рендеринга
При сборке Next.js анализирует, какие функции экспортирует каждая страница, и на основе этого решает, как она будет обслуживаться в рантайме.
Если страница экспортирует getStaticProps — это Static Generation. Страница рендерится в HTML при сборке, результат сохраняется как файл. В рантайме сервер отдаёт готовый HTML без каких-либо вычислений. Если при этом указан параметр revalidate, включается ISR — Incremental Static Regeneration.
Если страница экспортирует getServerSideProps — это Server-Side Rendering. HTML будет генерироваться на каждый запрос. Ничего не предрендеривается при сборке.
Если страница экспортирует getInitialProps — это тоже серверный рендеринг при первом заходе, но с важным отличием, о котором поговорим ниже.
Если страница не экспортирует ни одну из этих функций — срабатывает Automatic Static Optimization. Next.js определяет, что странице не нужны серверные данные, и генерирует её как статический HTML при сборке. Это самый быстрый вариант — по сути, статический файл. Важный нюанс: если в _app.tsx определён getInitialProps, Automatic Static Optimization отключается для всех страниц приложения, потому что Next.js больше не может гарантировать, что страница не зависит от серверных данных.
Эту информацию можно увидеть в выводе next build: рядом с каждым маршрутом стоит значок — кружок (статическая), лямбда (SSR), или пустой кружок (ISR с интервалом revalidate).
Route (pages) Revalidate Expire
┌ ○ /
├ /_app
├ ○ /404
├ ƒ /api/hello
├ ○ /client-fetch
├ ● /isr 15s 1y
├ ● /posts/[id]
│ ├ /posts/1
│ ├ /posts/2
│ └ /posts/3
├ ● /ssg
└ ƒ /ssr
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses getStaticProps)
ƒ (Dynamic) server-rendered on demand
Серверный рендеринг
Пользователь вводит URL в адресную строку и нажимает Enter. Запрос попадает на сервер Next.js. Что происходит дальше — зависит от стратегии рендеринга, но общий пайплайн всегда одинаковый: получить данные, отрендерить React-дерево в HTML, отдать результат.
Матчинг маршрута
Сервер NextNodeServer берёт URL из запроса, нормализует его и ищет совпадение в routes-manifest.json. Приоритет матчинга определён при сборке: сначала проверяются статические маршруты (точное совпадение), потом динамические ([param]), потом catch-all ([...slug]). Если маршрут найден — загружается соответствующий модуль из server/pages-manifest.json.
Дальше сервер смотрит, какую data-fetching функцию экспортирует страницу.
getStaticProps
Если страница использует getStaticProps, при первом заходе сервер отдаёт HTML и JSON, сгенерированные при сборке. Никакого серверного кода не выполняется — это чтение файла с диска.
Если включён ISR (параметр revalidate), логика усложняется. Сервер работает по модели stale-while-revalidate: отдаёт закешированную версию мгновенно, но если с момента последней генерации прошло больше revalidate секунд, запускает фоновую перегенерацию. Следующий посетитель получит уже обновлённую версию. Таким образом, ни один пользователь не ждёт генерации — все получают кешированный ответ, а обновление происходит асинхронно.
Для динамических маршрутов с getStaticPaths поведение при запросе неизвестного пути зависит от параметра fallback. Если fallback: false — сервер вернёт 404. Если fallback: true — сервер отдаст страницу без данных (компонент может показать скелетон), а в фоне запустит генерацию; при следующем запросе этого пути будет готовый HTML. Если fallback: 'blocking' — сервер подождёт генерации и отдаст готовую страницу, без промежуточного состояния.
getServerSideProps
Эта функция выполняется на каждый запрос, строго на сервере. Её код гарантированно не попадает в клиентский бандл — Next.js при сборке вырезает его из клиентских чанков. Это означает, что в getServerSideProps безопасно обращаться к базе данных напрямую, использовать серверные секреты и API-ключи, читать файловую систему.
Функция получает объект context с доступом к req, res, params, query. Результат — объект { props }, который будет передан React-компоненту страницы.
getInitialProps
getInitialProps — оригинальная функция получения данных в Next.js, которая появилась в самых первых версиях фреймворка. Её ключевое отличие от getServerSideProps: она выполняется и на сервере, и на клиенте. При первом заходе (полная загрузка страницы) — на сервере. При клиентской навигации через <Link> или router.push() — в браузере.
Это двойное поведение имеет серьёзное последствие: код getInitialProps попадает в клиентский бандл. Если вы используете там серверные секреты, прямой доступ к БД или импорт серверных модулей вроде fs — они окажутся в JavaScript, который загрузит браузер. Поэтому getServerSideProps безопаснее — она гарантированно выполняется только на сервере, а при клиентской навигации Next.js вызывает её через серверный эндпоинт /_next/data/, не исполняя код в браузере.
Кроме того, getInitialProps в _app.tsx отключает Automatic Static Optimization для всех страниц. Это происходит потому, что Next.js не может при сборке определить, какие данные _app.getInitialProps будет возвращать, и вынужден выполнять серверный рендеринг для каждой страницы.
Важный нюанс: если getInitialProps используется в _app.tsx, а конкретная страница использует getServerSideProps, то при клиентской навигации на эту страницу _app.getInitialProps тоже выполнится на сервере, а не на клиенте. Next.js переключает контекст выполнения, потому что ему всё равно нужно сходить на сервер для getServerSideProps.
Рендеринг HTML
После того как данные получены (неважно, каким способом), начинается рендеринг React-дерева в HTML. Порядок такой:
Сначала Next.js вызывает _app.tsx. Это обёртка вокруг всех страниц — обычно здесь живут глобальные провайдеры (тема, авторизация, стейт-менеджер). Компонент _app получает два пропса: Component (текущая страница) и pageProps (результат data-fetching функции). По сути, _app — это <Component {...pageProps} />, обёрнутый в нужные провайдеры.
Затем React рендерит всё дерево — _app → страница → все дочерние компоненты — в виртуальный DOM, а потом сериализует его в строку HTML. В Pages Router используется renderToReadableStream. Однако streaming ограничен: данные получаются до начала рендеринга через getServerSideProps/getStaticProps, React получает уже готовые пропсы и рендерит полное дерево за один проход. Потоковая передача здесь — это передача HTML по мере его генерации, без возможности отправить каркас страницы сейчас и дослать контент позже, как это делается в AppRouter.
После того как React-дерево отрендерено, в дело вступает _document.tsx. Этот файл отвечает за внешний каркас HTML — то, что находится за пределами React-приложения. Здесь определяются теги <html>, <head> и <body>. Внутри <body> находятся два ключевых компонента: <Main /> — сюда вставляется отрендеренный HTML React-приложения, и <NextScript /> — сюда Next.js вставляет <script> теги с клиентскими JS-бандлами, определёнными в build-manifest.json для текущей страницы.
_document рендерится только на сервере. В нём нельзя использовать обработчики событий или хуки. Он не перерендеривается при клиентской навигации.
__NEXT_DATA__
Перед тем как HTML будет отправлен клиенту, Next.js вставляет в него специальный тег:
<script id="__NEXT_DATA__" type="application/json">
{
"props": {
"pageProps": { "posts": [...] }
},
"page": "/blog",
"query": {},
"buildId": "dQwLxa7HFItvOZwgV08yw",
...
}
</script>
Это сериализованный JSON, который содержит результат data-fetching функции (pageProps), текущий маршрут (page), параметры запроса (query) и идентификатор сборки (buildId). Без этих данных гидратация невозможна — React на клиенте должен получить те же самые пропсы, которые использовались при серверном рендере, чтобы восстановить виртуальный DOM и убедиться, что он совпадает с реальным DOM.
У этого механизма есть практическое следствие: если getServerSideProps или getStaticProps возвращает большой объём данных, он окажется в HTML дважды — как отрендеренная разметка и как JSON в __NEXT_DATA__. Next.js выдаёт предупреждение, если размер __NEXT_DATA__ превышает 128 КБ. Рекомендация — возвращать из data-fetching функций только те данные, которые нужны для первого рендера; остальное загружать на клиенте.
Гидратация
Браузер получил HTML, пользователь видит страницу — текст, картинки, вёрстку. Но кнопки пока не кликаются, формы не отправляются, ссылки работают как обычные <a> теги с полной перезагрузкой. Страница неинтерактивна, потому что к DOM ещё не привязаны обработчики событий и не инициализировано состояние React-компонентов.
Гидратация — это процесс, в котором React на клиенте «подхватывает» серверный HTML и делает его интерактивным. Вот как это работает.
Браузер загружает JS-бандлы, указанные в <NextScript />. Среди них — React, код фреймворка Next.js и чанк текущей страницы. Клиентский код Next.js читает __NEXT_DATA__ из DOM, извлекает pageProps, page и другие параметры. Затем вызывает hydrateRoot(), передавая компонент страницы с теми же пропсами, что использовались на сервере.
React на клиенте рендерит виртуальный DOM из переданных пропсов и сравнивает его с реальным DOM, который уже есть на странице. Если всё совпадает — React просто привязывает обработчики событий к существующим DOM-элементам, не трогая разметку. Никаких изменений в DOM не происходит — React «прикрепляется» к нему.
Если серверный HTML и клиентский виртуальный DOM расходятся — возникает hydration mismatch. React выдаёт предупреждение и в худшем случае перерендеривает несовпадающую часть дерева с нуля, что приводит к мерцанию интерфейса. Типичные причины mismatch: использование Date.now() или Math.random() при рендере (на сервере и клиенте будут разные значения), обращение к window или localStorage без проверки окружения, браузерные расширения, которые модифицируют DOM до загрузки React.
В Pages Router гидрируется всё дерево компонентов целиком. Нет механизма, который бы позволил пометить часть компонентов как «только серверные» и исключить их из гидратации — это одно из фундаментальных отличий от App Router, где серверные компоненты не гидрируются вообще. На практике это означает, что JavaScript всех компонентов на странице попадает в клиентский бандл, даже если 90% контента — статический текст.
Ещё один нюанс: при Automatic Static Optimization параметры роутера (query) на сервере будут пустыми, потому что при предрендеринге нет реального запроса с query string. После гидратации Next.js обновляет query актуальными значениями из URL, что вызывает дополнительный ререндер. Именно поэтому существует router.isReady — флаг, показывающий, что гидратация завершена и параметры маршрута актуальны.
Клиентская навигация
До этого момента мы говорили о полной загрузке страницы — пользователь ввёл URL, получил HTML, произошла гидратация. Теперь пользователь кликает по <Link> или вызывает router.push(). Страница не перезагружается — вместо этого начинает работать клиентский роутер Next.js.
Prefetch
Ещё до того, как пользователь кликнул, Next.js начинает подготовку. Prefetch в Pages Router работает в два этапа, и их поведение различается в зависимости от того, какую data-fetching функцию использует целевая страница.
Первый этап — viewport prefetch. Когда компонент <Link> попадает в видимую область экрана, Next.js автоматически загружает ресурсы целевой страницы в фоне. Для страниц с getStaticProps загружаются и JS-чанки, и JSON с данными — оба ресурса статические, их безопасно запросить заранее. Для страниц с getServerSideProps загружаются только JS-чанки, без данных — потому что данные зависят от конкретного запроса и будут получены только в момент реальной навигации. Viewport prefetch дедуплицируется: один и тот же URL не запрашивается повторно, Next.js хранит уже загруженные ключи в Set.
Второй этап — hover prefetch. Когда пользователь наводит курсор на ссылку, Next.js снова вызывает router.prefetch() . В этот раз проверка по Set пропускается и запрос уходит каждый раз. Для страниц с getStaticProps это приводит к повторным запросам за JSON при каждом наведении курсора — сколько раз навёл мышку, столько запросов ушло. Для страниц с getServerSideProps здесь ничего не запрашивается.
Такое поведение — осознанное решение разработчиков Next.js. Идея в том, что hover сигнализирует о намерении кликнуть, и в этот момент стоит запросить самые свежие данные, даже если они уже были загружены ранее. Однако возникают и побочные эффекты — множественные запросы при движении мышки по списку ссылок. Отключить hover prefetch нельзя: prefetch={false} отключает только viewport prefetch, но hover prefetch продолжает работать. Единственный способ избавиться от него полностью — использовать обычный тег <a> вместо <Link>, потеряв при этом клиентскую навигацию.
Запрос данных
Когда навигация происходит, поведение зависит от data-fetching функции целевой страницы.
Если целевая страница использует getServerSideProps, клиентский роутер делает запрос на /_next/data/{buildId}/page.json. Это специальный эндпоинт, который Next.js создаёт автоматически для каждой страницы с getServerSideProps. На сервере этот запрос обрабатывается как обычный — вызывается getServerSideProps, результат сериализуется в JSON и отправляется клиенту. Никакой HTML не генерируется — только данные. Формат ответа: { "pageProps": { ... } }.
Если целевая страница использует getStaticProps, роутер загружает предрендеренный JSON-файл по пути вида /_next/data/{buildId}/page.json. Этот файл был создан при сборке (или при ISR-перегенерации) и отдаётся как статический ресурс, без выполнения какого-либо серверного кода.
Если целевая страница использует getInitialProps, поведение принципиально иное: никакого запроса на сервер не происходит. Вместо этого getInitialProps выполняется прямо в браузере. Код функции, загруженный в составе JS-чанка страницы, вызывается на клиенте, делает свои fetch-запросы (к API, к внешним сервисам) и возвращает пропсы.
Рендер новой страницы
Когда данные получены (неважно, каким способом), клиентский роутер передаёт их компоненту новой страницы. _app при этом сохраняется — обновляется только внутренний компонент (пропс Component в _app). Это означает, что глобальные провайдеры, состояние стейт-менеджера и персистентные элементы интерфейса, определённые в _app, не сбрасываются при навигации. React делает обычный reconciliation — сравнивает старое и новое дерево, обновляет изменившиеся части DOM.
При этом URL в адресной строке обновляется через History API, и в историю браузера добавляется новая запись. Кнопка «Назад» работает — при нажатии роутер загрузит предыдущую страницу по тому же механизму.
Shallow routing
В Pages Router есть режим shallow routing — навигация, при которой URL меняется, но data-fetching функции не вызываются. Это полезно для обновления query-параметров без повторного запроса данных: например, при фильтрации или пагинации, когда данные уже есть на клиенте. Вызывается через router.push(url, as, { shallow: true }). При shallow routing getServerSideProps и getStaticProps не выполняются, а компонент страницы получает обновлённый router.query. Shallow routing работает только в пределах одной страницы — при переходе на другой маршрут он игнорируется.
Итого
Pages Router — архитектурно простая модель. Данные получаются на уровне страницы через одну из трёх функций, React рендерит полное дерево в HTML, пропсы дублируются в __NEXT_DATA__ для гидратации, а при клиентской навигации роутер запрашивает данные отдельно от HTML и рендерит страницу на клиенте. Каждый компонент попадает в клиентский бандл, каждый компонент гидрируется. Это ограничивает производительность и увеличивает размер бандлов, но даёт предсказуемость — всегда понятно, какой код где выполняется и как данные попадают в компонент.
Часть 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 замыкают круг, позволяя вызывать серверные функции прямо из клиента.
Часть 4. App Router: навигация, кеш и мутации.
Введение
В предыдущей части мы проследили, как страница App Router рождается на сервере и доезжает до браузера. В этой части рассмотрим жизнь страницы после загрузки. Разберём, как работает клиентская навигация без перезагрузки, как Next.js заранее подтягивает контент через многоуровневый Segment Cache и как Server Actions дают вызывать серверные функции прямо из клиента. Всё это опирается на структуры, описанные в прошлой части.
Клиентская навигация
Пользователь кликает по <Link> или вызывает useRouter().push(). В этот момент страница не перезагружается, так как работает клиентский роутер.
При навигации клиент шлёт на тот же URL запрос, но с несколькими служебными заголовками (их можно посмотреть во вкладке Network):
RSC: 1
Next-Router-State-Tree: <структура маршрута, которая есть у клиента>
Next-Url: <текущий URL, нужен для interception-роутов>
RSC: 1 сообщает серверу, что нужен не HTML, а Flight-ответ.
Next-Router-State-Tree — это та самая структура (FlightRouterState), описывающая, что у клиента уже есть. Значение этого заголовка — это URL-encoded JSON, так что прочитать его можно через JSON.parse(decodeURIComponent(...)).
Также к URL добавляется cache-busting параметр (?_rsc=<хеш>). Многие CDN, закешировав HTML по «голому» URL, могут отдавать тот же HTML в ответ на RSC-запрос. Уникальный параметр разводит HTML- и Flight-ответы по разным ключам кеша.
Получив запрос с присланной структурой, сервер решает, с какого общего layout'а начинать рендер. Он идёт одновременно по своему дереву маршрута и по структуре от клиента, сравнивая сегмент за сегментом. Пока сегменты совпадают, сервер спускается глубже, ничего не рендеря. Как только сегменты расходятся или клиент явно отметил ветку для изменения, сервер рендерит поддерево с этой точки и отдаёт только его.
Клиент получает патч и вмердживает его в своё дерево. Сегменты, которые не изменились, сохраняют свои узлы, а с ними и состояние: позицию скролла, открытые аккордеоны, введённый в формы текст. Это прямое следствие того, что на каждой границе сидит LayoutRouter, выбирающий контент из кеша по своему слоту: затронутый слот берёт новый контент, остальные — прежний.
Отсюда происходит и экономия: при переходе между двумя страницами с общим layout сервер пришлёт только изменившуюся часть, а общий layout не будет ни рендериться, ни пересылаться.
Segment Cache и prefetch
Prefetch это предзагрузка страниц по ссылкам: Next.js заранее, ещё до клика, подтягивает данные для страниц, на которые пользователь может перейти, чтобы сама навигация прошла мгновенно. По умолчанию это происходит для каждого <Link>, когда он попадает в зону видимости, а также при наведении. Роутер в фоне запрашивает Flight-ответ целевого маршрута и кладёт в кеш.
В свежих версиях навигация и prefetch перестроены на архитектуру Segment Cache. Её идея — разделить клиентский кеш на два уровня:
- Route Cache — структуры маршрутов (деревья сегментов);
- Segment Cache — отдельные сегменты с их контентом.
В ранней модели prefetch тянул дерево маршрута целиком до первой границы loading. Теперь же клиент сначала запрашивает только лёгкую структуру маршрута, а потом — отдельные сегменты, которых ему не хватает. Сегменты, общие для нескольких маршрутов (например, корневой layout), кешируются один раз и переиспользуются при навигации куда угодно.
Если открыть Network, видно, что наведение на одну ссылку порождает не один запрос, а пачку. Это как раз прямое следствие посегментной модели. Сначала клиент отдельным запросом тянет дерево маршрута (с заголовком Next-Router-Segment-Prefetch: /_tree), а затем шлёт по запросу на каждый недостающий сегмент, где у каждого в Next-Router-Segment-Prefetch лежит путь до этого сегмента. Все prefetch-запросы помечены Next-Router-Prefetch: 1 (плюс обычный RSC: 1), так что в Network их легко отличить от навигационных.
Может показаться что такое количество запросов может бить по производительности, но это не так, и вот почему:
- дедупликация и кеш. Перед запросом клиент смотрит в Segment Cache: если сегмент уже там (или запрос к нему уже в процессе), то повторно не запрашивает.
- переиспользование общих сегментов. Корневой layout и прочие общие куски выкачиваются однократно на всё приложение, а не заново под каждую ссылку.
- запросы мелкие и параллельные. Один запрос — один сегмент; а по HTTP/2 они мультиплексируются по одному соединению.
- это управляемые задачи. Prefetch'ем рулит планировщик с приоритетами: он умеет в том числе притормаживать и отменять неактуальные задачи (например, когда увели мышку).
Если для конкретной ссылки такое поведение нежелательно, prefetch можно ослабить или выключить через проп prefetch у <Link>.
Серверная сторона заранее готовит контент так, чтобы его можно было резать на сегменты, и помечает каждый подсказками — нужно ли его вообще запрашивать отдельно, есть ли под ним граница loading. Тут есть нюанс с тем, какие маршруты поддерживают посегментный prefetch: при включённых Cache Components (cacheComponents: true в next.config) — все, а без них — только полностью статические страницы, потому что их посегментные ответы заготавливаются на этапе статической генерации (билд или ISR), а не на лету.
Server Actions
Server Actions — это вызов серверной функции прямо из клиента, без ручного написания API-маршрута.
На сборке каждая функция с директивой "use server" получает стабильный идентификатор — по сути хеш. Сервер заводит карту «идентификатор → реальная функция», которая хранится в файлах server-reference-manifest.
На клиенте вызов такой функции превращается в POST-запрос: в заголовке Next-Action едет этот идентификатор, а в теле — сериализованные аргументы. Сервер по идентификатору находит нужную функцию, декодирует аргументы и исполняет её.
Ответ возвращается как Flight-поток (Content-Type: text/x-component), а в нём лежат две разные вещи:
- возвращаемое значение функции — то, что мы написали в
returnвнутри экшена (в полезной нагрузке это полеa, от action result); - обновлённое дерево маршрута — результат ре-рендера страницы (поле
f, от flight).
Вот важный нюанс: ре-рендер происходит не всегда, а только если экшен инвалидировал кеш, например, вызвал revalidatePath() / revalidateTag() или сделал redirect(). Тогда сервер понимает, что данные на странице могли устареть, перерендеривает текущий маршрут и кладёт обновлённый Flight рядом с возвращаемым значением. Клиент применит его тем же механизмом, что и при навигации, и UI сразу покажет свежие данные без отдельного запроса. Если же экшен ничего не инвалидирует, поле f приедет пустым, и в ответе будет только возвращаемое значение.
Если идентификатор неизвестен (например, запрос прилетел от старого деплоя), сервер сразу отвечает «такого экшена нет» и не запускает дальнейшую обработку — ни выполнение функции, ни возможный ре-рендер маршрута.
Отдельного внимания заслуживает безопасность замыканий. Серверный экшен можно объявить инлайн, прямо внутри компонента, — и тогда он может захватывать переменные из окружающей области видимости. Например, экшен pickInline ниже использует secret, объявленный в компоненте этажом выше.
// TagPicker.tsx — серверный компонент
import { pickTag } from './actions/tag';
export async function TagPicker() {
const secret = await getServerToken(); // значение, известное только серверу
// secret захвачен замыканием инлайнового экшена → его зашифруют
async function pickInline(tagId: string) {
'use server';
await pickTag(secret, tagId);
}
return (
<>
<form action={pickInline.bind(null, 'hot')}>...</form>
</>
)
}
В этом и кроется риск. Чтобы клиент потом смог вызвать такой экшен, захваченная переменная должна незаметно для тебя уехать на клиент и вернуться обратно (клиент обязан прислать её при вызове). А в замыкание экшена легко попадает что-то серверное — токен, ключ, внутренний id.
Чтобы это не превратилось в дыру в безопасности, такие захваченные переменные шифруются. Происходит это на сборке ключом, единым для деплоя. Идентификатор экшена при этом приписывается к данным как контрольная сумма и проверяется при расшифровке, что защищает от подмены. На клиенте оседает только шифротекст, прочитать или подменить его там нельзя — расшифровка возможна лишь на сервере, у которого есть ключ.
Важно понимать границу: шифруется только захват через замыкание. Аргументы, переданные экшену явным .bind(null, …), не шифруются, поэтому они едут открытым JSON и в исходнике страницы, и в теле POST-запроса. Скрытое замыкание прячут, чтобы поймать случайную утечку, а явный переданный аргумент — нет.
// TagPicker.tsx — серверный компонент
import { pickTag } from './actions/tag';
export async function TagPicker() {
const secret = await getServerToken(); // значение, известное только серверу
return (
<>
{/* secret передан явным .bind → поедет открытым */}
<form action={pickTag.bind(null, secret, 'hot')}>...</form>
</>
)
}
Ещё одно следствие архитектуры Server Actions: формы с ними работают даже без JavaScript. Если экшен привязан к <form action={...}>, браузер отправит обычный POST, а сервер обработает его, отрендерит и вернёт страницу.
Итого
Жизнь страницы после загрузки в App Router — это двустороннее общение клиента с сервером поверх Flight. Цена за всю эту модель — сложность: два рантайма, собственный протокол, многоуровневый кеш и неочевидные границы серверного и клиентского кода. Но в совокупности оно даёт то, ради чего всё затевалось, — минимум JavaScript в браузере при сохранении SSR и стриминга.
Часть 5. Серверный слой.
Введение
В прошлых частях мы разобрали Pages и App Router, но весь этот разговор начинался уже внутри рендеринга. На самом деле до рендера запрос проходит через целый слой, который мы лишь мельком упомянули в первой части: матчинг маршрута, middleware, проверку файловой системы, отдачу статики.
В этой части мы посмотрим на этот слой. Проследим путь запроса от момента, когда он пришёл на порт, до момента, когда вызывается рендер. Главное, что стоит держать в голове с самого начала: next start поднимает два логических слоя. Один отвечает за маршрутизацию, второй — за рендеринг.
Колбэк на HTTP-сервере
В основе своей Next.js не делает ничего экзотического с сетью. Когда вы запускаете next start, фреймворк создаёт обычный Node.js HTTP-сервер и навешивает на него один обработчик запросов. Всё, что Next.js умеет — роутинг, рендеринг, кеширование — это то, что происходит внутри этого обработчика. Снаружи это просто функция (req, res), как в любом сервере на голом Node.
Из этого следует и то, как Next.js встраивается в чужую инфраструктуру: если у вас уже есть HTTP-сервер, Next.js может отдать вам свой обработчик, и вы навесите его сами.
Любопытная деталь старта: сервер начинает слушать порт раньше, чем готова вся внутренняя машинерия. Запросы, которые прилетели в этот момент, не отбрасываются, а дожидаются, пока инициализация завершится, и только потом обрабатываются. Это сделано ради того, чтобы сокет открывался как можно быстрее и ни один ранний запрос не потерялся.
Два слоя: router-server и render-server
Теперь к главному. Запустив HTTP-сервер, Next.js поднимает над ним два слоя, у которых принципиально разные задачи.
Первый слой router-server — это маршрутизатор. Он принимает сырой запрос и решает, что с ним делать: применить редирект из конфига, прогнать через middleware, переписать URL по правилу rewrite, отдать статический файл с диска или, если речь о настоящей странице, передать запрос дальше — на рендеринг. Сам router-server ничего не рендерит. Он не знает про React, RSC и гидратацию.
Второй слой — render-server. Это тот самый сервер, который мы неявно обсуждали в предыдущих частях: он берёт разрешённый маршрут и превращает его в HTML или Flight-поток. Внутри render-server живёт BaseServer и его реализация для Node.js, про которые шла речь в первой части.
Дальше мы рассмотрим подробнее router-server — именно там происходит всё интересное до рендера.
Порядок обработки запроса
Когда запрос попадает в router-server, он прогоняется через фиксированную последовательность шагов, которую можно сложить в такую лестницу:
- Заголовки из
next.config— добавляются к ответу. - Редиректы из конфига — если совпало, запрос разворачивается.
- Middleware — если URL подходит под матчер, запускается наш код.
- Rewrites группы
beforeFilesизnext.config— переписывание URL до проверки файлов. - Проверка файловой системы — точное совпадение с реальным маршрутом или статическим файлом.
- Rewrites группы
afterFilesизnext.config— переписывание URL, если по файлам ничего не нашлось. - Динамические маршруты и повторная проверка.
- Rewrites группы
fallbackизnext.config— последний шанс переписать URL.
Этот порядок объясняет целый класс вопросов, например, почему middleware видит запрос раньше, чем срабатывает редирект из вашего кода внутри страницы.
В минимальном режиме — так Next.js работает на serverless-платформах, где маршрутизацию берёт на себя сама платформа — почти все эти шаги отключены: редиректы, заголовки и rewrites уже применены инфраструктурой платформы. Router-server в этом режиме занимается в основном тем, что доводит запрос до рендера.
Что сервер отдаёт сам
Важный момент: далеко не каждый запрос доходит до render-server. На шаге проверки файловой системы router-server сверяет путь с тем, что у него есть на диске, и для целого ряда случаев отвечает сам, не запуская рендеринг вообще.
Чтобы понимать, что есть на диске, на старте router-server считывает служебную информацию о сборке: идентификатор сборки, списки страниц и роут-хендлеров, содержимое папки public, правила для middleware. Для всего этого сервер использует для этого уже знакомые нам манифесты.
Существует развилка по типу совпадения:
- Запрос к
/_next/static/...— это собранные при билде чанки. Они отдаются как статические файлы напрямую, без какого-либо участия React. - Запрос к файлу из папки
public— тоже отдаётся как статика. - Запрос к оптимизируемой картинке уходит в оптимизатор изображений.
- И только запрос, который совпал с настоящей страницей или роут-хендлером, передаётся дальше — в рендеринг.
API-эндпоинты
До сих пор мы говорили про то, что Next.js делает сам. Но часть маршрутов разработчик описывает руками — это API-эндпоинты. Тут полезно понять, как наш код подключается к слою, который мы только что разобрали. В Next.js есть две модели написания эндпоинтов, и они довольно разные.
Route Handlers — современная модель App Router. Мы создаём файл route.ts и экспортируем из него функции, названные по HTTP-методам: GET, POST, DELETE и так далее. На сборке Next.js оборачивает этот файл в служебный модуль, который собирает из наших экспортов таблицу «метод → функция». Когда приходит запрос, модуль смотрит на его HTTP-метод и вызывает соответствующий обработчик.
API Routes — старая модель Pages Router. Здесь мы экспортируем один обработчик по умолчанию — функцию (req, res). Next.js прогоняет её через свою обвязку, которая досыпает в req и res удобные хелперы: разбор тела запроса, чтение кук, методы вроде res.json(). Это классический Node-стиль: мы получаем запрос и ответ Node.js, слегка обогащённые, и сами пишем в ответ.
С точки зрения router-server API-эндпоинт и страница — это один и тот же тип «выхода»: динамический маршрут, который надо передать в render-server. Просто эндпоинт рендерится не в HTML, а в ответ, который мы сформировали руками.
Middleware (proxy)
Вторая точка, где код разработчика врезается в серверный слой — это middleware (в свежих версиях его постепенно переименовывают в proxy). Мы описываем в нём правило — для каких путей он должен срабатывать. На сборке это правило превращается в набор регулярных выражений, которые ложатся в служебную карту (middleware-manifest). В рантайме на третьем шаге router-server сверяет путь запроса с этими выражениями, и если совпало — запускает наш код. Правило может быть и сложнее простого совпадения по пути: можно требовать наличия определённого заголовка или куки, либо наоборот их отсутствия.
Дальше наша функция получает запрос и возвращает ответ: «пропустить дальше», «переписать URL», «сделать редирект», «добавить заголовок». Но middleware не вызывает маршрутизатор напрямую. Вместо этого его решение кодируется в специальные служебные заголовки ответа.
Router-server читает эти служебные заголовки обратно и поступает согласно им: разворачивает редирект, подменяет путь, прокидывает изменённые заголовки в следующий шаг.
У middleware есть и важное ограничение: он всегда исполняется на Edge, а не в полноценном Node.js. Причина в его роли: middleware стоит на пути всех запросов, подходящих под его правило, ещё до того, как стало понятно, что вообще с запросом делать. На таком горячем участке тяжёлая среда исполнения бы сильно резала производительность.
Передача в рендер
Допустим, запрос прошёл всю лестницу: его не развернул редирект, middleware пропустил дальше, по файловой системе он совпал с настоящей страницей. Теперь router-server передаёт его в render-server.
Router-server уже проделал всю работу по разрешению маршрута: он знает финальный путь и параметры запроса. Эту разрешённую информацию он добавляет как служебные метаданные и зовёт обработчик render-server. Тот берёт готовый разрешённый маршрут и сразу идёт в пайплайн рендера, тот самый, что мы разбирали в предыдущих частях.
При этом render-server — это не финальная инстанция. Router-server вызывает его так же, как вызывал любой другой шаг лестницы. И если render-server возвращает «этот путь отрендерить не удалось» (например, для динамического маршрута без предрендера и без fallback), router-server не считает это концом: последнее слово остаётся за ним, и он просто продолжает лестницу со следующего шага, пробуя подобрать другой выход.
Итого
Серверный слой Next.js — это два слоя с разделением труда. Router-server занимается маршрутизацией: прогоняет запрос через фиксированную лестницу из заголовков, редиректов, middleware, rewrites и проверки файловой системы; статику, ассеты и картинки отдаёт сам; а до рендера доводит только то, что действительно нужно рендерить. Render-server — отдельный слой, который берёт уже разрешённый маршрут и превращает его в ответ. Код разработчика встраивается в эту трубу в двух местах: эндпоинты и middleware.
Часть 6. Кастомный сервер.
Введение
В прошлой части мы разобрали серверный слой Next.js и познакомились с router-server, который занимается маршрутизацией, и render-server, который отвечает за рендеринг.
Стандартно Next.js сам поднимает сервер. Однако фреймворк позволяет написать кастомный сервер и самому решать, что делать с входящими запросами. В этой части мы разберём такой вариант подробнее.
Зачем может понадобиться кастомный сервер
Кастомный сервер — это когда мы поднимаем HTTP-сервер в том же процессе, что и Next.js, и сами передаём ему запросы. Это долго живущий Node.js-процесс, который мы держим самостоятельно. На платформах, где Next.js разворачивается как набор serverless-функций с маршрутизацией на стороне инфраструктуры (например, Vercel), такой процесс не вписывается в модель, и мы рискуем потерять часть платформенных оптимизаций.
Тем не менее остаются задачи, ради которых это может быть оправдано. Например, когда часть маршрутов в том же процессе обслуживает не Next.js, а отдельное Express- или Fastify-приложение, GraphQL-эндпоинт или вебхук. Сюда же относится WebSocket-сервер, которому по тем или иным причинам нужно жить в одном процессе и на одном порту с Next.js.
Всё это — про сценарии, когда нам нужно держать Next.js и другую серверную логику в одном процессе на одном хосте. Такое может потребоваться для serverful-приложения или если мы ограничены со стороны инфраструктуры. И всё же зачастую современный Next.js предлагает решения, не требующие собственного сервера.
Минимальный сервер
Канонический кастомный сервер выглядит так:
import { createServer } from 'http'
import next from 'next'
const port = parseInt(process.env.PORT || '3000', 10)
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
createServer((req, res) => {
handle(req, res)
}).listen(port)
})
Здесь работают три вызова, и у каждого своя роль.
next(options) создаёт экземпляр приложения. В объект опций можно передать почти то же, что живёт в next.config.js, а также dev, dir (расположение проекта), hostname, port, httpServer и выбор бандлера (turbopack либо webpack). Функция ничего не запускает — она только конструирует объект.
app.prepare() инициализирует приложение и возвращает промис.
app.getRequestHandler() возвращает функцию-обработчик с сигнатурой (req, res, parsedUrl?). Именно её мы навешиваем на свой HTTP-сервер. Всё, что Next.js дальше делает с запросом, спрятано за этим handle.
Сам файл server.js не проходит через компилятор и бандлер Next.js, в отличие от страниц и компонентов.
Путь запроса в handle
Что делает handle, когда мы вызываем handle(req, res)?
Дело в том, что handle — это обработчик запросов router-server. Поэтому запрос попадает не сразу в рендеринг, а в начало той же лестницы, которую мы обсуждали в предыдущей части: заголовки из конфига, редиректы, middleware, rewrites, проверка файловой системы, отдача статики, оптимизация картинок — и только затем, если нужно, рендеринг через render-server.
Поскольку handle делегирует запрос в router-server, а middleware (он же proxy) — это один из шагов лестницы router-server, то proxy также отрабатывает, если путь подходит под его матчер. Однако здесь важно помнить, что он срабатывает только для того, что дошло до handle. Если наш кастомный сервер перехватил маршрут раньше и обработал его сам, ни разу не позвав handle, — Next.js этого запроса просто не видит, и его proxy для такого маршрута не запустится.
Кастомный сервер, таким образом, не заменяет сервер Next.js, а оборачивает его. Мы владеем сокетом и самым внешним (req, res), но всё, что происходит с запросом дальше, — это по-прежнему двухслойный сервер Next.js. В итоге весь процесс можно записать одной строкой: наш HTTP-сервер → handle → router-server → render-server.
Кастомная маршрутизация
Раз handle прогоняет запрос через весь роутинг Next.js, то где же тогда учитывается наша собственная маршрутизация? Для этого существует третий аргумент handle — parsedUrl.
Правильная модель кастомного роутинга выглядит так: мы сами разбираем req.url, при необходимости меняем pathname или query и передаём изменённый объект в handle третьим аргументом. Дальше, если parsedUrl передан, обработчик пересобирает req.url из него и только потом отдаёт запрос в router-server. По факту мы правим то, что router-server увидит как входной URL, а всю остальную работу он делает сам.
createServer((req, res) => {
const parsedUrl = parse(req.url, true)
const { pathname } = parsedUrl
if (pathname === '/legacy') {
// отдать запрос так, будто пришли на /modern —
// но пройдя через весь роутинг Next.js
handle(req, res, { ...parsedUrl, pathname: '/modern' })
return
}
handle(req, res, parsedUrl)
}).listen(port)
Pages Router и App Router
Зная о двух роутерах Next.js, мы вправе ожидать, что для App Router кастомный сервер настраивается как-то иначе. Однако нет. Кастомный сервер работает над router-server, а разрешение того, к какому роутеру относится маршрут, происходит гораздо глубже, уже внутри render-server. Поэтому слой, которым мы управляем через handle, вообще не знает и не должен знать, Pages это или App Router. Он оперирует сырым запросом и разрешённым маршрутом, а различие между роутерами для него не существует. Один и тот же handle обслуживает обе архитектуры, в том числе если они сосуществуют в одном приложении.
Устаревшие способы
Исторически у кастомного сервера был другой инструмент — метод app.render(req, res, pathname, query). Он позволял отрендерить конкретную страницу напрямую. Аналогично работали методы renderToHTML(), renderError(), render404(). В свежих версиях Next.js все эти методы помечены как устаревшие.
Причина деприкейта — раньше app.render() шёл в рендеринг напрямую, минуя router-server, а значит, минуя middleware, rewrites, редиректы и решения о кешировании. Для простого случая это работало, но означало, что часть поведения Next.js, которое разработчик видел при обычном запуске, при такой реализации пропускалась.
Однако в актуальных версиях этой разницы в поведении уже нет. Если заглянуть в текущую реализацию render() внутри NextCustomServer, окажется, что она больше ничего не рендерит напрямую, а только нормализует pathname, пересобирает из него, query и parsedUrl новый req.url и вызывает ровно тот же this.requestHandler, что и getRequestHandler(), то есть тот же самый обработчик запросов router-server. Это значит, что сейчас app.render() проходит через ту же лестницу, что и обычный путь.
Команда Next.js сводит все варианты к одному getRequestHandler(), чтобы свободно менять внутренности, а метод render() и аналогичные ему оставляет тонкими обёртками с предупреждением о деприкейте.
На одном из моих рабочих проектов кастомный сервер устроен именно на основе app.render(). Приложение не запускается через next start. Ответственным за процесс выступает NestJS на Express-адаптере: он владеет HTTP-сервером, роутингом, guard'ами и BFF, а Next.js вызывают только чтобы отрендерить страницу или отдать /_next/* и статику. Маршрутизацию страниц описывает контроллер NestJS: у каждого роута свой набор @UseGuards (авторизация, фичафлаги), которые отрабатывают на сервере до рендера, а @Render('some-page') под капотом превращается в вызов app.render(req, res, '/some-page') из Next.js.
Исторически такую связку нам давал пакет nest-next, но он перестал поддерживаться, и на очередном апгрейде Next мы переписали мост сами — на тот же публичный API кастомного сервера (next(), prepare() и т.д.). Для рендера страниц мы пока что оперлись на app.render(req, res, view), а не на getRequestHandler(), по двум причинам. Во-первых, так сохранялся прежний контракт: @Render('view') в контроллере продолжал один в один отображаться на app.render(req, res, '/view'), и переписать нужно было только прослойку, а не сами контроллеры. Во-вторых, сигнатура render(req, res, view) буквально выражает нашу модель — «контроллер уже выбрал страницу и пропустил её через guard'ы, теперь отрендери ровно эту view». getRequestHandler() устроен иначе: он берёт страницу из req.url. Там, где путь запроса и есть путь страницы, его можно отдать как есть. Но в случае с @Get('*') @Render('404') он должен отрендерить /404 независимо от того, какой прилетел URL, поэтому render(req, res, view) в данном случае подходит больше. Однако целевое решение, скорее всего, будет заключаться в переезде на getRequestHandler().
Ограничения
У кастомного сервера есть несколько несовместимостей и ограничений.
Он несовместим с режимом output: 'standalone'. Этот режим нужен, чтобы получить маленький самодостаточный артефакт для деплоя. Обычно, чтобы запустить собранное приложение через next start, на сервере должны лежать папка .next, весь node_modules и package.json — и основной вес приходится на node_modules, куда попадают в том числе зависимости сборки и разработки. Standalone решает это трассировкой: на next build Next статически анализирует серверный код, определяет, какие файлы реально нужны в рантайме, и складывает в папку .next/standalone только их — скомпилированный сервер, нужные куски node_modules и свою точку входа server.js, которую запускают через node server.js вместо next start. Получается папка, которой для старта достаточно установленного Node.js. Конфликт с кастомным сервером вытекает прямо из этого устройства. Наш server.js в эту трассировку не попадает, поэтому при попытке запустить собственный сервер он упадёт в рантайме, так как не найдёт нужные модули из node_modules.
Кастомный сервер также несовместим с output: 'export'. Экспорт превращает приложение в набор статических файлов в out/, и всё, что требует живого сервера, в нём запрещено: getServerSideProps, API-роуты, middleware/proxy, rewrites, redirects, headers, ISR, дефолтная оптимизация картинок. Если что-то из этого используется, падает уже сборка next build. Если же приложение полностью статическое и собирается, кастомный сервер в том понимании, в котором мы рассматривали его здесь, как правило не нужен.
Отдельно стоит упомянуть опцию useFileSystemPublicRoutes. По умолчанию Next.js обслуживает каждую страницу из pages/ по пути, совпадающему с её именем: файл pages/product.tsx доступен по адресу /product сам по себе, без дополнительной нашей маршрутизации. Когда роутинг строит кастомный сервер, это мешает: одну и ту же страницу мы отдаём по своему адресу (например, рендерим product на /products/:id), но она параллельно остаётся доступна и по «файловому» /product. Получается один контент по двум URL — дубли для поисковиков и открытые наружу пути, которых мы не планировали.
useFileSystemPublicRoutes: false выключает эту автоматическую файловую маршрутизацию: на сервере рендерятся только те пути, которые наш сервер обрабатывает явно, а прямой заход на файловый путь отдаёт 404. Важная оговорка: выключение работает только на стороне сервера. Клиентский роутинг (переходы по next/link, кнопка «назад») всё ещё может открыть такую страницу — её бандл лежит на клиенте, и клиентская навигация не проходит через наш сервер. Так что полностью закрыть путь одним флагом не выйдет: клиентские переходы придётся запрещать отдельно.
Что касается dev-режима — за кастомным сервером он продолжает работать, поднимая бандлер через тот же router-server, но у этого достаточно нюансов, чтобы разобрать их отдельно. Здесь достаточно помнить, что server.js живёт вне компилятора, поэтому про его перезапуск при изменениях стоит подумать отдельно.
Итого
Кастомный сервер — это не замена сервера Next.js, а обёртка над ним. Мы берём под контроль самые внешние (req, res), но всё, что происходит с запросом дальше, остаётся двухслойной машиной из прошлой части.
Большинство причин, по которым раньше писали кастомный сервер — редиректы, rewrites, заголовки, backend-for-frontend логика, — сегодня закрываются конфигом и proxy, без собственного процесса и без отказа от платформенных оптимизаций. Кастомный сервер по-прежнему существует и по-прежнему работает, но современный Next.js устроен так, чтобы к нему приходилось прибегать всё реже.
Часть 7. Сборка.
Введение
В предыдущих частях мы уже смотрели, что может появиться в директории .next/ после выполнения команды next build. Теперь посмотрим, как этот результат генерируется: какой механизм превращает .tsx-файлы в артефакты, которые потом обслуживают запросы пользователя.
Компилятор и бандлер
Когда мы говорим про сборку Next.js-приложения, за этим стоят две разные задачи.
Первая — трансформация отдельного файла: взять один .tsx, убрать из него типы, превратить JSX в вызовы React, заминифицировать и т.д. Эту работу делает компилятор. Ключевая его особенность в том, что он смотрит на файл изолированно. Он не знает, кто этот файл импортирует, какие ещё модули есть в проекте, что из этого попадёт на клиент, а что останется на сервере. Один файл на входе — один файл на выходе.
Вторая задача — построение графа зависимостей и бандлинг: пройти от точек входа по всем импортам, собрать граф модулей, разложить его на чанки, понять, какой код общий, а какой уникален для маршрута, вырезать неиспользуемое и разложить всё это по целевым средам — клиент, Node.js-сервер, Edge. Это работа бандлера, и он, в отличие от компилятора, видит приложение целиком.
В Next.js роли распределены так: компилятор — это SWC, а бандлер — Webpack, Turbopack или, экспериментально, Rspack. При этом Turbopack использует SWC внутри себя для трансформации отдельных файлов — только, в отличие от Webpack, который дёргает SWC как внешний нативный модуль через N-API, Turbopack сам написан на Rust и подключает SWC как обычную библиотеку.
Исторически стек Next.js двигался в одном направлении. Сначала это была классическая для JavaScript связка: Babel как компилятор, Terser как минификатор, Webpack как бандлер. В версии 12 Babel по умолчанию заменили на SWC — компилятор на Rust, что ускорило трансформацию в разы. Затем появился Turbopack, нацеленный на то, чтобы заменить связку SWC+Webpack единым Rust-инструментом. По итогу, как и в других частях фронтенда, в Next.js происходит миграция тулинга с JavaScript на Rust ради скорости.
SWC
SWC (Speedy Web Compiler) — компилятор на Rust, отвечающий за трансформацию отдельных файлов. На каждый .ts, .tsx или .js он выполняет предсказуемый набор преобразований: стирает TypeScript-аннотации, превращает JSX в вызовы рантайма React, при необходимости понижает современный синтаксис под список поддерживаемых браузеров, а на этапе оптимизации минифицирует код.
SWC написан на Rust, а Next.js исполняется в Node.js, поэтому связь между ними — нативный модуль через N-API, механизм, позволяющий вызывать Rust-код напрямую, без отдельного процесса. Устроено это так: под каждую комбинацию платформы и архитектуры опубликован свой пакет — @next/swc-linux-x64-gnu, @next/swc-darwin-arm64 и так далее. При установке Next.js подтягивается тот, что подходит нужной системе. Если подходящего нативного бинарника не нашлось, есть запасной вариант — сборка на WebAssembly. Она медленнее нативной, но работает где угодно.
Помимо базовых преобразований, SWC применяет набор трансформаций, специфичных для Next.js. В продакшене он может вырезать вызовы console.* и убирать служебные атрибуты вроде data-testid. Для CSS-in-JS есть трансформы, которые обеспечивают стабильные имена классов и корректную работу при серверном рендеринге.
Отдельного внимания заслуживают modularizeImports и optimizePackageImports — они разворачивают импорты из баррель-файлов. Это практически важная штука. Когда мы пишем import { Button } from 'ui-kit', а ui-kit/index.ts реэкспортирует сотни компонентов, наивный бандлинг рискует затащить в граф их все. Трансформ переписывает такой импорт в точечный, прямо на конкретный модуль, чтобы в бандл попало только то, что реально используется. modularizeImports делает это по заданному в конфиге шаблону пути, optimizePackageImports же работает автоматически, поскольку строит карту экспортов.
Наконец, именно на уровне SWC обрабатываются директивы "use client" и "use server" — за это отвечают трансформы serverComponents и serverActions. Здесь серверные экшены получают свои идентификаторы, а границы между серверным и клиентским кодом размечаются для последующих шагов бандлера.
Babel также поддерживается в Next.js как альтернативный вариант. Если в проекте есть конфигурация Babel (.babelrc или babel.config.js), Next.js автоматически переключается на Babel. Но за это приходится платить отключением SWC-трансформаций, а значит, потерей скорости и части оптимизаций.
Чтобы трансформация не упиралась в один поток, есть пул воркеров. Это собственная инфраструктура Next поверх обычных worker_threads из Node.js, которой управляет сам нативный биндинг SWC: он через метод registerWorkerScheduler просит Next создавать и завершать потоки, а Next выступает лишь фабрикой этих потоков. Так работа компилятора раскладывается по нескольким ядрам.
Webpack
Webpack — бандлер, на котором Next.js прожил большую часть своей истории, и именно его конфигурация до сих пор задаёт эталон того, как должна выглядеть итоговая сборка. Даже после перехода на Turbopack полезно понимать Webpack-модель, потому что Turbopack во многом воспроизводит её результат.
Next.js не собирает приложение одним проходом. Функция построения конфигурации вызывается отдельно под каждую целевую среду — client, server для Node.js-рантайма и edge для Edge-рантайма. У каждой среды свои внешние зависимости, свой формат вывода, свой рантайм. Клиентский бандл должен работать в браузере, серверный — иметь доступ к Node.js API, edge — укладываться в ограничения Web-совместимой среды. По сути это три разных компилятора Webpack, результаты которых потом сшиваются в единую сборку.
Слои
Внутри каждой среды webpack использует механизм слоёв (layers). Со слоями мы уже сталкивались в третьей части, когда говорили про две сборки React — RSC-рантайм (серверный React без useState и useEffect, сериализующий дерево в Flight-поток) и SSR/клиентский рантайм (обычный React). Переключает их условие экспорта react-server в package.json самого React, и тогда мы отметили, что Next включает это условие точечно для слоя в бандлере.
Слой — это некий ярлык, который бандлер вешает на модуль. Сам по себе он не меняет содержимое модуля, но меняет две вещи в том, как модуль обрабатывается: какие условия применяются при разрешении его импортов и через какие загрузчики он проходит. Полный список слоёв выглядит так:
const WEBPACK_LAYERS_NAMES = {
shared: 'shared',
reactServerComponents: 'rsc',
serverSideRendering: 'ssr',
actionBrowser: 'action-browser',
apiNode: 'api-node',
apiEdge: 'api-edge',
middleware: 'middleware',
instrument: 'instrument',
edgeAsset: 'edge-asset',
appPagesBrowser: 'app-pages-browser',
pagesDirBrowser: 'pages-dir-browser',
pagesDirEdge: 'pages-dir-edge',
pagesDirNode: 'pages-dir-node',
};
Ключевые для разделения клиента и сервера — это rsc (серверные компоненты), ssr и app-pages-browser (клиентский бандл App Router).
Главное, что делают слои, — управляют разрешением модулей. Для слоя rsc Next дописывает условие react-server в начало списка условий резолвера. Поэтому import ... from 'react' внутри серверного компонента разрешается в урезанную серверную сборку React (где нет useState и useEffect), тогда как ровно тот же импорт в слое ssr или в клиентском слое разрешается в обычный React. Таким образом, серверный компонент не может случайно вызвать клиентский хук, потому что в его слое этого хука не существует.
Загрузчики и плагины
Внутри Webpack SWC подключён как загрузчик (next-swc-loader), через который проходит каждый модуль. Слои определяют и то, какой конфиг SWC-загрузчика достанется модулю: Next собирает отдельные экземпляры лоадера под каждый слой. Но помимо next-swc-loader, в цепочке участвует ещё несколько специализированных загрузчиков:
next-flight-loaderобрабатывает модули RSC, размечая границы серверного и клиентского кода;next-app-loaderпревращает файловые конвенции App Router —page,layout,loading,error— в модули маршрутов;next-font-loaderберёт на себяnext/font: скачивает и инлайнит шрифты на этапе сборки, чтобы убрать лишний сетевой запрос в рантайме;- цепочка из
postcss-loader,lightningcss-loaderиmini-css-extractпрогоняет стили через PostCSS-плагины и извлекает CSS в отдельные файлы.
Если загрузчики трансформируют отдельные модули, то плагины работают со сборкой целиком. Часть из них как раз и генерирует те манифесты, что мы разбирали во второй части.
Особняком стоят flight-manifest-plugin и flight-client-entry-plugin. Именно здесь физически появляется разделение на сервер и клиент. Плагин flight-client-entry-plugin обходит граф модулей, находит границы "use client" и для каждой серверной точки входа создаёт соответствующую ей клиентскую. А flight-manifest-plugin генерирует манифест клиентских ссылок, по которому рантайм сопоставляет плейсхолдеры из RSC Payload с реальными клиентскими чанками. Всё, о чём мы говорили в предыдущих частях про Flight Protocol, опирается на то, что происходит на этом шаге сборки.
Разделение на чанки
То, как Webpack решает, что положить в какой файл, задаётся самим Next.js в секции optimization.splitChunks. Для клиентской production-сборки логика следующая. Отдельно выделяется каркасный, framework-чанк — в него попадает код, который меняется реже всего: React, ReactDOM, сам Next.js. Держать его отдельно выгодно ради долгого кеширования в браузере. Крупные зависимости из node_modules выносятся в lib-чанки: если размер библиотеки больше ~160 КБ (160000 байт), то она получает отдельный чанк и не увеличивает общий бандл. Небольшой рантайм самого Webpack выносится в отдельный runtime-чанк.
Turbopack
Turbopack — бандлер, который Vercel пишет специально под Next.js. В версии 16 он стал бандлером по умолчанию, а Webpack остался доступен через флаг.
В основе Turbopack лежит система инкрементальных вычислений turbo-tasks. Идея в том, чтобы моделировать всю сборку как граф мемоизированных функций.
Устройство Turbopack
turbo-tasks оперирует несколькими примитивами. Функции — это единицы исполнения и инвалидации; конкретный вызов функции с аргументами называется задачей (task). Значения — это данные, которые функции создают и возвращают. Ссылка на результат задачи — это Vc (Value Cell, «ячейка значения»): не само значение, а указатель на ячейку, содержимое которой может измениться при пересчёте. Когда одна задача читает Vc другой, между ними образуется зависимость, и turbo-tasks её запоминает.
Все задачи и их зависимости образуют граф задач. Дальше начинается инкрементальность. Когда что-то меняется — например, содержимое файла — система помечает соответствующую задачу как «грязную», и инвалидация распространяется снизу вверх по графу: от изменившегося листа к тем задачам, что от него зависят. Пересчитывается только затронутый подграф; всё, чего изменение не коснулось, остаётся как есть.
Инкрементальность легко воспринять как dev-фичу — пересобираем только то, что поменялось при сохранении файла. Но и в продакшене у неё есть не менее важное проявление. Результаты задач можно сохранять на диск между запусками. Это персистентный кеш сборки: повторный next build, локальный или в CI с сохранённым кешом, не начинает с нуля, а переиспользует незатронутые результаты. Так инкрементальная модель ускоряет production-билды.
Поверх turbo-tasks выстроен бандлер, разбитый на Rust-пакеты, например:
turbopack-coreсодержит граф модулей и алгоритм разбиения на чанки;turbopack-ecmascriptотвечает за обработку JS и TS и опирается на SWC;turbopack-cssобрабатывает стили;turbopack-resolveразрешает импорты в модули;turbopack-nodeумеет исполнять Node.js-код прямо внутри графа, например для получения данных на этапе сборки.
Главное архитектурное отличие от Webpack — единый граф. Webpack запускает отдельные компиляторы под client, server и edge и сшивает результаты. Turbopack же строит один граф зависимостей на все целевые среды сразу.
Как Next.js управляет Turbopack
С точки зрения Next.js Turbopack — это нативный модуль, доступный через N-API, которому Next создаёт объект Project для общения с ним и передаёт опции сборки. Next запрашивает у Turbopack через этот Project точки входа, а затем Turbopack собирает из результатов такие же манифесты, какие генерирует и Webpack. Серверный слой, который мы разбирали в пятой части, не должен знать, каким бандлером собрано приложение — он читает манифесты в едином формате независимо от того, Webpack их произвёл или Turbopack.
Другие шаги в next build
Компиляция и бандлинг — центральный, но далеко не единственный этап сборки. Внутри сборки приложения на Next.js запускается длинный конвейер, где бандлинг — лишь один из шагов.
Сначала генерируется идентификатор сборки (buildId). Затем загружается и валидируется next.config.js, разрешаются redirects, rewrites и headers. Дальше Next обходит app/ и pages/, обнаруживает все маршруты и строит по ним карту — routes-manifest. И только после этого наступает компиляция: webpackBuild или turbopackBuild, в зависимости от выбранного бандлера. Здесь работают SWC и бандлер, генерируются чанки и большинство манифестов.
После компиляции Next трассирует используемые файлы (об этом ниже), а затем анализирует каждый маршрут, определяя его стратегию рендеринга: что можно предрендерить статически, а что придётся считать на каждый запрос. Маршруты, помеченные как статические, тут же и предрендериваются в HTML. Под конец, если включён standalone-вывод, формируется самодостаточная директория для деплоя, а в консоль печатается дерево со значками ○, ● и ƒ у каждого маршрута и размерами чанков.
Отдельно стоит выделить трассировку файлов. Её задача — статически проанализировать, какие файлы реально нужны для работы каждого серверного входа: не только наш код, но и транзитивные зависимости из node_modules. Результат — это список минимально необходимых файлов, из которого режим output: 'standalone' собирает компактную директорию, содержащую только то, что действительно используется в рантайме, без всего node_modules целиком.
Итого
За словом «сборка» в Next.js скрываются два слоя — компилятор, работающий с файлом изолированно, и бандлер, видящий приложение целиком. Оба этих слоя прямо сейчас активно мигрируют из мира JavaScript в мир Rust. Babel уступил место SWC, а Webpack постепенно заменяется на Turbopack.
За гибкость приходится платить сложностью: три (с учётом Rspack) возможных бандлера, три целевые среды, слои, манифесты, трассировка файлов — и всё это должно давать на выходе совместимый результат, который серверный слой обслужит, не зная деталей сборки.
Часть 8. Dev-mode.
Введение
В седьмой части мы разобрали, как next build превращает исходники в артефакты, а в пятой и шестой — как серверный слой эти артефакты обслуживает. Обе картины опирались на одно и то же допущение: к моменту, когда придёт первый запрос, манифесты и чанки уже собраны, а список маршрутов известен.
В dev-режиме этого допущения нет. next dev живёт в режиме, где сборка идёт параллельно с обслуживанием запросов, а её результат должен доезжать до уже открытого браузера. В статье рассмотрим нюансы этого процесса подробнее.
Процессы внутри dev
Команда next dev использует внутри себя два процесса. Родительский процесс форкает дочерний и следит за ним. Вся логика (HTTP-сервер, бандлер, рендеринг) живёт именно в дочернем процессе.
Разделение существует ради сценария перезапуска. Дочерний процесс отдельно следит за файлами конфигурации, и когда next.config.js меняется, он завершается со специальным кодом выхода. Родитель реагирует на этот код и поднимает дочерний процесс заново с теми же опциями. Так применяется новый конфиг.
Внутри дочернего процесса живут знакомые нам router-server и render-server. Между ними вклинивается бандлер — Webpack или Turbopack — как долгоживущий объект, к которому можно обращаться в рантайме.
Рядом с основным процессом Next держит ещё несколько воркеров, например, один из них обслуживает getStaticPaths и generateStaticParams. Эти функции отвечают на вопрос, какие конкретные пути динамического маршрута считаются известными заранее. К моменту их вызова в dev-сервере уже загружены модули, отработали предыдущие запросы, накопилось состояние. Если реализация случайно на это состояние опирается, то в dev всё будет работать, но production-сборка упадёт. Поэтому Next создаёт под каждый вызов отдельный воркер и тут же его уничтожает, воспроизводя условия билда.
Маршруты без манифестов
В production-режиме на старте router-server считывает манифесты для получения информации о сборке (списки страниц, правила для middleware и т.д.) и дальше использует их при проверке файловой системы. В dev отдельного шага сборки нет, поэтому карту маршрутов строит вотчер. Под наблюдением оказываются директории app/ и pages/, а также точечный набор файлов: кандидаты на middleware (он же proxy) и instrumentation, .env-файлы и tsconfig.json с jsconfig.json.
На каждое изменение вотчер заново обходит файлы и пересобирает всё, что из них следует: списки страниц, роут-хендлеров, лэйаутов и слотов, матчеры middleware, набор статических файлов метаданных. Он же обнаруживает конфликты, когда один и тот же путь описан и в app/, и в pages/, и он же генерирует типы маршрутов в .next/types. Router-server использует эти данные для шага проверки файловой системы. Благодаря этому новая страница в dev появляется без перезапуска сервера. Если набор маршрутов изменился, вотчер дополнительно сообщает об этом в браузер клиентскому роутеру.
Компиляция по требованию
В production Next.js загружает уже собранные модули. В dev, если страница ещё не собрана, запрос ждёт, пока бандлер её соберёт.
Webpack сам по себе не имеет механизма «ленивой» компиляции: он собирает всё, что перечислено в его точках входа. Поэтому механизм написан в Next, поверх обычного API бандлера, и построен вокруг карты entrypoints, которая живёт в памяти процесса. Каждая запись в ней знает свой статус (добавлена, собирается, собрана), время последнего обращения и флаг «помечена на выгрузку» (о нём чуть ниже). Если запись уже есть и она собрана, обращение за страницей просто обновляет время активности и снимает флаг, а компиляция не запускается. Если записи нет, она создаётся со статусом «добавлена», и вот тогда сборку нужно запустить.
Список entrypoints пересобирается заново перед каждой сборкой, и участвуют в ней все живые записи. Невалидными при этом помечаются только модули изменившихся файлов, всё остальное берётся из кеша модулей. А вот работа поверх модулей — построение графа чанков, кодогенерация, хеширование — проходит по всей компиляции целиком.
Число entrypoints за долгую сессию растёт, а раз в сборке участвуют все они, растёт и стоимость каждой итерации. Поэтому неактивные страницы выгружаются. Раз в шесть секунд таймер обходит карту и помечает флагом всё, что уже собрано и к чему не обращались дольше минуты. Затем этап компиляции выбрасывает помеченные записи из карты, и модули, которые подключались только через них, из сборки уходят.
Сервер узнаёт, что страница «активна», из пингов, которые идут через тот же сокет, по которому приходят обновления. Pages Router шлёт свой pathname. App Router шлёт всё дерево состояния роутера целиком, и сервер продлевает жизнь всем entrypoints, которые из этого дерева следуют.
С Turbopack отдельного механизма как у Webpack нет: граф turbo-tasks вычисляется по требованию, а неиспользуемое просто не пересчитывается.
Канал обновлений
Обратный канал, по которому Next.js сообщает об обновлениях в сборке клиенту, — это обычный WebSocket. Обработчик такого соединения находится в router-server. Если сервер запущен в dev и путь начинается с /_next/hmr, соединение отдаётся бандлеру. Всё остальное идёт по обычному пути — через разрешение маршрутов, rewrites и, при необходимости, проксирование. Для сокетов также проверяется origin (список допустимых источников задаётся конфигом).
По этому каналу ходит довольно много типов сообщений. Есть жизненный цикл компиляции: началась сборка, закончилась сборка, синхронизация состояния при подключении. Есть изменения карты маршрутов: страница добавилась, страница удалилась. Есть классификация того, что именно изменилось: клиентские изменения, изменения только серверной части, изменения middleware, изменения серверных компонентов, изменение набора статически известных параметров. Есть и команда перезагрузить страницу.
Fast Refresh
Fast Refresh — это функция среды разработки React, которая мгновенно показывает изменения кода в браузере, сохраняя при этом текущее состояние компонентов (например, введённый текст или открытые вкладки).
Fast Refresh можно перепутать или объединить с Hot Module Replacement, но уровни у них разные. HMR — общий механизм бандлера. Про React он ничего не знает и сам по себе не может решить, что делать с состоянием компонентов. Fast Refresh же умеет найти смонтированный компонент, подменить его реализацию и определить, можно ли сохранить состояние. Реализует его пакет react-refresh из репозитория React, а Next включает этот пакет в свой dev-режим.
Для клиентской сборки в dev включается флаг, который доезжает до SWC как опция react-трансформа. Трансформ регистрирует каждый компонент модуля под стабильным идентификатором и вычисляет для него сигнатуру — слепок того, какие хуки и в каком порядке использованы. Параллельно в сборку добавляется рантайм react-refresh, который умеет по этим идентификаторам находить смонтированные компоненты и подменять их реализацию.
Из этого напрямую вытекают следующие правила. Если модуль экспортирует только компоненты, реализацию можно заменить, сохранив состояние. Если сигнатура хуков изменилась — состояние восстановить нельзя, компонент перемонтируется. Если модуль экспортирует что-то помимо компонентов, гарантий нет: обновление уходит вверх по графу зависимостей и в пределе может дойти до полной перезагрузки страницы.
И главное ограничение: флаг включается только для клиентского слоя. Fast Refresh физически не существует для серверного кода.
Серверные изменения
Серверный компонент в браузере не живёт — там есть только результат его рендеринга. Поэтому на этом уровне работает другой механизм.
По окончании компиляции Next на сервере сравнивает множества изменившихся страниц по разным целевым средам и раскладывает изменения по категориям. Правки middleware дают одно сообщение, правки серверной части страниц Pages Router — другое, правки серверных компонентов ведут к третьему.
Параллельно нужно избавиться от старых модулей на самом сервере. Собранные серверные чанки лежат на диске и подгружаются обычным require, поэтому Next вычищает их из кеша модулей Node.js.
На клиенте сообщение о серверных изменениях обрабатывается так. Если страница сейчас в состоянии ошибки, просто происходит полная перезагрузка. В нормальном же случае вызывается refresh роутера внутри startTransition: клиент запрашивает новый RSC Payload и накатывает его поверх текущего дерева.
Кастомный сервер в dev
Про какие нюансы dev-режима нужно знать, когда мы используем кастомный сервер?
Вызов next({ dev: true }) поднимает полноценный dev-режим со всей описанной выше машинерией. Работает это потому, что handle — обработчик router-server, а бандлер вживлён именно в router-server, так что вместе с маршрутизацией мы получаем и сборку по требованию.
Дальше начинаются нюансы. Первый и самый заметный: HMR ходит по WebSocket, то есть через событие upgrade нашего HTTP-сервера, а handle — обработчик обычных запросов. Если апгрейд не пробросить в Next отдельно, страницы будут открываться нормально, но обновляться перестанут.
Также стоит помнить, что наш server.js не проходит через компилятор и не находится под вотчером, так что его изменения не подхватываются ничем. Перезапуск процесса при правке собственного сервера придётся организовать самостоятельно.
Итого
Dev- и prod-режимы в Next.js отличаются и в ряде других аспектов.
Кеширование в dev намеренно почти отключено. Предзагрузка ссылок при появлении во вьюпорте также выключена: она потребовала бы компилировать все страницы, на которые ведут видимые ссылки. При этом предзагрузка по наведению осталась.
Сборка тоже другая: нет минификации, нет production-стратегии разделения на чанки, зато есть source maps и development-сборка React с её проверками. Поверх всего этого React в Strict Mode рендерит компоненты дважды.
Из этого следует, что любые измерения производительности в dev измеряют только dev. Этот режим оптимизирован под цикл правки, а не под скорость ответа, и потому ничего не говорит о том, как приложение поведёт себя в бою.
Часть 9. Способы оптимизации.
Введение
Оптимизация в Next.js — это следствие решений, которые мы разбирали во всех предыдущих частях. Поэтому в этой части мы собираем вместе то, что уже видели, и добавляем несколько механизмов, о которых не успели поговорить до этого.
Статика как оптимизация по умолчанию
Самый дешёвый запрос — тот, который не требует рендеринга вообще. Next.js старается определить это на этапе сборки: если маршрут не читает cookies(), headers() или другие динамические API, он рендерится один раз в next build и дальше отдаётся как готовый HTML.
Возможность отдавать статику, которая при этом умеет обновляться, называется ISR (Incremental Static Regeneration). Идея в модели stale-while-revalidate: истёкшая по revalidate страница не блокирует пользователя пересчётом — ему отдаётся текущая, устаревшая версия, а пересчёт запускается в фоне и подменяет запись в кеше для следующих запросов.
После того как router-server разрешил маршрут и передал его в render-server, тот перед рендером идёт в инкрементальный кеш, построив ключ из пути и параметров. Дальше развилка:
- записи нет — рендерим, кладём результат в кеш вместе со значением
revalidate, взятым из конфигурации маршрута; - запись есть и не протухла — отдаём сохранённый HTML и RSC Payload, рендер не вызывается вообще;
- запись есть, но протухла — отдаём её как есть, а рендер уходит в фон и по завершении перезаписывает запись.
Обычная статика — частный случай той же механики: у её записи revalidate фактически бесконечен, поэтому третья ветка никогда не срабатывает.
Partial Prerendering
Деление на статику и динамику раньше было выбором на уровне всего маршрута: либо весь он предрендерится, либо весь считается на каждый запрос. Один персонализированный виджет в углу страницы — например, корзина или рекомендации — переводил в динамический режим всю страницу целиком.
Partial Prerendering (PPR) снимает это ограничение, разрешая статике и динамике сосуществовать в одном ответе. На этапе сборки Next.js рендерит страницу и на границах Suspense останавливается: то, что снаружи границ, попадает в статическую HTML-оболочку, а то, что внутри — превращается в postponed state, сериализованное состояние, откуда рендеринг можно продолжить позже.
На запросе сервер сразу отдаёт готовую оболочку, потому что она уже лежит в кеше, как обычная статика, а затем стартует рендер с сохранённого postponed state и досылает динамические куски в тот же поток, заполняя оставленные дыры.
У модели есть характерная ловушка: чтение searchParams или любого другого динамического API прямо в компоненте страницы делает динамическим всё, что вокруг него, потому что границы Suspense между этим чтением и корнем страницы нет — а значит, нет и точки, в которой рендер можно было бы приостановить, отложив только эту часть. Чтение динамических данных стоит опускать в дочерний компонент и уже его оборачивать в Suspense — тогда в дыру уходит только он, а не вся страница.
Разбиение бандла
В седьмой части мы разбирали, как Next.js выносит React и себя самого во framework-чанк, а крупные библиотеки из node_modules — в отдельные lib-чанки при превышении ~160 КБ. Эта логика работает без нашего участия и одинаково для всех маршрутов.
Рядом с ней работают SWC-трансформы modularizeImports и optimizePackageImports, которые мы тоже упоминали в седьмой части. Когда мы пишем import { Button } from 'ui-kit', а ui-kit/index.ts реэкспортирует сотни компонентов, наивный бандлинг рискует затащить в граф их все. Трансформ переписывает такой импорт в точечный, прямо на конкретный модуль. Разница между двумя вариантами в том, откуда берётся правило переписывания: modularizeImports требует задать шаблон пути в конфиге руками, optimizePackageImports строит карту экспортов сам и поэтому работает автоматически.
А вот next/dynamic — это уже инструмент, которым управляем мы, а не бандлер. Он построен поверх Loadable, форка библиотеки react-loadable. Компонент, обёрнутый в dynamic(() => import('./Component')), попадает не в основной чанк маршрута, а в отдельный, который бандлер выделяет по границе import(), и загружается по требованию, а не при первом заходе на страницу.
У этого механизма есть несколько практических деталей. По умолчанию Loadable ждёт 200 мс, прежде чем показать состояние загрузки — чтобы не мигать спиннером на быстрых загрузках. Также можно передать ssr: false и полностью исключить компонент из серверного рендера.
next/image
Компонент Image решает конкретную проблему: браузер не знает заранее, какого размера будет картинка, из-за чего страница дёргается, пока она не загрузится. Image требует указать width/height (или использовать статический импорт, откуда размеры выводятся сами) и резервирует место под картинку ещё до её загрузки.
Дальше в дело вступает серверный оптимизатор — эндпоинт /_next/image. Router-server, разобрав путь, отдаёт такой запрос в оптимизатор изображений. На вход оптимизатор принимает url, ширину w и качество q. Внутри происходит трансформация через sharp: изображение приводится к запрошенной ширине и перекодируется в формат, который, судя по заголовку Accept, поддерживает браузер. Результат такой трансформации кешируется на диске в .next/cache/images, чтобы в следующий раз можно было отдать уже посчитанный вариант, не вызывая sharp заново.
С точки зрения того, что мы отдаём в браузер, Image сам генерирует srcset на основе deviceSizes, так что браузер выбирает подходящий по своему вьюпорту размер.
next/font
Шрифты — источник сразу двух проблем: лишний внешний запрос и сдвиг макета, пока шрифт не подгрузился. next/font решает обе на этапе сборки.
Для этого используется next-font-loader — один из специализированных загрузчиков бандлера. В случае с next/font/local он читает файлы из проекта, считает метрики, генерирует @font-face. В случае с next/font/google к этому добавляется загрузка: лоадер идёт за CSS на fonts.googleapis.com, вытаскивает оттуда ссылки на файлы и скачивает их, чтобы положить рядом с остальной статикой приложения.
Момент загрузки — это момент обработки модуля лоадером. В продакшен-сборке это выполнение next build, а в dev шрифт скачивается тогда, когда впервые компилируется импортирующая его страница. Разной оказывается и цена неудачи: dev-режим на недоступный Google Fonts ответит предупреждением и fallback-шрифтом, продакшен-сборка упадёт. Для CI без выхода наружу это означает, что next/font/google придётся либо проксировать, либо заменять на next/font/local.
Вторая часть оптимизаций — автоматический подбор метрик шрифта для fallback. next/font знает метрики (ascent, descent, line gap) как целевого шрифта, так и системных шрифтов, которые браузер покажет, пока веб-шрифт не догрузился, и генерирует для fallback-шрифта корректировки через size-adjust и связанные CSS-свойства. Смысл в том, чтобы placeholder текста занимал столько же места, сколько займёт финальный шрифт, и переключение между ними не двигало вёрстку.
next/script
Сторонние скрипты — аналитика, чаты, виджеты — классический источник блокировки рендера. Браузер должен их скачать и выполнить прежде, чем продолжить строить страницу. next/script даёт явный контроль над тем, когда это происходит, через параметр strategy:
beforeInteractive— до гидратации, для критичного кода (антифрод, полифилы);afterInteractive— сразу после гидратации;lazyOnload— в простое браузера, для всего некритичного;worker— в отдельном воркере через Partytown, в стороне от главного потока.
По умолчанию (afterInteractive) скрипт не мешает первому рендеру вообще — он загружается параллельно и выполняется, когда основной поток уже освободился после гидратации.
Трассировка файлов
В седьмой части, разбирая шаги next build, мы упоминали трассировку файлов. Её задача — статически проанализировать, какие файлы действительно нужны каждому серверному входу: не только наш код, но и транзитивные зависимости из node_modules. Результат — список минимально необходимых файлов, и именно из него режим output: 'standalone' собирает компактную директорию для деплоя.
С точки зрения оптимизации он не ускоряет ответ пользователю, но зато сокращает размер образа, а вместе с ним и время сборки контейнера, время выкладки и холодный старт на serverless-платформах.
Итого
Next.js старается сдвинуть как можно больше работы на этап сборки и максимально сузить то, что остаётся на рантайм. Статика и ISR сдвигают в билд сам рендеринг, PPR — ту часть рендеринга, которая не зависит от запроса. next/font сдвигает в билд загрузку и хостинг шрифтов, а SWC-трансформы и next/dynamic принимают решение о том, что попадёт в первый чанк. next/image — исключение: он не может сдвинуть в билд трансформацию для произвольных внешних изображений, поэтому вместо этого кеширует результат так, чтобы трансформация произошла только один раз для каждой комбинации URL, ширины и качества.
