Миграция Next.js с pages router на app router: пошагово, не за день
Я переносила два больших Next.js-приложения с pages router на app router. Первое — за два с половиной месяца, второе — за полтора. В обоих случаях это была не «героическая суббота с кофе», а методичная работа по плану, где каждый шаг был обратимым. Расскажу, как я её планировала, и какие грабли встретила.
Если ты сейчас сидишь и думаешь «надо мигрировать, но боюсь», — этот текст для тебя. Только без обещаний «всё пройдёт гладко». Не пройдёт. Но пройдёт.
Главное: pages и app живут одновременно
Next.js поддерживает оба роутера в одном приложении. Это первая хорошая новость. Ты не обязан переписывать всё за один спринт — можешь переносить страницу за страницей, и приложение работает.
Структура такая:
app/
layout.tsx
page.tsx # это новая главная
pages/
about.tsx # это старая «О нас»
api/
legacy-route.ts # API-роут на старом синтаксисе
Если у тебя есть и app/page.tsx, и pages/index.tsx, app перебивает pages — Next будет ругаться. Это, по сути, единственное жёсткое требование на период перехода: на одном маршруте только один файл.
Чем app router принципиально отличается
Прежде чем планировать миграцию, держи в голове, что меняется:
- Файловая структура. Каждая папка — это маршрут,
page.tsx— её контент,layout.tsx— обёртка. - Серверные компоненты по умолчанию. Файл считается серверным, пока ты не написал
"use client". - Загрузка данных. Нет
getServerSideProps,getStaticProps,getInitialProps. Вместо них —async-серверные компоненты сfetch, и встроенный кеш Next. - Layout-вложенность. Layouts можно вкладывать. Шапка приложения — один layout, внутри секция — свой layout, и они не перерендериваются на каждой навигации внутри своей зоны.
- Метаданные через export. Вместо
<Head>— экспорт объектаmetadataиз страницы или layout-а. - Параллельные и перехватывающие маршруты. Это для сложных сценариев, на старте без них.
План на двух больших проектах
Я делала миграцию по одному и тому же плану из шести этапов. Этапы можно растягивать, главное — порядок.
Этап 1: подготовка инфраструктуры
Перед первой переносимой страницей я делала:
- Обновляла Next до версии, которая стабильно поддерживает app router и совместима с моим React.
- Настраивала ESLint и TypeScript так, чтобы новые файлы в
app/сразу падали под строгие правила. - Создавала
app/layout.tsxиapp/loading.tsxв качестве каркаса.
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: { default: "Сайт", template: "%s — Сайт" },
description: "Описание",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>{children}</body>
</html>
);
}
На этом этапе ничего не сломалось — pages-приложение продолжает работать как было.
Этап 2: переезд глобальных провайдеров
Если у тебя в pages/_app.tsx сидят провайдеры (тема, локализация, query client, auth-провайдер) — выноси их в клиентский компонент и подключай в app/layout.tsx:
// app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(() => new QueryClient());
return (
<QueryClientProvider client={client}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Потом тот же Providers тоже подключаешь в pages/_app.tsx, чтобы и старые страницы продолжали жить со всеми контекстами.
Этап 3: переезд простых страниц
Я начинала с самых простых: контент-страниц без авторизации, без сложных стейтов. «О нас», «контакты», «ЧаВо».
Алгоритм для одной страницы:
- Создаю
app/<маршрут>/page.tsx, копирую содержимое. - Удаляю всё, что относилось к pages-апи (
getStaticPropsзаменяется наasync-функцию). - Если страница чисто статичная — оставляю серверной, JS не нужен.
- Если есть интерактив — выношу его в отдельный клиентский компонент.
- Удаляю старый файл из
pages/. - Проверяю в dev-режиме и в production-сборке.
Этап 4: переезд страниц с данными
Дальше переношу страницы, которые использовали getServerSideProps или getStaticProps. Тут логика сводится к замене на async-компонент:
// pages/products/[slug].tsx (старое)
export async function getServerSideProps({ params }) {
const product = await api.getProduct(params.slug);
return { props: { product } };
}
export default function ProductPage({ product }) {
return <Product data={product} />;
}
// app/products/[slug]/page.tsx (новое)
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await api.getProduct(slug);
return <Product data={product} />;
}
Тут стоит разобраться с кешированием. В app router fetch кешируется по умолчанию. Если у тебя в API дёргается fetch и ты ожидаешь свежие данные на каждом запросе — указывай явно:
const res = await fetch(url, { cache: "no-store" });
// или
const res = await fetch(url, { next: { revalidate: 60 } });
Я по умолчанию явно прописываю стратегию кеша, чтобы не догадываться.
Этап 5: переезд страниц с авторизацией и формами
Самые сложные. Тут нужно решить две вещи: как читать сессию на сервере и как делать формы (Server Actions или REST + клиент).
Чтение сессии — через cookies() в серверном компоненте. Формы — по обстоятельствам, я писала про это отдельно.
Этап 6: API-роуты и завершение
В app router API-роуты живут как app/api/<путь>/route.ts с экспортом GET, POST и т. д. Я обычно их переношу в самом конце, потому что они продолжают работать в pages/api/ без проблем.
// app/api/posts/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const posts = await db.posts.findMany();
return NextResponse.json(posts);
}
Грабли, которые встретила
1. Хуки next/router больше не работают
useRouter из next/router в app router не работает. Нужно useRouter из next/navigation, и API у него другое: нет query, нет asPath. Вместо query — useSearchParams и useParams.
2. Контекст-провайдеры из pages не достаются в app
Если у тебя кусок UI был в pages/_app.tsx и вылетел в общий компонент, его нужно использовать в обоих местах. Я обычно делала отдельный Providers, который подключаю и там, и там.
3. CSS-in-JS требует отдельной настройки
styled-components, emotion в app router работают через специальный реестр. Если не настроить — стили теряются при первом рендере, и пользователь видит «вспышку» нестилизованного UI. Это решается, но требует пары часов на настройку реестра по гайду из их документации.
4. Страницы 404 и ошибок
В app router not-found.tsx и error.tsx — отдельные файлы с особым API. error.tsx обязан быть клиентским и принимать пропсы error и reset. Я обычно делаю один общий error в корне и переопределяю в подмаршрутах, где нужно.
5. Динамические пути с catch-all
Старый [...slug] и [[...slug]] работают и в app router, но их поведение немного отличается. Я нашла пару мест, где старые URL переставали матчиться. Решение — подробные тесты на каждый формат URL до и после.
Что я делала, чтобы не сломать прод
- Каждая перенесённая страница уезжала на staging и проходила smoke-тесты.
- Всю миграцию делала в feature-ветке, отдельные страницы вливала маленькими PR-ами.
- На критических страницах (главная, корзина, оплата) — A/B-выкат через middleware: 5% трафика на новую версию, мониторим LCP и ошибки, расширяем.
- Тщательно сравнивала Web Vitals до и после.
Сроки и оценка
Я заметила, что время на миграцию хорошо предсказывается по типу страниц:
- Простая статика: 30 минут на страницу.
- Страница с одним
getServerSideProps: 1–2 часа. - Страница с авторизацией и формой: 0.5–1 день.
- Сложный дашборд с десятком сторонних либ: 2–5 дней.
На проекте с 60 страницами это вышло в среднем 250 человеко-часов. Не поломалось ничего критичного, потому что в каждой точке был быстрый откат — старая страница ещё лежала в pages/ и достаточно было удалить новый app/-файл.
Что запомнить
App router и pages router спокойно живут вместе на одном проекте. Это значит, что миграция — не «всё или ничего», а длинная последовательность маленьких шагов. Делай по одной странице, начиная с простых. Тестируй каждый шаг отдельно, особенно поведение URL и кеширование fetch. Не пытайся перенести API-роуты в первую очередь — они могут жить в старом месте сколько угодно.
И не геройствуй. Если за неделю переехала одна страница — это нормально, особенно если она важная. Лучше медленно и без откатов, чем быстро и с ночными хотфиксами.