Миграция на App Router в Next.js: пошаговый гайд

Как перевести проект с Pages Router на App Router без боли. Стратегия, типичные ошибки и совместимость.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 9 сентября 2026 г.📖 9 мин чтения

Миграция на App Router — не переименование папок. Меняется модель выполнения: компонент по умолчанию исполняется на сервере, и всё, что привыкло жить в браузере — контексты, обработчики, обращения к window, значительная часть библиотек — требует явного решения.

Хорошая новость в том, что pages/ и app/ работают одновременно, поэтому переезд можно вести постранично и растянуть на месяцы. Плохая — в том, что оценка «одна-две недели» верна только для проекта, который не использует ничего сложного.

💡 Совет

Проверяйте детали API по документации своей версии. App Router активно развивается, и конкретные сигнатуры (кэширование, fetch, работа с параметрами роутов) между мажорными версиями менялись. Приёмы и порядок миграции из статьи остаются верными, а вот код перед копированием стоит сверить с документацией установленной у вас версии.

Зачем переходить на App Router

Next.js 13+ представил App Router — новую архитектуру на основе React Server Components. Преимущества:

  • Server Components по умолчанию — меньше JavaScript на клиенте
  • Вложенные layouts — общие элементы без перерендера
  • Streaming — постепенная загрузка страницы
  • Параллельные routes — модалки и слайдпанели в URL
💡 Совет

Pages Router никуда не делся. Оба роутера работают параллельно, что позволяет мигрировать постепенно.

Стратегия миграции

Подход «страница за страницей»

Не переписывайте всё сразу. Мигрируйте по одной странице:

  1. Оставляете pages/ как есть
  2. Создаёте app/ рядом
  3. Переносите страницы по приоритету
  4. Удаляете pages/ когда всё перенесено
text
project/
├── app/           # Новые страницы
│   ├── layout.tsx
│   ├── page.tsx   # Главная (уже мигрирована)
│   └── blog/
│       └── page.tsx
├── pages/         # Старые страницы (ещё работают)
│   ├── about.tsx
│   └── contact.tsx

Основные изменения

1. Структура файлов

Pages Router:

text
pages/
├── index.tsx        → /
├── about.tsx        → /about
├── blog/
│   ├── index.tsx    → /blog
│   └── [slug].tsx   → /blog/[slug]
└── _app.tsx
└── _document.tsx

App Router:

text
app/
├── page.tsx         → /
├── layout.tsx       → Общий layout
├── about/
│   └── page.tsx     → /about
├── blog/
│   ├── page.tsx     → /blog
│   └── [slug]/
│       └── page.tsx → /blog/[slug]

2. Data Fetching

Pages Router (getServerSideProps):

tsx
// pages/blog/[slug].tsx
export async function getServerSideProps({ params }) {
  const post = await getPost(params.slug);
  return { props: { post } };
}

export default function BlogPost({ post }) {
  return <article>{post.title}</article>;
}

App Router (async Server Component):

tsx
// app/blog/[slug]/page.tsx
async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 3600 },
  });
  return res.json();
}

export default async function BlogPost({ 
  params 
}: { 
  params: Promise<{ slug: string }> 
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  
  return <article>{post.title}</article>;
}

3. Metadata

Pages Router:

tsx
// pages/about.tsx
import Head from 'next/head';

export default function About() {
  return (
    <>
      <Head>
        <title>О нас</title>
        <meta name="description" content="Описание страницы" />
      </Head>
      <main>...</main>
    </>
  );
}

App Router:

tsx
// app/about/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'О нас',
  description: 'Описание страницы',
};

export default function About() {
  return <main>...</main>;
}

Динамические метаданные:

tsx
// app/blog/[slug]/page.tsx
export async function generateMetadata({ 
  params 
}: { 
  params: Promise<{ slug: string }> 
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      images: [post.image],
    },
  };
}

4. Layouts

Pages Router (_app.tsx):

tsx
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { Header } from '@/components/Header';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Header />
      <Component {...pageProps} />
    </>
  );
}

App Router (layout.tsx):

tsx
// app/layout.tsx
import { Header } from '@/components/Header';

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

Вложенные layouts:

tsx
// app/blog/layout.tsx
import { Sidebar } from '@/components/Sidebar';

export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <Sidebar />
      <main className="flex-1">{children}</main>
    </div>
  );
}

