Skip to content

Миграция на Next.js App Router с Pages Router: полный практический гайд (2026)

App Router - не переработанный Pages Router. Это другая парадигма маршрутизации, построенная на другой модели компонентов, другом контракте загрузки данных и другой стратегии рендеринга. Воспринимать миграцию как реструктуризацию файлов - главный источник продакшн-инцидентов при переходе.

Архитектурная диаграмма миграции Next.js - Pages Router vs App Router
Опубликовано:Обновлено:Время чтения:16 мин

Next.js App Router, стабильный с Next.js 13.4 (май 2023) и полностью зрелый в Next.js 15 (октябрь 2024), - не инкрементальное улучшение Pages Router, а параллельная реализация роутинга на трёх фундаментальных изменениях: React Server Components, которые переводят модель компонентов от клиентского дерева к серверному с явными client opt-in границами; конкурентный рендерер React и Suspense streaming, заменяющие синхронное внедрение пропсов на асинхронную доставку HTML-чанков; и файловая конвенция, поднимающая лейауты, состояния загрузки и error boundaries с уровня приложения на уровень фреймворка. В этой статье я разбираю миграцию через четыре измерения: стоимостная модель гидратации, которую RSC принципиально меняет; механика модульного графа webpack с правилами размещения границ 'use client'; внутреннее устройство Data Cache с дедупликацией и ревалидацией; и инкрементальный протокол миграции с безопасным сосуществованием обоих роутеров в продакшне.

Часть I - Стоимостная модель гидратации: что не так в Pages Router

Чтобы понять, почему App Router представляет подлинный архитектурный прогресс, нужно сначала разобраться в стоимости гидратации Pages Router и почему эта стоимость структурная, а не случайная. При рендеринге страницы в Pages Router через renderToString или renderToPipeableStream создаются два артефакта: (1) HTML-строка, доставляемая браузеру, и (2) JSON-блоб в __NEXT_DATA__, содержащий сериализованное React virtual DOM дерево всей страницы - включая все пропсы, данные и состояния компонентов. Браузер получает оба артефакта и начинает гидратацию.

Гидратация в Pages Router - это операция O(n) по всему дереву компонентов. React обходит каждый компонент - независимо от наличия интерактивности - для присоединения event listeners и верификации совпадения client-вычисленного VDOM с серверным HTML. Для страницы продукта с 50 компонентами, где только 3 (<AddToCart>, <QuantitySelector>, <WishlistButton>) имеют event handlers, React всё равно обходит все 50. JavaScript для всех 50 компонентов должен быть разобран, скомпилирован и выполнен в главном потоке до начала интерактивности страницы. Это фундаментальный налог гидратации: стоимость пропорциональна размеру дерева компонентов, а не интерактивной поверхности.

App Router меняет это через модель компонентов RSC. Server Components не отправляются в браузер как JavaScript - они рендерятся на сервере в HTML и вплетаются в начальный ответ. JavaScript-движок браузера никогда не видит эти компоненты - нет стоимости разбора, компиляции или гидратации. Только 'use client' компоненты требуют гидратации. Страница продукта с 50 компонентами и 3 интерактивными доставляет JavaScript только для этих 3 - стоимость гидратации O(k), где k - интерактивная поверхность, а не O(n). Для типичных eCommerce-страниц продуктов это снижение гидратационной работы на 65–80% в единицах процессорного времени главного потока.

  • Модель компонентов. Pages Router: все компоненты клиентские по умолчанию - имеют доступ к browser API, могут использовать хуки, гидратируются в браузере. App Router: компоненты серверные по умолчанию, выполняются на сервере, имеют прямой доступ к БД и файловой системе, вносят нуль байт в клиентский бандл. Интерактивность - opt-in через директиву 'use client'.
  • Контракт загрузки данных. Pages Router: загрузка данных вынесена в lifecycle-функции (getServerSideProps, getStaticProps, getInitialProps) - архитектурно отделена от компонента. App Router: Server Components могут быть async-функциями - const data = await db.query(...) валидно внутри Server Component. Lifecycle-функций нет; компонент является единицей загрузки данных.
  • Модель лейаутов. Pages Router: _app.tsx / _document.tsx - единый глобальный враппер. Вложенные лейауты - ручные компоненты-обёртки. App Router: вложенные лейауты нативно через файлы layout.tsx в иерархии директорий. Лейаут в app/dashboard/layout.tsx оборачивает все маршруты /dashboard и сохраняет React-состояние между навигациями внутри поддерева без ремаунта.
  • Гранулярность стратегии рендеринга. Pages Router: настройка per-page через экспортируемые функции. App Router: per-fetch через опции fetch() (cache, next.revalidate, next.tags) и per-route через export const dynamic в сегменте - более гранулярно, но требует осознанных архитектурных решений на каждой границе данных.

