lenec ru

← все посты

Миграция Next.js с pages router на app router: пошагово, не за день

14K

Я переносила два больших 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: подготовка инфраструктуры

Перед первой переносимой страницей я делала:

  1. Обновляла Next до версии, которая стабильно поддерживает app router и совместима с моим React.
  2. Настраивала ESLint и TypeScript так, чтобы новые файлы в app/ сразу падали под строгие правила.
  3. Создавала 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: переезд простых страниц

Я начинала с самых простых: контент-страниц без авторизации, без сложных стейтов. «О нас», «контакты», «ЧаВо».

Алгоритм для одной страницы:

  1. Создаю app/<маршрут>/page.tsx, копирую содержимое.
  2. Удаляю всё, что относилось к pages-апи (getStaticProps заменяется на async-функцию).
  3. Если страница чисто статичная — оставляю серверной, JS не нужен.
  4. Если есть интерактив — выношу его в отдельный клиентский компонент.
  5. Удаляю старый файл из pages/.
  6. Проверяю в 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. Вместо queryuseSearchParams и 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 до и после.

Что я делала, чтобы не сломать прод

  1. Каждая перенесённая страница уезжала на staging и проходила smoke-тесты.
  2. Всю миграцию делала в feature-ветке, отдельные страницы вливала маленькими PR-ами.
  3. На критических страницах (главная, корзина, оплата) — A/B-выкат через middleware: 5% трафика на новую версию, мониторим LCP и ошибки, расширяем.
  4. Тщательно сравнивала Web Vitals до и после.

Сроки и оценка

Я заметила, что время на миграцию хорошо предсказывается по типу страниц:

  • Простая статика: 30 минут на страницу.
  • Страница с одним getServerSideProps: 1–2 часа.
  • Страница с авторизацией и формой: 0.5–1 день.
  • Сложный дашборд с десятком сторонних либ: 2–5 дней.

На проекте с 60 страницами это вышло в среднем 250 человеко-часов. Не поломалось ничего критичного, потому что в каждой точке был быстрый откат — старая страница ещё лежала в pages/ и достаточно было удалить новый app/-файл.

Что запомнить

App router и pages router спокойно живут вместе на одном проекте. Это значит, что миграция — не «всё или ничего», а длинная последовательность маленьких шагов. Делай по одной странице, начиная с простых. Тестируй каждый шаг отдельно, особенно поведение URL и кеширование fetch. Не пытайся перенести API-роуты в первую очередь — они могут жить в старом месте сколько угодно.

И не геройствуй. Если за неделю переехала одна страница — это нормально, особенно если она важная. Лучше медленно и без откатов, чем быстро и с ночными хотфиксами.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.