Next.js изнутри. Часть 2. Как работает Pages Router.
Anastasia KotovaВведение
В первой части мы посмотрели на архитектуру 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].j
"/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
Cерверный рендеринг
Пользователь вводит 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 и рендерит страницу на клиенте. Каждый компонент попадает в клиентский бандл, каждый компонент гидрируется. Это ограничивает производительность и увеличивает размер бандлов, но даёт предсказуемость — всегда понятно, какой код где выполняется и как данные попадают в компонент.