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.