5. API Routes

Pages Router:

tsx
// pages/api/contact.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }
  
  // Обработка формы
  res.status(200).json({ success: true });
}

App Router (Route Handlers):

tsx
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const body = await request.json();
  
  // Обработка формы
  
  return NextResponse.json({ success: true });
}

export async function GET() {
  return NextResponse.json({ error: 'Method not allowed' }, { status: 405 });
}

6. Loading и Error состояния

App Router добавляет файловые конвенции:

text
app/
├── blog/
│   ├── page.tsx
│   ├── loading.tsx    # Показывается во время загрузки
│   ├── error.tsx      # Показывается при ошибке
│   └── not-found.tsx  # 404 для этого сегмента
tsx
// app/blog/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/2 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-full mb-2" />
      <div className="h-4 bg-gray-200 rounded w-3/4" />
    </div>
  );
}

// app/blog/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="text-center py-10">
      <h2>Что-то пошло не так</h2>
      <button onClick={reset} className="btn btn-primary mt-4">
        Попробовать снова
      </button>
    </div>
  );
}

Миграция клиентских компонентов

Правило: 'use client'

В App Router компоненты серверные по умолчанию. Для интерактивности добавьте директиву:

tsx
// components/Counter.tsx
'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Счётчик: {count}
    </button>
  );
}

Что требует 'use client':

  • useState, useEffect, useRef и другие хуки
  • onClick, onChange и другие обработчики событий
  • Browser APIs (window, localStorage, navigator)
  • Кастомные хуки с состоянием
  • Context providers

Паттерн: Server + Client

tsx
// app/products/page.tsx (Server Component)
import { ProductList } from '@/components/ProductList';
import { AddToCartButton } from '@/components/AddToCartButton';

async function getProducts() {
  const res = await fetch('https://api.example.com/products');
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();
  
  return (
    <div>
      {products.map(product => (
        <div key={product.id}>
          {/* Серверный рендеринг данных */}
          <h3>{product.name}</h3>
          <p>{product.price} ₽</p>
          
          {/* Клиентская интерактивность */}
          <AddToCartButton productId={product.id} />
        </div>
      ))}
    </div>
  );
}

Миграция контекстов

Проблема: Context в Server Components

Context работает только в клиентских компонентах. Решение — wrapper:

tsx
// providers/theme-provider.tsx
'use client';

import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext<{
  theme: 'light' | 'dark';
  toggle: () => void;
} | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  
  return (
    <ThemeContext.Provider 
      value={{ 
        theme, 
        toggle: () => setTheme(t => t === 'light' ? 'dark' : 'light') 
      }}
    >
      {children}
    </ThemeContext.Provider>
  );
}

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within ThemeProvider');
  return context;
};
tsx
// app/layout.tsx
import { ThemeProvider } from '@/providers/theme-provider';

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

Миграция роутинга

useRouter изменился

Pages Router:

tsx
import { useRouter } from 'next/router';

function Component() {
  const router = useRouter();
  const { slug } = router.query;
  
  router.push('/about');
}

App Router:

tsx
import { useRouter, useParams, useSearchParams } from 'next/navigation';

function Component() {
  const router = useRouter();
  const params = useParams();
  const searchParams = useSearchParams();
  
  const slug = params.slug; // Динамические параметры
  const page = searchParams.get('page'); // Query параметры
  
  router.push('/about');
}
⚠️ Важно

В App Router useRouter импортируется из next/navigation, а не next/router. Это частая ошибка при миграции.

Чеклист миграции

Перед началом

  • Обновить Next.js до 14+ (npm install next@latest)
  • Обновить React до 18+ (npm install react@latest react-dom@latest)
  • Прочитать breaking changes в release notes

Для каждой страницы

  • Создать app/[route]/page.tsx
  • Перенести getServerSideProps / getStaticProps в async компонент
  • Заменить Head на metadata export
  • Добавить 'use client' где нужна интерактивность
  • Проверить работу в dev-режиме
  • Удалить старый файл из pages/

После миграции всех страниц

  • Удалить pages/_app.tsx и pages/_document.tsx
  • Перенести глобальные стили в app/layout.tsx
  • Проверить все API routes
  • Прогнать тесты
  • Проверить SEO (метатеги, sitemap)

Что на самом деле определяет сроки

