Перейти к содержимому

28. Миграция Pages → App Router

Привет, разработчик! 👋 Пришло время поговорить об одной из самых важных тем последних лет в экосистеме Next.js — миграции с Pages Router на App Router. Если у тебя есть существующий проект, это руководство поможет сделать переход плавным, без боли и без “а давайте перепишем всё с нуля”.

Представь переезд из старой квартиры в новую 🏠➡️🏢. Можно взять всё самое нужное, а старый хлам оставить. Именно так работает постепенная миграция в Next.js!


Прежде чем начать, давай честно ответим на вопрос: а оно нам надо? Спойлер: да, надо. Вот почему:

⚡ React Server Components (RSC) — компоненты, которые рендерятся на сервере и не отправляют JavaScript в браузер. Меньше JS = быстрее загрузка.

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

📁 Вложенные Layout-ы — каждая папка может иметь свой layout.tsx, который сохраняется при навигации. Больше никаких _app.js с кучей условий.

🔀 Параллельная загрузка данных — в Server Components можно делать несколько fetch параллельно прямо в компоненте. Никаких хитрых трюков с Promise.all в getServerSideProps.

📦 Меньше клиентского JavaScript — всё, что не требует интерактивности, остаётся на сервере. Бандл уменьшается, Core Web Vitals улучшаются.

🎯 Будущее Next.js — Pages Router больше не получает новых фич. Все улучшения, оптимизации и новые возможности идут только в App Router.

💡 Важно: Страницы на Pages Router не исчезнут в ближайшее время. Vercel обещает поддержку, но активная разработка — только App Router.


Прежде чем писать код, давай поймём концептуальные различия. Это как разница между REST и GraphQL — не просто синтаксис, а другая философия.

КонцепцияPages RouterApp Router
Расположениеpages/app/
Компоненты по умолчаниюClient ComponentsServer Components
Получение данныхgetServerSideProps, getStaticPropsasync/await в компоненте
Layout_app.js (один на всё)layout.tsx (вложенные)
APIpages/api/app/api/route.ts
Метаданные<Head> компонентmetadata объект / generateMetadata
Loading stateРучное управлениеloading.tsx файл
Ошибки_error.jserror.tsx файл
Middlewaremiddleware.tsmiddleware.ts (похоже, но отличия)

Главный сдвиг в мышлении:

Pages Router: "всё — клиентский компонент, данные приходят через props"
App Router: "всё — серверный компонент по умолчанию, 'use client' — исключение"

📋 Стратегия миграции: постепенно, не всё сразу!

Заголовок раздела «📋 Стратегия миграции: постепенно, не всё сразу!»

Золотое правило миграции: никогда не переписывай всё за раз! 🚫

Next.js специально разработал возможность параллельного существования pages/ и app/ в одном проекте. Это значит, ты можешь мигрировать постепенно:

Шаг 1 — Подготовка:

  • Обновись до Next.js 13.4+ (лучше сразу до 14 или 15)
  • Убедись, что тесты проходят
  • Создай ветку feature/app-router-migration

Шаг 2 — Начни с малого:

  • Создай app/ папку рядом с pages/
  • Добавь app/layout.tsx — это обязательный файл
  • Мигрируй одну страницу — например, /about

Шаг 3 — Расширяй постепенно:

  • Переноси страницы одну за другой
  • Начинай с простых (статический контент)
  • Оставь сложные (auth, dashboard) на потом

Шаг 4 — Финальная уборка:

  • Когда все страницы перенесены, удаляй pages/
  • Убирай зависимости от next/router
  • Очищай unused компоненты

⚠️ Предупреждение: Маршруты в app/ имеют приоритет над pages/. Если один и тот же путь существует в обоих местах — победит app/.


Вот как выглядит проект в переходный период:

my-next-app/
├── app/ ← новый App Router
│ ├── layout.tsx ← обязательный root layout
│ ├── page.tsx ← главная страница (мигрирована)
│ ├── about/
│ │ └── page.tsx ← /about (мигрирована)
│ └── blog/
│ └── page.tsx ← /blog (мигрирована)
├── pages/ ← старый Pages Router (ещё работает!)
│ ├── _app.tsx ← всё ещё нужен для pages/
│ ├── dashboard.tsx ← /dashboard (ещё не мигрирована)
│ └── api/
│ └── auth.ts ← API ещё в pages/api
└── ...

Важный нюанс: _app.tsx продолжает оборачивать все страницы из pages/, а app/layout.tsx — все страницы из app/. Они не пересекаются.

// next.config.ts — никаких специальных настроек не нужно!
// Next.js автоматически видит оба роутера
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// App Router включён по умолчанию с Next.js 13.4+
};
export default nextConfig;

_app.js — это “обёртка” вокруг каждой страницы. В App Router его роль берёт на себя app/layout.tsx.

До (Pages Router):

pages/_app.tsx
import type { AppProps } from 'next/app';
import { ThemeProvider } from '@/components/ThemeProvider';
import { AuthProvider } from '@/contexts/AuthContext';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import '@/styles/globals.css';
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<AuthProvider>
<ThemeProvider>
<Navbar />
<main>
<Component {...pageProps} />
</main>
<Footer />
</ThemeProvider>
</AuthProvider>
);
}

После (App Router):

app/layout.tsx
import type { Metadata } from 'next';
import { ThemeProvider } from '@/components/ThemeProvider';
import { AuthProvider } from '@/contexts/AuthContext';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import '@/styles/globals.css';
export const metadata: Metadata = {
title: {
template: '%s | My App',
default: 'My App',
},
description: 'Описание моего приложения',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body>
<AuthProvider>
<ThemeProvider>
<Navbar />
<main>{children}</main>
<Footer />
</ThemeProvider>
</AuthProvider>
</body>
</html>
);
}

⚠️ Нюанс: Провайдеры с useState/useContext должны быть помечены как 'use client'. Вынеси их в отдельный компонент-обёртку!

components/Providers.tsx
'use client'; // ← обязательно для провайдеров с состоянием
import { ThemeProvider } from '@/components/ThemeProvider';
import { AuthProvider } from '@/contexts/AuthContext';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<ThemeProvider>{children}</ThemeProvider>
</AuthProvider>
);
}

_document.js управлял <html>, <head> и <body> тегами. В App Router это тоже становится частью layout.tsx.

До (Pages Router):

pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="ru">
<Head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="icon" href="/favicon.ico" />
<meta name="theme-color" content="#0f172a" />
</Head>
<body className="antialiased">
<Main />
<NextScript />
</body>
</Html>
);
}

После (App Router):

// app/layout.tsx — всё в одном месте!
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin', 'cyrillic'] });
export const metadata: Metadata = {
icons: {
icon: '/favicon.ico',
},
};
// viewport — отдельный экспорт в Next.js 14+
export const viewport: Viewport = {
themeColor: '#0f172a',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
</head>
<body className={`${inter.className} antialiased`}>
{children}
</body>
</html>
);
}

Это самое приятное изменение! getServerSideProps был неудобным костылём — отдельная функция, передача данных через props. Теперь просто async/await прямо в компоненте.

До (Pages Router):

pages/products/[id].tsx
import type { GetServerSideProps } from 'next';
interface Product {
id: string;
name: string;
price: number;
description: string;
}
interface Props {
product: Product;
}
export const getServerSideProps: GetServerSideProps<Props> = async ({ params }) => {
const id = params?.id as string;
const res = await fetch(`https://api.example.com/products/${id}`);
if (!res.ok) {
return { notFound: true }; // покажет 404
}
const product = await res.json();
return { props: { product } };
};
export default function ProductPage({ product }: Props) {
return (
<article>
<h1>{product.name}</h1>
<p className="price">{product.price} ₽</p>
<p>{product.description}</p>
</article>
);
}

После (App Router):