Часть II - Изменения файловой конвенции

App Router использует отдельную файловую конвенцию, сосуществующую с Pages Router, а не заменяющую его. Это сосуществование - архитектурная основа инкрементальной стратегии миграции.

  • Файлы страниц: pages/about.tsxapp/about/page.tsx. Конвенция page.tsx заменяет filename-equals-route.
  • Лейауты: pages/_app.tsxapp/layout.tsx (корневой лейаут, обязателен). Вложенные лейауты: app/dashboard/layout.tsx для всех маршрутов /dashboard/*.
  • Состояния загрузки: Новый app/[route]/loading.tsx - автоматически оборачивает страницу в Suspense boundary и показывает UI загрузки.
  • Error boundaries: Новый app/[route]/error.tsx - автоматически оборачивает сегмент в React Error Boundary.
  • API Routes: pages/api/hello.tsapp/api/hello/route.ts. Route Handlers используют Web API Request/Response вместо NextApiRequest/NextApiResponse.
  • Страница 404: pages/404.tsxapp/not-found.tsx. Кастомные атрибуты <html>/<body> из _document.tsx переносятся в app/layout.tsx.

Часть III - Data Cache: дедупликация запросов и ревалидация изнутри

Один из наименее понятых аспектов миграции - как работает Data Cache на уровне реализации. В Pages Router загрузка данных вынесена в lifecycle-функции именно чтобы предотвратить избыточные API-вызовы. В App Router загрузка данных размещается внутри компонентов - но без избыточных сетевых запросов. Механизм: Next.js monkey-patching глобальной функции fetch при старте сервера для перехвата каждого вызова fetch() во время серверного рендеринга. Поверх этого перехвата работают два слоя кэширования.

  • Request Memoization (дедупликация per-render): В рамках одного серверного прохода рендеринга любые два вызова fetch() с идентичными URL + method + body возвращают один и тот же in-memory ответ - второй вызов никогда не достигает сети. <ProductTitle>, <ProductImages> и <ProductSchema> могут независимо вызывать fetch('/api/product/123'), и будет сделан только один HTTP-запрос. Реализовано через Map<string, Promise<Response>> с ключом по fingerprint запроса, ограниченный деревом рендеринга React. Map очищается между запросами.
  • Data Cache (cross-render persistence): Ответы, помеченные next: { revalidate: N } или next: { tags: ['...'] }, хранятся на диске (или в настроенном бэкенде кэша) между проходами рендеринга. Страница продукта с revalidate: 3600 делает один реальный API-вызов в час независимо от объёма трафика. Инвалидация по тегам (revalidateTag('product-123')) удаляет конкретную запись кэша; следующий запрос регенерирует её. Архитектурно это ISR, но работающий с гранулярностью per-fetch, а не per-page.
  • `getServerSideProps` → async Server Component: Трансформация устраняет косвенность props-injection. Компонент страницы - async; данные загружаются через обычный await внутри тела компонента. Data Cache гарантирует отсутствие регрессии производительности.
  • `getStaticProps` + `getStaticPaths` → `generateStaticParams` + `cache: 'force-cache'`: generateStaticParams() выполняется при сборке и возвращает комбинации параметров для пре-рендеринга. Режим кэша fetch() по умолчанию - force-cache, что делает статический рендеринг opt-out, а не opt-in. ISR настраивается через export const revalidate = N на уровне сегмента маршрута.
  • API Routes → Route Handlers: export async function POST(request: Request) { const body = await request.json(); return Response.json({ ok: true }); }. Именованные экспорты по HTTP-методу заменяют method-conditional логику. Web API Request/Response заменяет NextApiRequest/NextApiResponse.

Часть IV - Модульный граф webpack и механика границ 'use client'

Директива 'use client' - наиболее значимое решение в App Router-приложении, и наиболее часто неправильно понимаемое. Её поведение определяется алгоритмом построения модульного графа webpack, а не только моделью компонентов React.

При обработке сборки Next.js App Router webpack строит два отдельных модульных графа: серверный граф (RSC-бандл, никогда не отправляемый в браузер) и клиентский граф (браузерный бандл). Директива 'use client' в начале файла - это декларация границы модульного графа: она говорит webpack «этот модуль и все модули, достижимые из него через import, принадлежат клиентскому графу». Критически: граница транзитивна. Если ProductPage.tsx помечен 'use client', то все его импорты (formatPrice.ts, useAnalytics.ts, fetchProduct.ts, ProductImage.tsx) рекурсивно попадают в клиентский бандл. Стоимость неправильно размещённого 'use client' - не размер помеченного компонента, а размер всего подграфа импортов с корнем в этом компоненте.

  • Правильная ментальная модель: Server Components - листья в дереве зависимостей модулей, которые могут импортировать Client Components, но Client Components не могут импортировать Server Components. Граница 'use client' маркирует корень клиентского поддерева. Размещайте её как можно глубже в иерархии компонентов - у наименьшего компонента, реально требующего browser API или event handlers.
  • Практическое влияние: Компонент <ProductPage>, импортирующий <PriceDisplay>, который импортирует утилиту formatCurrency.ts - если <ProductPage> является 'use client', все три попадают в браузер. Если только <AddToCartButton> является 'use client', а <ProductPage> импортирует его вместе с <PriceDisplay> и <ProductImages> (оба Server Components), только <AddToCartButton> и его импорты попадают в браузер. Разница в размере бандла - типично 50–150 KB gzip для реальной страницы продукта.
  • Client Reference Objects: Когда Server Component импортирует 'use client' компонент, webpack генерирует Client Reference Object - JSON-указатель на chunk ID в клиентском манифесте. Сервер рендерит эту ссылку в RSC wire format payload; браузер резолвит ссылку в реальный компонент из скачанного бандла. Именно поэтому 'use client' компоненты принимают пропсы от Server Components бесшовно, несмотря на то что сервер никогда не выполняет код клиентского компонента.

Часть V - Протокол инкрементальной миграции

Сосуществование обоих роутеров в одном Next.js-приложении - не временный хак, а полноценно поддерживаемая конфигурация. Next.js разрешает маршруты, проверяя app/ перед pages/; маршрут, определённый в обоих, использует реализацию из app/. Продакшн-приложение обслуживает маршруты Pages Router и App Router одновременно без штрафа за само сосуществование.

  • Фаза 1 - Корневой лейаут (нулевой риск): Создать app/layout.tsx, реплицирующий _app.tsx/_document.tsx: <html lang>, <body>, глобальные CSS-импорты, шрифты, аналитика. Директория pages/ не тронута. Верифицировать сборку. Нет видимого пользователю влияния.
  • Фаза 2 - Статические листовые маршруты: Мигрировать страницы только с getStaticProps - блог-посты, документация, лендинги. Трансформируются в async Server Components с generateStaticParams. Нет клиентской интерактивности для управления. Верифицировать LCP и CLS в CrUX после деплоя.
  • Фаза 3 - Извлечение утилит данных: Перед миграцией динамических маршрутов извлечь общую загрузку данных в типизированные server-only утилиты (lib/data.ts с import 'server-only'). Заменяет паттерн getServerSideProps boilerplate и делает server-only данные безопасными от случайного клиентского импорта.
  • Фаза 4 - Динамические страницы с интерактивностью: Наиболее сложная фаза. Паттерн: сегмент маршрута (app/product/[handle]/page.tsx) - Server Component, получающий данные и рендерящий статическое дерево. Интерактивные компоненты (<AddToCartButton>, <VariantSelector>) - 'use client' листья, импортируемые Server Component. Граница 'use client' - у листа, а не страницы.
  • Фаза 5 - API Routes → Route Handlers: pages/api/*.tsapp/api/*/route.ts. Именованные экспорты по методу. Мигрировать последними - наименьшая связность, наиболее механическое преобразование.
  • Фаза 6 - Очистка: Удалить директорию pages/. Убрать остатки _app.tsx, _document.tsx. Запустить next build и верифицировать отсутствие предупреждений об осиротевших маршрутах.

