Миграция на 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 никуда не делся. Оба роутера работают параллельно, что позволяет мигрировать постепенно.
Стратегия миграции
Подход «страница за страницей»
Не переписывайте всё сразу. Мигрируйте по одной странице:
- Оставляете
pages/как есть - Создаёте
app/рядом - Переносите страницы по приоритету
- Удаляете
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.tsxApp 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):
// 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):
// 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:
// 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:
// app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'О нас',
description: 'Описание страницы',
};
export default function About() {
return <main>...</main>;
}Динамические метаданные:
// 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):
// 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):
// 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:
// 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:
// 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):
// 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 для этого сегмента// 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 компоненты серверные по умолчанию. Для интерактивности добавьте директиву:
// 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
// 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:
// 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;
};// 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:
import { useRouter } from 'next/router';
function Component() {
const router = useRouter();
const { slug } = router.query;
router.push('/about');
}App Router:
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наmetadataexport - Добавить
'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 описываются иначе, и потеря части метаданных обнаруживается через недели по трафику.