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

URL: https://dmeshcheryakov.ru/blog/nextjs-app-router-migration/
Раздел: NextJS
Теги: NextJS, React, Migration
Опубликовано: 2026-09-09
Обновлено: 2026-09-07
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

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

Миграция на 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/` когда всё перенесено

```
project/
├── app/           # Новые страницы
│   ├── layout.tsx
│   ├── page.tsx   # Главная (уже мигрирована)
│   └── blog/
│       └── page.tsx
├── pages/         # Старые страницы (ещё работают)
│   ├── about.tsx
│   └── contact.tsx
```

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

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

**Pages Router:**
```
pages/
├── index.tsx        → /
├── about.tsx        → /about
├── blog/
│   ├── index.tsx    → /blog
│   └── [slug].tsx   → /blog/[slug]
└── _app.tsx
└── _document.tsx
```

**App Router:**
```
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 добавляет файловые конвенции:**

```
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 описывается иначе, и потеря части метаданных при переезде обнаруживается через недели по трафику.