Часть VI - Таксономия ошибок: анализ на уровне механизмов

Постмортемы продакшн-миграций выявляют одни и те же ошибки. Разница между успешными и регрессирующими командами - понимание механизма за каждой ошибкой, а не только симптома.

  • Ошибка 1 - Эскалация границы `'use client'`. Пометка каждого компонента 'use client' для исправления ошибок хуков, прогрессивная эскалация до тех пор, пока страница сама не становится Client Component - устраняя все преимущества RSC. Решение: 'use client' - исключение, требующее явного обоснования. Только компоненты с хуками, browser API или event handlers требуют его.
  • Ошибка 2 - Context providers в Server Components. React Context - Client Component API. Оборачивание всего приложения в Context provider принудительно включает каждый дочерний компонент в клиентский бандл. Решение: оборачивать только интерактивное поддерево, требующее контекст, в 'use client' провайдер.
  • Ошибка 3 - `cookies()`/`headers()` в кэшируемых маршрутах. Чтение cookies() или headers() переводит весь маршрут в динамический рендеринг, отключая Full Route Cache. Решение: перемещать их в наименьшую необходимую область; unstable_noStore() только для конкретного fetch, требующего динамического поведения.
  • Ошибка 4 - Несериализуемые пропсы через Server/Client-границу. RSC wire format сериализует Server→Client пропсы как JSON. Функции, экземпляры классов и Date не могут пересечь эту границу. Решение: передавать только plain JSON-сериализуемые данные; инкапсулировать серверную логику в Server Actions ('use server').
  • Ошибка 5 - Отсутствие `generateStaticParams` для динамических маршрутов. Без него все сегменты динамических маршрутов дефолтно переходят в динамический рендеринг - каждый запрос попадает на сервер. Решение: реализовать generateStaticParams для всех известных динамических маршрутов для включения CDN-кэширования.
  • Ошибка 6 - Несовместимые third-party библиотеки. Библиотеки, использующие хуки или window на уровне модуля, падают в Server Component окружении. Решение: оборачивать в локальный 'use client' файл-обёртку, а не помечать потребляющую страницу как Client Component.

