28. Миграция Pages → App Router
🚀 Миграция с Pages Router на App Router
Заголовок раздела «🚀 Миграция с Pages Router на App Router»Привет, разработчик! 👋 Пришло время поговорить об одной из самых важных тем последних лет в экосистеме Next.js — миграции с Pages Router на App Router. Если у тебя есть существующий проект, это руководство поможет сделать переход плавным, без боли и без “а давайте перепишем всё с нуля”.
Представь переезд из старой квартиры в новую 🏠➡️🏢. Можно взять всё самое нужное, а старый хлам оставить. Именно так работает постепенная миграция в Next.js!
🤔 Зачем мигрировать? Преимущества App Router
Заголовок раздела «🤔 Зачем мигрировать? Преимущества App Router»Прежде чем начать, давай честно ответим на вопрос: а оно нам надо? Спойлер: да, надо. Вот почему:
⚡ 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.
🔄 Ключевые отличия: Pages vs App Router
Заголовок раздела «🔄 Ключевые отличия: Pages vs App Router»Прежде чем писать код, давай поймём концептуальные различия. Это как разница между REST и GraphQL — не просто синтаксис, а другая философия.
| Концепция | Pages Router | App Router |
|---|---|---|
| Расположение | pages/ | app/ |
| Компоненты по умолчанию | Client Components | Server Components |
| Получение данных | getServerSideProps, getStaticProps | async/await в компоненте |
| Layout | _app.js (один на всё) | layout.tsx (вложенные) |
| API | pages/api/ | app/api/route.ts |
| Метаданные | <Head> компонент | metadata объект / generateMetadata |
| Loading state | Ручное управление | loading.tsx файл |
| Ошибки | _error.js | error.tsx файл |
| Middleware | middleware.ts | middleware.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/.
🤝 Сосуществование Pages и App Router
Заголовок раздела «🤝 Сосуществование Pages и App Router»Вот как выглядит проект в переходный период:
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/layout.tsx
Заголовок раздела «🏗️ Миграция _app.js → app/layout.tsx»_app.js — это “обёртка” вокруг каждой страницы. В App Router его роль берёт на себя app/layout.tsx.
До (Pages Router):
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):
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'. Вынеси их в отдельный компонент-обёртку!
'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 → app/layout.tsx
Заголовок раздела «📄 Миграция _document.js → app/layout.tsx»_document.js управлял <html>, <head> и <body> тегами. В App Router это тоже становится частью layout.tsx.
До (Pages Router):
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 → async Server Component
Заголовок раздела «⚡ getServerSideProps → async Server Component»Это самое приятное изменение! getServerSideProps был неудобным костылём — отдельная функция, передача данных через props. Теперь просто async/await прямо в компоненте.
До (Pages Router):
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):
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 → async Server Component
Заголовок раздела «📦 getStaticProps → async Server Component»getStaticProps использовался для статической генерации. В App Router Server Components по умолчанию кэшируются (в Next.js 14) или можно явно указать cache: 'force-cache' (в Next.js 15).
До (Pages Router):
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):
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
Заголовок раздела «🗂️ getStaticPaths → generateStaticParams»getStaticPaths генерировал список путей для статической генерации динамических роутов. Его заменил generateStaticParams.
До (Pages Router):
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):
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 = falseexport 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>;}🛣️ API Routes → Route Handlers
Заголовок раздела «🛣️ API Routes → Route Handlers»pages/api/*.ts превращаются в app/api/*/route.ts. Главное отличие — именованные экспорты для каждого HTTP-метода.
До (Pages Router):
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):
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 из next/router → next/navigation
Заголовок раздела «🧭 useRouter из next/router → next/navigation»Это одна из самых частых ошибок при миграции. 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. Эти данные теперь в отдельных хуках!
🔗 Изменения в next/link
Заголовок раздела «🔗 Изменения в next/link»Хорошая новость: это незначительное, но приятное изменение. Больше не нужен <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»Хорошая новость: 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 в App Router
Заголовок раздела «🌐 Context API в App Router»Это один из самых важных архитектурных нюансов. Context API работает только в Client Components!
Проблема:
// ❌ Это НЕ работает — Server Component не может использовать useContextimport { 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-компонент-обёртку:
'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 Routerimport { 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 — это Promiseexport 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.tsxexport default function BlogLayout({ children }: { children: React.ReactNode }) { return <div className="blog-layout">{children}</div>; // просто div!}✅ Checklist миграции
Заголовок раздела «✅ Checklist миграции»Используй этот список как дорожную карту для своей миграции:
Подготовка:
- Обновить 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/директорию (когда всё готово)