app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
interface Props {
params: Promise<{ id: string }>; // в Next.js 15 params — Promise!
}
export default async function ProductPage({ params }: Props) {
const { id } = await params; // await в Next.js 15
const res = await fetch(`https://api.example.com/products/${id}`);
if (!res.ok) {
notFound(); // вызывает not-found.tsx
}
const product = await res.json();
return (
<article>
<h1>{product.name}</h1>
<p className="price">{product.price} ₽</p>
<p>{product.description}</p>
</article>
);
}

Магия в том, что: компонент сам является и получателем данных, и рендерером. Никаких лишних props!


getStaticProps использовался для статической генерации. В App Router Server Components по умолчанию кэшируются (в Next.js 14) или можно явно указать cache: 'force-cache' (в Next.js 15).

До (Pages Router):

pages/blog/index.tsx
import type { GetStaticProps } from 'next';
interface Post {
id: string;
title: string;
slug: string;
publishedAt: string;
}
export const getStaticProps: GetStaticProps = async () => {
const res = await fetch('https://api.example.com/posts');
const posts: Post[] = await res.json();
return {
props: { posts },
revalidate: 3600, // ISR: обновлять каждый час
};
};
export default function BlogPage({ posts }: { posts: Post[] }) {
return (
<div>
<h1>Блог</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<time>{post.publishedAt}</time>
</article>
))}
</div>
);
}

После (App Router):

app/blog/page.tsx
interface Post {
id: string;
title: string;
slug: string;
publishedAt: string;
}
export default async function BlogPage() {
// next.revalidate = ISR! В Next.js 15 нужно явно указывать кэш
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }, // ISR: каждый час
});
const posts: Post[] = await res.json();
return (
<div>
<h1>Блог</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<time>{post.publishedAt}</time>
</article>
))}
</div>
);
}

getStaticPaths генерировал список путей для статической генерации динамических роутов. Его заменил generateStaticParams.

До (Pages Router):

pages/blog/[slug].tsx
import type { GetStaticPaths, GetStaticProps } from 'next';
export const getStaticPaths: GetStaticPaths = async () => {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
const paths = posts.map((post: { slug: string }) => ({
params: { slug: post.slug },
}));
return {
paths,
fallback: 'blocking', // генерировать новые страницы по запросу
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const res = await fetch(`https://api.example.com/posts/${params?.slug}`);
const post = await res.json();
return { props: { post }, revalidate: 3600 };
};

После (App Router):

app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
// Возвращаем массив объектов с параметрами
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
// fallback: 'blocking' → dynamicParams = true (по умолчанию)
// fallback: false → dynamicParams = false
export const dynamicParams = false; // 404 для не-сгенерированных путей
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 },
});
const post = await res.json();
return <article>{/* ... */}</article>;
}

pages/api/*.ts превращаются в app/api/*/route.ts. Главное отличие — именованные экспорты для каждого HTTP-метода.

До (Pages Router):

pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { db } from '@/lib/db';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
if (req.method === 'GET') {
const user = await db.user.findUnique({ where: { id: id as string } });
if (!user) return res.status(404).json({ error: 'Not found' });
return res.status(200).json(user);
}
if (req.method === 'PUT') {
const user = await db.user.update({
where: { id: id as string },
data: req.body,
});
return res.status(200).json(user);
}
if (req.method === 'DELETE') {
await db.user.delete({ where: { id: id as string } });
return res.status(204).end();
}
res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}

После (App Router):

app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
// GET /api/users/[id]
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(user);
}
// PUT /api/users/[id]
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const user = await db.user.update({ where: { id }, data: body });
return NextResponse.json(user);
}
// DELETE /api/users/[id]
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.user.delete({ where: { id } });
return new Response(null, { status: 204 });
}

Плюсы нового подхода: чистая структура, TypeScript типы из коробки, возможность использовать Web API напрямую (Request/Response).


Это одна из самых частых ошибок при миграции. useRouter есть в обоих роутерах, но импортируется из разных мест и ведёт себя по-разному!