Часть VII - Измеренные результаты производительности

Вот что показывают реальные цифры по продакшн-миграциям с правильным размещением границ 'use client' и реализацией generateStaticParams.

  • Размер бандла: Наиболее стабильное улучшение. Типичная eCommerce-страница продукта, ранее доставлявшая 380–520 KB gzip, сокращается до 110–160 KB после RSC-миграции - компоненты отображения данных перемещаются на сервер. Сокращение пропорционально доле дерева, занятой чистым отображением данных против интерактивности.
  • LCP: Устранение client-side waterfall (useEffect → fetch → setState → render) убирает наиболее распространённую причину плохого LCP на динамических страницах. Server Components доставляют предзагруженный HTML в начальном ответе - браузер рендерит немедленно, а не ждёт выполнения JS.
  • INP: Меньший клиентский бандл означает меньше времени на разбор и компиляцию JavaScript в главном потоке. Сокращение бандла на 300 KB коррелирует с примерно 80–120ms снижением main thread blocking time на mid-tier мобайле, напрямую улучшая INP.
  • Time to Interactive: Улучшения TTI коррелируют с сокращением бандла. Меньше JavaScript для разбора означает более быстрый выход браузера в интерактивное состояние - типично на 400–800ms раньше на mid-tier мобайле при сокращении бандла на 300+ KB.

