lenec ru

← все посты

Next.js App Router vs Pages Router: миграция

19K

На прошлой неделе закончил миграцию большого проекта с Pages Router на App Router. Это уже третья такая операция за карьеру, и каждый раз нахожу что-то новое — модель Next.js за это время сильно изменилась, и в 2026 году у миграции свои особенности. Здесь — подробный разбор того, что я делал, в каком порядке и где спотыкался.

Почему вообще мигрировать

Pages Router всё ещё поддерживается, но новые фичи появляются только в App Router: серверные компоненты, server actions, partial prerendering, параллельные роуты, intercepting routes. Если ты собираешься в продукте использовать что-то из этого списка — миграция нужна. Если у тебя простой сайт-визитка, не критично.

Из мотиваций последнего проекта: нужно было уменьшить клиентский бандл, прикрутить нормальный Suspense streaming и убрать кастомную фабрику data fetching, которая отъедала кучу времени на ревью. Все три задачи лежат в App Router в коробке.

Стратегия: постепенно, не разом

Главное правило, которое я усвоил: не пытайся выкатить новую версию одним коммитом. Next допускает одновременную работу обеих систем. Я делал так:

  1. Создал в проекте директорию app/ рядом со старым pages/.
  2. Перенёс верхний layout — app/layout.tsx.
  3. Дальше переезжал по разделам: сначала самые простые страницы (about, contacts), потом блог, в конце — приложение с авторизацией.
  4. На каждой ветке мигрировал один-два маршрута, ревьюил, мерджил, шёл дальше.

Маршруты, которые остались в pages/, продолжали работать; те, которые в app/, обслуживались новой системой.

Layout: первая обязательная задача

App Router требует app/layout.tsx на корне. В нём ты задаёшь <html> и <body> — то, что раньше жило в _document.tsx:

// app/layout.tsx
export const metadata = {
  title: { default: 'Сайт', template: '%s — Сайт' },
  description: 'Описание',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ru">
      <body>{children}</body>
    </html>
  );
}

Старые _app.tsx и _document.tsx остаются для того, что ещё в pages/, но любой маршрут под app/ идёт через новый layout.

Замена getServerSideProps

В Pages Router данные тянулись через getServerSideProps и попадали в компонент через пропсы. В App Router серверный компонент сам асинхронный:

// pages/posts/[slug].tsx
export async function getServerSideProps(ctx) {
  const post = await db.post.findUnique({ where: { slug: ctx.params.slug } });
  if (!post) return { notFound: true };
  return { props: { post } };
}

// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function Page({ params }: { params: { slug: string } }) {
  const post = await db.post.findUnique({ where: { slug: params.slug } });
  if (!post) notFound();
  return <Article post={post} />;
}

Большинство случаев — переписывание один-в-один. Сложнее с теми страницами, где getServerSideProps возвращал куки или редиректы. В App Router для этого есть cookies() и redirect() из next/navigation.

Замена getStaticProps и getStaticPaths

Эти исчезли. В App Router статика — это серверный компонент, который вызывает свои данные синхронно или с одним await; generateStaticParams — замена getStaticPaths:

export async function generateStaticParams() {
  const posts = await db.post.findMany({ select: { slug: true } });
  return posts.map((p) => ({ slug: p.slug }));
}

export default async function Page({ params }: { params: { slug: string } }) {
  const post = await db.post.findUnique({ where: { slug: params.slug } });
  if (!post) notFound();
  return <Article post={post} />;
}

Кеширование на уровне fetch стало по умолчанию no-store с Next 15 — раньше было force-cache. Если хочешь повторить старое поведение, явно указывай { next: { revalidate: 3600 } } в опциях fetch или используй встроенные cache и unstable_cache.

API-роуты

Старое pages/api/users.ts переезжает в app/api/users/route.ts:

// pages/api/users.ts
export default async function handler(req, res) {
  if (req.method === 'GET') return res.json({ ok: true });
  res.status(405).end();
}

// app/api/users/route.ts
export async function GET() {
  return Response.json({ ok: true });
}

export async function POST(request: Request) {
  const body = await request.json();
  return Response.json({ ok: true, body });
}

В App Router сигнатуры — стандартный Web API Request и Response. Привычные req.body и res.status(...).json(...) уходят. Для сложных хендлеров пишутся обёртки.

Layouts и вложенность

Layouts — самая мощная фича App Router и одновременно самый частый источник недоразумений. Каждый сегмент маршрута может иметь свой layout.tsx, который оборачивает всё внутри. Layout не перерендеривается при навигации между страницами того же сегмента, что снимает мерцание шапок и навигаций.

// app/(blog)/layout.tsx — общий для всего блога
export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="blog">
      <Sidebar />
      <main>{children}</main>
    </div>
  );
}

В Pages Router для этого приходилось писать кастомный _app.tsx с условиями по router.pathname. С layout — задача делится автоматически.

Loading и error

Файлы loading.tsx и error.tsx — встроенный Suspense fallback и Error boundary. На уровне сегмента маршрута. Пишешь раз — работает везде внутри.

// app/(blog)/loading.tsx
export default function Loading() {
  return <div className="skeleton">Загрузка...</div>;
}

// app/(blog)/error.tsx
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <p>Ошибка: {error.message}</p>
      <button onClick={reset}>Повторить</button>
    </div>
  );
}

Клиент vs сервер

Это самая частая причина граблей. По умолчанию все компоненты в App Router — серверные. Если нужны хуки, события, состояния — добавь 'use client' в начало файла. Не каждый компонент должен быть клиентским; не каждый клиентский должен быть на самом верхнем уровне.

Я придерживаюсь правила: клиентские компоненты — листья дерева. Сервер передаёт данные, клиент рисует и обрабатывает события. Это даёт минимальный клиентский бандл и предсказуемое поведение.

Где я споткнулся

  • Контексты React. На сервере их нет. Если у тебя контекст темы — оборачивай его в 'use client'-компонент и поднимай как можно выше.
  • Сторонние библиотеки. Часть из них использует useEffect или document на верхнем уровне модуля. Импортируй их только в клиентские компоненты, иначе билд падает.
  • Кеши fetch. Старая привычка fetch(url, { cache: 'no-store' }) теперь не нужна — это дефолт. А вот force-cache или revalidate явно проставляй там, где раньше работал ISR.
  • API-роуты с res.redirect. В App Router это NextResponse.redirect.

Что я бы делал иначе

На первом проекте я попытался сразу мигрировать сложный раздел с авторизацией. Получилось много времени, кучу странных багов с куками и почти неделя простоя. На втором — начал с публичных страниц, и темп вырос вдвое.

Мой рекомендуемый порядок: верстка-страницы → блог → продуктовые экраны без авторизации → авторизация и приватные зоны. На каждом шаге — чистый коммит, который можно откатить.

Что копать дальше

Когда базовая миграция закончена, не спеши закрывать тикет. Пройди ещё раз по статистике сборки, посмотри размеры чанков, оцени, где можно было бы заменить клиентские компоненты серверными. Часто после быстрой миграции 30% компонентов остаются клиентскими «по привычке», хотя могли бы остаться серверными. Это вторая итерация, и она даёт ещё больший выигрыш по бандлу.

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

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

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