Миграция pages/ в app/: сосуществование, карта переноса и таймлайн в кварталах, а не спринтах
Оба роутера живут бок о бок, поэтому миграция идёт помаршрутно: getServerSideProps превращается в await fetch внутри RSC, _app — в root layout, а у router.events нет замены routeChangeStart. Сначала листовые страницы; приложение на 100 страниц — это кварталы, а не спринты.
SaaS на 117 страниц на pages-роутере объявляет big-bang переписывание: ветка app-router-v2, шесть недель, вся команда. Месяц четвёртый: ветка отстаёт от main на 400 коммитов, глобальный NProgress мёртв, потому что router.events в app-роутере не существует, styled-components мигает нестилизованной разметкой, пока кто-то не находит реестр серверной вставки стилей, а QA обнаруживает тихо протухающие сессии чекаута — скользящее продление жило в getServerSideProps, и порт вызывает cookies().set внутри Server Component, что бросает исключение. Месяц пятый: ветку хоронят. Цена: квартал роадмапа, ноль доставленной ценности. Вторая попытка переворачивает всё — pages/ и app/ живут бок о бок в одной сборке, маршруты переезжают по одному, начиная с листьев, каждый PR уезжает в продакшен за границей маршрута. Устоявшийся темп — около двух маршрутов на инженеро-неделю; полная миграция завершается за три квартала без единой заморозки релизов. Та же кодовая база, та же команда. Разница — отказ воевать с тем, как фреймворк на самом деле спроектировал миграцию.
Сосуществование — и есть дизайн миграции
Оба роутера компилируются в одну сборку, и честность держится на одном правиле: URL принадлежит ровно одному из них. Если существуют и pages/pricing.tsx, и app/pricing/page.tsx, сборка падает с ошибкой конфликта — эта ошибка и есть страховка, машинная гарантия, что каждый маршрут живёт ровно в одном мире и полумигрированный маршрут не может тихо заслонить свою старую версию. Навигация через границу работает через Link в обе стороны, но это полная загрузка документа, а не клиентский переход — два роутера не делят клиентский рантайм. У этого есть цена, которую стоит назвать прямо: на время миграции пути, пересекающие границу, платят двойной накладной расход фреймворка (в путешествие едут оба рантайма), и постраничное состояние пересечение не переживает. Middleware, напротив, действует на оба роутера одинаково — это единственный слой, который мигрирует бесплатно.
Карта переноса
Большая часть механической работы — таблица, а большинство инцидентов прячутся в её углах. getServerSideProps становится асинхронным Server Component, который ждёт свои данные по месту, — но заметьте ловушку: gSSP был неявно per-request, а в app-роутере умолчание — статика. Наивный порт, просто зовущий fetch без cache: 'no-store', опции revalidate или обращения к динамическому API, даёт страницу, отрендеренную один раз и замороженную — см. квиз о провале ниже. getStaticProps плюс revalidate превращается в fetch с next: { revalidate: 60 } (или в revalidate-экспорт уровня сегмента); getStaticPaths — в generateStaticParams. _app и _document схлопываются в root layout, провайдеры становятся клиентским компонентом, оборачивающим children; next/head — экспортом metadata или generateMetadata. API-роуты переезжают из pages/api в route handlers в app/**/route.ts.
Теперь угол, для которого нет строки в таблице: router.events исчез и эквивалента не имеет. App-роутер даёт usePathname и useSearchParams — реактивные значения, обновляющиеся после того, как навигация закоммитилась. routeChangeStart не существует. Каждый глобальный прогресс-бар, подвешенный на него — NProgress в _app стоит в половине продакшен-приложений на свете, — молча перестаёт работать: ни ошибки, ни бара. Реалистичные варианты: обернуть Link (и программные push) для эмиссии собственного сигнала старта, взять библиотеку из сообщества, которая делает этот патчинг, или принять родную модель фреймворка — посегментный loading.tsx, что честно лучше как UX, но действует на маршрут, а не глобально.
После переезда оболочки на app-роутер глобальный NProgress, подвешенный на router.events routeChangeStart, больше не появляется — и нигде ни одной ошибки. Почему, и какова реалистичная замена?
Ментальный сдвиг в получении данных
Если портировать паттерн фетчинга дословно, но упустить сдвиг в модели мышления, компоненты будут сложнее в композиции, чем должны — и почему, будет непонятно ещё долго.
Карта переноса механистична; эта часть концептуальна, и команды, пропустившие её, годами пишут app-роутерный код с акцентом pages-роутера. В pages/ данные брались на страницу: одна благословлённая функция наверху и пирамида пропсов, продавливающая всё вниз. В app/ фетчинг покомпонентный: любой Server Component ждёт ровно те данные, которые рендерит. Доступным это делает мемоизация запросов — внутри одного прохода рендера одинаковые вызовы fetch (тот же URL, те же опции) дедуплицируются автоматически, так что layout, страница и глубокая карточка могут каждый сделать await getUser() и породить один сетевой вызов. cache() из React распространяет ту же гарантию на не-fetch функции вроде прямых запросов к базе. Следствия: типы page-props и продавливание исчезают, компоненты становятся самодостаточными в данных, а новая дисциплина — охота на серверные водопады: последовательные await, которым положено быть параллельными. Где два независимых фетча живут в одном компоненте — стартуйте их вместе через Promise.all; где они в родителе и ребёнке — обычно нормально: мемоизация плюс стриминг закрывают это.
Auth переезжает; мутация cookie — нет
Middleware переезжает без изменений — тот же файл, тот же matcher, оба роутера. Ловушка — путь записи cookie. В getServerSideProps можно было продлевать сессионную cookie на каждом запросе, выставляя заголовки ответа. В app-роутере cookies() читается везде на сервере, но мутация разрешена только в Server Actions и Route Handlers — рендер RSC (React Server Component, серверный компонент React) не может ставить cookie: к моменту рендера компонента ответ может уже стримиться, а результат рендера может быть закеширован и проигран другим пользователям. Паттерн скользящего продления из пролога обязан переехать в middleware, который выполняется на каждый запрос до рендера и может ставить cookie на пересылаемый ответ. Это не смена синтаксиса, а переселение ответственности — и самая частая auth-регрессия в реальных миграциях.
Скользящее продление сессии, переставлявшее cookie на каждом запросе, жило в getServerSideProps. Порт вызывает cookies().set внутри Server Component страницы и падает в рантайме. Где продлению жить теперь?
CSS-in-JS платит налог на стили
Рантаймовый CSS-in-JS — styled-components, Emotion — генерирует стили во время рендера, что сталкивается со стриминговым серверным рендерингом. Поддерживаемый обход — реестр стилей: клиентский компонент с useServerInsertedHTML, сбрасывающий собранные стили в поток впереди той разметки, которой они нужны. Работает, но читайте мелкий шрифт: эти библиотеки живут только в клиентских компонентах, так что стилизованный лист никогда не станет Server Component — библиотека стилей теперь диктует ваш раздел сервер/клиент. Команды выбирают между реестром как признанным строительным долгом, миграцией горячих путей на CSS Modules или Tailwind до переезда и zero-runtime библиотекой (vanilla-extract и родня), компилирующей проблему прочь. Решить это до переезда первого маршрута — разница между стратегией стилей и археологией стилей.
Последовательность и честный таймлайн
Порядок — это стратегия. Сначала листовые страницы — настройки, юридические, контентные, со слабой связностью layout: на них команда дёшево набирает словарь паттернов (реестр, metadata, опции fetch). Кластеры с общим layout переезжают вместе: пока кластер не мигрировал целиком, его оболочка существует дважды — в _app и в layout, — и каждое изменение оболочки — двойное сопровождение, так что полупереехавший кластер — худшее устойчивое состояние. Рано разрешите URL, за который дерутся оба роутера, — обычно опциональный catch-all в pages/, перекрывающийся со статическим маршрутом в app/, — используя ошибку конфликта сборки как арбитра. Критичные для выручки пути (чекаут, auth-потоки) идут последними, когда знание паттернов на пике. Честный таймлайн для ~100 страниц: первый маршрут занимает две-три недели — он везёт root layout, реестр стилей, переезд auth и кривую обучения команды. Устоявшийся темп — один-два маршрута на инженеро-неделю при параллельной продуктовой работе. Хвост снова медленный. Итого: два-три квартала — а честным процесс держит одна метрика burn-down: число маршрутов, оставшихся в pages/, еженедельно, пока оно не станет нулём и pages/ не удалят.
- 01Пройдитесь по карте переноса, включая два пункта, которые суть ловушки, а не переименования.
- 02Сформулируйте стратегию последовательности и честный таймлайн для приложения на ~100 страниц, включая то, почему cookie и CSS-in-JS решаются рано.
Провалившаяся big-bang ветка и успешная миграция за три квартала — одна и та же кодовая база; разница в использовании реального дизайна фреймворка: pages/ и app/ компилируются в одну сборку, URL принадлежит ровно одному роутеру под арбитражем ошибки конфликта, навигация через границу — полная загрузка документа, а middleware покрывает оба роутера бесплатно. Карта переноса делает механическую часть — getServerSideProps становится await fetch в асинхронном Server Component с ловушкой статики по умолчанию для наивных портов, getStaticProps отображается в опции revalidate, getStaticPaths в generateStaticParams, _app и _document в root layout, next/head в metadata — и прячет две настоящие поломки: router.events исчез без эквивалента, молча убивая каждый прогресс-бар на routeChangeStart, а мутация cookie запрещена в рендере RSC, выгоняя скользящее продление сессии из получения данных в middleware или Server Actions. Концептуальный сдвиг — покомпонентный фетчинг: мемоизация запросов дедуплицирует одинаковые fetch в пределах прохода рендера, cache() покрывает не-fetch функции, пирамиды пропсов растворяются — новая дисциплина это охота на серверные водопады с Promise.all. Рантаймовый CSS-in-JS платит налог: реестр на useServerInsertedHTML плюс структурное ограничение, что стилизованные компоненты остаются клиентскими, — выбирайте реестр, предмиграцию на CSS Modules или zero-runtime до первого маршрута. Последовательность и есть стратегия: листья первыми ради дешёвого обучения, кластеры с общим layout атомарно во избежание двойных оболочек, спорные URL рано, чекаут последним. Честные цифры: первый маршрут две-три недели, устоявшийся темп один-два маршрута на инженеро-неделю, два-три квартала на ~100 страниц — под одной метрикой burn-down, маршруты в pages/, пока она не обнулится и pages/ не удалят. Теперь, когда команда предложит big-bang переписывание за шесть недель, вы знаете точную цену: квартал роадмапа, ветка на 400 коммитов позади main, и миграция, которую сам фреймворк спроектировал инкрементальной.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.