До (Pages Router):

// Всё в одном хуке
import { useRouter } from 'next/router'; // ← next/router!
export default function SearchPage() {
const router = useRouter();
// Параметры строки запроса
const { q, page } = router.query; // { q: 'поиск', page: '2' }
// Динамические сегменты (тоже в query!)
// Для /products/[id]: router.query.id
// Навигация
const handleSearch = (query: string) => {
router.push(`/search?q=${query}`);
};
// Текущий путь
console.log(router.pathname); // '/search'
console.log(router.asPath); // '/search?q=next.js'
return <div>Поиск: {q}</div>;
}

После (App Router):

// Разделено на 3 отдельных хука!
'use client'; // ← обязательно для клиентских хуков
import { useRouter } from 'next/navigation'; // ← next/navigation!
import { useParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { usePathname } from 'next/navigation';
export default function SearchPage() {
const router = useRouter(); // только push/replace/back/forward/refresh
const params = useParams(); // динамические сегменты: { id: '123' }
const searchParams = useSearchParams(); // строка запроса: URLSearchParams
const pathname = usePathname(); // текущий путь: '/search'
const q = searchParams.get('q'); // 'поиск'
const handleSearch = (query: string) => {
router.push('/search?q=' + query);
};
return <div>Поиск: {q}</div>;
}

⚠️ Критически важно: useRouter из next/navigation не имеет query, pathname, asPath. Эти данные теперь в отдельных хуках!


Хорошая новость: это незначительное, но приятное изменение. Больше не нужен <a> внутри <Link>!

До (Pages Router):

import Link from 'next/link';
// Нужен был <a> внутри!
function Navigation() {
return (
<nav>
<Link href="/about">
<a>О нас</a> {/* ← обязательный <a> */}
</Link>
<Link href="/blog">
<a className="nav-link">Блог</a>
</Link>
</nav>
);
}

После (App Router):

import Link from 'next/link';
// <a> не нужен! Link сам рендерит <a>
function Navigation() {
return (
<nav>
<Link href="/about">О нас</Link> {/* чисто! */}
<Link href="/blog" className="nav-link">
Блог
</Link>
{/* Если нужна обёртка — используй legacyBehavior */}
<Link href="/docs" legacyBehavior>
<a className="docs-link">Документация</a>
</Link>
</nav>
);
}

Хорошая новость: middleware.ts остаётся почти без изменений! Файл по-прежнему живёт в корне проекта.

Что изменилось:

// middleware.ts — структура похожа, но есть нюансы
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Защита роутов — без изменений
if (pathname.startsWith('/dashboard')) {
const token = request.cookies.get('token');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// Локализация — без изменений
if (!pathname.startsWith('/ru') && !pathname.startsWith('/en')) {
return NextResponse.redirect(new URL('/ru' + pathname, request.url));
}
return NextResponse.next();
}
export const config = {
// matcher работает одинаково в обоих роутерах
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

⚠️ Нюанс в App Router: в middleware нельзя использовать Node.js API напрямую (нет fs, path и т.д.). Только Edge Runtime. Это актуально и для Pages Router, но в App Router это ещё важнее.


Это один из самых важных архитектурных нюансов. Context API работает только в Client Components!

Проблема:

// ❌ Это НЕ работает — Server Component не может использовать useContext
import { useContext } from 'react';
import { ThemeContext } from '@/contexts/ThemeContext';
// Server Component (нет 'use client')
export default function ServerComponent() {
const theme = useContext(ThemeContext); // ❌ Error!
return <div>...</div>;
}

Решение — создай Client-компонент-обёртку:

contexts/ThemeContext.tsx
'use client'; // ← весь файл контекста должен быть клиентским
import { createContext, useContext, useState } from 'react';
type Theme = 'light' | 'dark';
const ThemeContext = createContext<{
theme: Theme;
toggle: () => void;
}>({ theme: 'dark', toggle: () => {} });
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark');
return (
<ThemeContext.Provider value={{ theme, toggle: () => setTheme(t => t === 'dark' ? 'light' : 'dark') }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
// app/layout.tsx — Server Component может рендерить Client-обёртки!
import { ThemeProvider } from '@/contexts/ThemeContext';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ThemeProvider> {/* Client Component внутри Server Component — OK! */}
{children} {/* children могут быть Server Components — тоже OK! */}
</ThemeProvider>
</body>
</html>
);
}

Альтернатива: используй серверные механизмы — cookies(), headers(), передачу данных через props сверху вниз. Зачастую контекст в Server Components вообще не нужен!


Эти ошибки встречаются у каждого при первой миграции. Сохрани этот раздел!

❌ Ошибка 1: Использование useRouter из next/router в App Router

// ❌ Неправильно — это вызовет ошибку в App Router
import { useRouter } from 'next/router';
// ✅ Правильно
import { useRouter } from 'next/navigation';

❌ Ошибка 2: useState/useEffect в Server Component

// ❌ Ошибка — Server Components не могут иметь состояние
export default function Counter() {
const [count, setCount] = useState(0); // Error!
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// ✅ Добавь 'use client'
'use client';
export default function Counter() {
const [count, setCount] = useState(0); // OK!
// ...
}

❌ Ошибка 3: Забыть await params в Next.js 15

// ❌ В Next.js 15 params — это Promise
export default async function Page({ params }: { params: { id: string } }) {
const { id } = params; // Warning: синхронный доступ устарел
// ✅ Правильно
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; // OK
}

❌ Ошибка 4: Импорт серверных модулей в Client Components

// ❌ Нельзя использовать серверные API в клиентских компонентах
'use client';
import { cookies } from 'next/headers'; // Error!
// ✅ cookies, headers — только в Server Components / Route Handlers

❌ Ошибка 5: Добавление 'use client' везде “на всякий случай”

Это антипаттерн! Каждый 'use client' добавляет JavaScript в бандл. Оставляй компоненты серверными везде, где нет реальной необходимости в клиентском состоянии.

❌ Ошибка 6: Вложенные <html> в Layout

// ❌ Несколько <html> тегов ломают структуру
// app/layout.tsx → <html><body>...</body></html>
// app/blog/layout.tsx → <html><body>...</body></html> // не нужно!
// ✅ Вложенные layout-ы НЕ должны содержать <html> и <body>
// app/blog/layout.tsx
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return <div className="blog-layout">{children}</div>; // просто div!
}

Используй этот список как дорожную карту для своей миграции:

Подготовка:

  • Обновить Next.js до версии 14+ (лучше 15)
  • Запустить npx @next/codemod@canary upgrade latest для автомиграции
  • Убедиться, что все тесты проходят
  • Создать ветку для миграции

Настройка App Router:

  • Создать app/ директорию
  • Создать app/layout.tsx с <html> и <body>
  • Перенести глобальные стили из _app.tsx в layout.tsx
  • Создать Client-обёртки для провайдеров контекста
  • Настроить metadata экспорт

Миграция страниц:

  • Заменить getServerSideProps на async/await в компоненте
  • Заменить getStaticProps на async/await + next.revalidate
  • Заменить getStaticPaths на generateStaticParams
  • Добавить await params (Next.js 15)
  • Заменить notFound() из next/navigation

Навигация и роутинг:

  • Заменить импорты next/router на next/navigation
  • Разделить router.query на useParams + useSearchParams
  • Убрать <a> внутри <Link>
  • Проверить middleware (обычно без изменений)

API Routes:

  • Перенести pages/api/ в app/api/route.ts
  • Заменить NextApiRequest/Response на NextRequest/Response
  • Разделить один handler на именованные HTTP функции

Финальная проверка:

  • Убедиться в отсутствии useRouter из next/router в app/
  • Проверить, что Client Components помечены 'use client'
  • Запустить next build без ошибок
  • Удалить pages/ директорию (когда всё готово)