Фреймворк принятия решений: когда не мигрировать

  • Мигрируйте, когда: (1) Страницы преимущественно data-display с минимальной интерактивностью - RSC даёт наибольшее сокращение бандла/LCP. (2) Нужен Partial Prerendering (PPR) - только App Router. (3) Новые фичи требуют вложенных лейаутов, per-segment UI загрузки или streaming. (4) Server Actions упростят ваши паттерны мутаций.
  • Отложите миграцию, когда: (1) Приложение - сложная stateful SPA (визарды, real-time collaborative tools) - RSC минимально полезен для изначально клиентских UI. (2) Команда не знакома с RSC при ближайшем дедлайне. (3) Third-party зависимости сильно несовместимы с Server Components. (4) Текущее приложение уже проходит все пороги Core Web Vitals в CrUX - нет давления производительности, миграция вносит риск без пропорционального результата.

Заключение

App Router - это не просто улучшенный Pages Router. Он построен на возможностях, которых просто не было, когда Pages Router проектировался: нулевые серверные компоненты, нативные вложенные лейауты, Route Handlers на базе Web API. Всё это теперь часть фреймворка из коробки.

Инкрементальный путь безопасен - оба роутера сосуществуют в Next.js 15, стратегия маршрут-за-маршрутом убирает риск big-bang переписывания. Все описанные выше ошибки предотвратимы, если понимаешь механизм. Для data-heavy приложений выигрыш в производительности реальный и заметен в CrUX field data уже через несколько недель после деплоя.

Для архитектурного ревью, скоупинга миграции или реализации App Router - сервис архитектуры | кейсы | обсудить задачу.

FAQ

  • Могут ли Pages Router и App Router сосуществовать в одном Next.js-приложении? Да. Next.js 13+ поддерживает одновременно pages/ и app/. Маршруты в app/ имеют приоритет. Рекомендуется инкрементальный маршрут-за-маршрутом подход.
  • Поддерживает ли App Router `getInitialProps`? Нет. Если используется в _app.tsx для глобальных данных, необходимо отрефакторить к Server Component data fetching перед миграцией корневого лейаута.
  • Как управлять аутентификацией в App Router? Данные сессии доступны через cookies() в Server Components или валидируются в Middleware. Auth.js v5 (next-auth) имеет первоклассную поддержку App Router. Чтение сессии в Server Component переводит маршрут в динамический рендеринг - используйте Middleware для валидации сессии для сохранения статического рендеринга.
  • Какие регрессии бандла отслеживать? Наиболее распространённая регрессия - непреднамеренная эскалация 'use client', фактически превращающая RSC-страницы в CSR. Мониторьте размер бандла через анализатор next build и CrUX LCP/INP в процессе миграции.
  • Поддерживает ли App Router i18n? В App Router нет встроенного i18n-роутинга, эквивалентного next.config.js i18n из Pages Router. Рекомендуемое решение в 2026 году - next-intl с Middleware-определением локали и generateMetadata() для hreflang alternates.

Источники

Похожие статьи

React Server Components: как на самом деле работает архитектура с нулевым бандлом (2026)

Глубокое погружение в React Server Components - модель рендеринга, которая убирает клиентский JavaScript для серверных поддеревьев. Wire-формат RSC, семантика границ, async-загрузка данных, Suspense-стриминг, Partial Prerendering и React 19 Compiler - с реальными бенчмарками и практическим фреймворком принятия решений.

ReactNext.jsArchitecture
Читать статью

React 19: useOptimistic, use(), Server Actions - механизм и архитектура (2026)

Детальный разбор пяти новых примитивов React 19: useOptimistic (конкурентная composition оптимистичного состояния с автоматическим rollback), use() (условное чтение Promise и Context), Server Actions (RPC-over-HTTP контракт и Progressive Enhancement), useActionState (стейт-threading паттерн), useFormStatus. Action-based mutation архитектура, которая заменяет Redux/Zustand для серверных мутаций.

React 19ArchitectureNext.js
Читать статью

Partial Prerendering (PPR) в продакшне: архитектурные паттерны (2026)

Детальный разбор Next.js Partial Prerendering в продакшне. Механизм двухфазного ответа (статический shell с CDN + стриминговые dynamic holes), правила размещения Suspense-границ, взаимодействие с Full Route Cache, дизайн fallback для нулевого CLS, измеренные TTFB/LCP, сравнение с ISR+CSR и full SSR, известные ограничения и фреймворк принятия решений.

Next.jsPerformancePPR
Читать статью