Оценка миграции почти никогда не упирается в переписывание страниц — это механическая работа. Реальные сроки задают четыре вещи, и оценивать проект нужно по ним.

Клиентские библиотеки. Компоненты, обращающиеся к window или использующие контекст на верхнем уровне, требуют 'use client', а это отменяет часть выгод перехода. Если у вас тяжёлая библиотека графиков, конструктор форм или карта — начните оценку с проверки их совместимости, а не со страниц.

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

Аутентификация. Проверка пользователя переезжает из клиента на сервер, и это меняет способ защиты страниц целиком. Отдельная задача, которую стоит закладывать отдельно.

Сторонние скрипты и аналитика. Счётчики, чаты, пиксели — всё, что подключалось в _document.tsx, требует переделки и, что важнее, проверки, потому что ломается это тихо: страница работает, конверсии просто перестают считаться.

Итоги

Миграция окупается на проектах, которым нужно то, что даёт App Router: серверные компоненты (меньше JavaScript у клиента), потоковая отдача, вложенные лейауты. Если проект — набор статических страниц, выгода будет незаметной, а работа реальной.

Что важно при планировании:

Мигрируйте постранично, начиная с простого. Первая страница займёт непропорционально много времени — на ней вы настроите лейаут, стили и общие компоненты; дальше пойдёт быстрее.

Начните с наименее посещаемой страницы, а не с главной. Ошибки будут, и лучше найти их там, где их увидит меньше людей.

Не мигрируйте и не рефакторьте одновременно. Соблазн «раз уж всё равно переписываем» велик, но при этом теряется возможность сравнить поведение до и после — а именно это спасает при разборе того, что сломалось.

Заложите время на проверку SEO. Метатеги, sitemap, robots, канонические адреса, микроразметка — всё это в App Router описывается иначе, и потеря части метаданных при переезде обнаруживается через недели по трафику.

Частые вопросы

Нужно ли переписывать весь проект сразу при переходе на App Router?

Нет. Директории pages/ и app/ работают параллельно, поэтому мигрировать можно постранично: оставляете pages/ как есть, создаёте app/ рядом, переносите страницы по приоритету и удаляете pages/, когда всё перенесено. Первая страница займёт непропорционально много времени — на ней вы настроите лейаут, стили и общие компоненты, дальше пойдёт быстрее.

Чем заменить getServerSideProps в App Router?

Асинхронным серверным компонентом: данные загружаются прямо в теле функции страницы через await, а параметры маршрута приходят пропсом params. Метаданные вместо компонента Head описываются экспортом metadata, а для динамических — функцией generateMetadata. API-маршруты из pages/api переезжают в app/api как Route Handlers с экспортами GET, POST и другими методами.

Что требует директивы use client?

Хуки состояния и эффектов (useState, useEffect, useRef), обработчики событий вроде onClick и onChange, браузерные API (window, localStorage, navigator), кастомные хуки с состоянием и провайдеры контекста. Практичный паттерн — оставлять страницу серверной, а интерактивность выносить в маленькие клиентские компоненты вроде кнопки добавления в корзину.

Откуда импортировать useRouter в App Router?

Из next/navigation, а не из next/router — это одна из самых частых ошибок при миграции. Там же лежат useParams для динамических сегментов маршрута и useSearchParams для query-параметров: в Pages Router всё это было в объекте router.query.

Что реально определяет сроки миграции на App Router?

Четыре вещи, и оценивать проект нужно по ним, а не по числу страниц. Клиентские библиотеки: тяжёлая библиотека графиков, конструктор форм или карта требуют use client и отменяют часть выгод перехода. Глобальное состояние: контексты, провайдеры темы и сторы нельзя просто перенести — нужно решить, где проходит граница между серверной и клиентской частью дерева. Аутентификация: проверка пользователя переезжает с клиента на сервер и меняет способ защиты страниц целиком. И сторонние скрипты из _document.tsx — счётчики, чаты, пиксели, — которые ломаются тихо: страница работает, конверсии просто перестают считаться.

Стоит ли совмещать миграцию с рефакторингом?

Нет. Соблазн «раз уж всё равно переписываем» велик, но при этом теряется возможность сравнить поведение до и после — а именно это спасает при разборе того, что сломалось. Отдельно заложите время на проверку SEO: метатеги, sitemap, robots, канонические адреса и микроразметка в App Router описываются иначе, и потеря части метаданных обнаруживается через недели по трафику.