Skip to content

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

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

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

Senior Frontend Architect, 10+ лет опыта построения production-проектов на Next.js. Contentful Certified Professional (2024). Специализация: React Server Components, headless eCommerce, инженерия Core Web Vitals.

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 с дедупликацией и ревалидацией; и инкрементальный протокол миграции с безопасным сосуществованием обоих роутеров в продакшне.

Что 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 в сегменте - более гранулярно, но требует осознанных архитектурных решений на каждой границе данных.

Как меняется структура файлов при миграции с /pages на /app?

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.

Как App Router на самом деле дедуплицирует fetch и обрабатывает ревалидацию?

Один из наименее понятых аспектов миграции - как работает 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.

Как директива 'use client' работает в модульном графе webpack?

Директива '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 бесшовно, несмотря на то что сервер никогда не выполняет код клиентского компонента.

Какой самый безопасный путь инкрементальной миграции с Pages Router на App Router?

Сосуществование обоих роутеров в одном 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 и верифицировать отсутствие предупреждений об осиротевших маршрутах.

Какие подводные камни App Router чаще всего ломают прод, и как их избежать?

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

  • Ошибка 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.

Какого прироста производительности ожидать от миграции на App Router?

Вот что показывают реальные цифры по продакшн-миграциям с правильным размещением границ '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.

Когда НЕ стоит мигрировать на App Router?

  • Мигрируйте, когда: (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 уже через несколько недель после деплоя. А если миграция перед вами - это слой контента, а не роутер, тот же инкрементальный, аудируемый подход переносится на миграцию с WordPress на Next.js-CMS вроде Payload.

Для архитектурного ревью, скоупинга миграции или реализации 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
Читать статью