App Router Next.js - модель маршрутизации, которая стала стабильной в Next.js 13.4 (май 2023), а начиная с 14‑й и 15‑й версии идёт как способ по умолчанию при create-next-app. Я перевёл на неё несколько боевых проектов - от лендингов до админ-панелей на React - и здесь разбираю по слоям, как устроена папка app, чем она отличается от привычного pages, где миграция реально окупается, а где добавит работы без выгоды. Без абстрактной теории: смотрим на структуру файлов, серверные компоненты и загрузку данных так, как это выглядит в рабочем репозитории.
Что делает App Router и почему Vercel сменил маршрутизацию
Оба роутера решают одну задачу - превратить структуру папок в маршруты сайта. Разница в модели рендеринга. В Pages Router каждый файл в pages/ становится страницей, и почти весь код по умолчанию едет в браузер как клиентский React. В App Router компоненты по умолчанию серверные - React Server Components (RSC). Они выполняются на сервере, отдают готовую разметку, а в бандл клиента попадает только то, что помечено директивой 'use client'.
Эффект видно на цифрах. На одном лендинге после переноса каталога товаров на серверные компоненты first-load JS упал со 180 КБ до примерно 90 КБ - почти вдвое, потому что логика фильтрации и запросы к API остались на сервере и в браузер уже не уезжали. Для страницы, где важна скорость первого экрана и SEO, это ощутимая разница по метрикам Core Web Vitals.
Вторая причина смены - вложенные шаблоны и стриминг. В Pages Router общий layout приходилось городить через _app.js и ручные обёртки. App Router даёт вложенные layout.js, частичную загрузку через Suspense и отдельные состояния загрузки и ошибок на уровне сегмента. Это не косметика, а другой подход к архитектуре страницы.
Структура папок: app против pages
В Pages Router маршрут - это просто файл. pages/about.js становится /about, pages/blog/[slug].js - динамической страницей. Коротко и понятно, но вся служебная логика (layout, обработка ошибок, загрузка) живёт отдельно от маршрута.
В App Router маршрут - это папка, а роль файла определяется его именем. Сама папка задаёт сегмент URL, а маршрут появляется только когда внутри есть page.js.
app/
layout.js // общий каркас для всех страниц
page.js // маршрут /
loading.js // скелетон, пока грузится /
blog/
layout.js // общий layout для раздела блога
page.js // маршрут /blog
[slug]/
page.js // маршрут /blog/:slug
not-found.js // 404 внутри статьи
cart/
page.js // маршрут /cart
error.js // экран ошибки для корзины
Ключевые служебные файлы, которых в Pages Router просто не было:
- page.js - делает сегмент публичным маршрутом. Нет файла - нет URL.
- layout.js - обёртка, которая переиспользуется для всех вложенных страниц и не перемонтируется при навигации между ними.
- loading.js - автоматически показывается через Suspense, пока грузятся данные сегмента.
- error.js - ловит ошибку рендера внутри сегмента, обязательно клиентский компонент.
- route.js - заменяет
pages/api/*, здесь живут обработчики API-запросов.
Отдельный приятный момент - папки в круглых скобках, например (marketing). Они группируют маршруты, но не влияют на URL: удобно, когда у промо-страниц и кабинета нужны разные layout, а адреса остаются плоскими.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
Серверные и клиентские компоненты: React Server Components на практике
Это главная перемена, из-за которой Pages Router нельзя один в один переложить в app. По умолчанию каждый компонент серверный. Он не попадает в бандл, не имеет доступа к useState, useEffect и браузерным API, зато спокойно ходит в базу, читает переменные окружения и обращается к платным API без риска утечь ключом в клиент.
Как только компоненту нужна интерактивность - состояние, обработчики кликов, хуки - я ставлю 'use client' в первой строке файла. С этого момента компонент и всё, что он импортирует, становится клиентским.
// app/product/[id]/page.js — серверный компонент
async function getProduct(id) {
const res = await fetch(`https://api.shop.ru/product/${id}`, {
next: { revalidate: 60 }, // кэш на 60 секунд
});
return res.json();
}
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return (
<article>
<h1>{product.title}</h1>
<AddToCartButton id={product.id} /> {/* это уже клиентский */}
</article>
);
}
Практическое правило, к которому я пришёл: держите клиентскую границу как можно ниже по дереву. Не вешайте 'use client' на всю страницу ради одной кнопки - выносите интерактивный кусок в отдельный маленький компонент, а обёртку оставляйте серверной. На реальной корзине интернет-магазина это дало разницу примерно в 40 КБ клиентского JS только за счёт того, что вычисление итоговой суммы и промокодов осталось на сервере.
Загрузка данных: fetch вместо getServerSideProps
В Pages Router данные тянулись через три экспортируемые функции: getServerSideProps для запроса на каждый заход, getStaticProps для сборки и getStaticPaths для динамических путей. Всё это жило рядом с компонентом, но отдельно от него.
В App Router данные грузятся прямо внутри асинхронного серверного компонента через обычный fetch, а поведение кэша задаётся опциями запроса. Это заметно короче и убирает целый класс шаблонного кода.
| Задача | Pages Router | App Router |
|---|---|---|
| Данные при каждом запросе | getServerSideProps | fetch(url, { cache: ‘no-store’ }) |
| Статика на сборке | getStaticProps | fetch(url) - кэш по умолчанию |
| Обновление по интервалу | revalidate в getStaticProps | fetch(url, { next: { revalidate: 60 } }) |
| Динамические пути | getStaticPaths | generateStaticParams() |
Серверный fetch особенно выручает на интеграциях, где нельзя светить ключи. Когда я подключал эквайринг T‑Bank и расчёт доставки СДЭК на витрине, все запросы с секретными токенами ушли в серверные компоненты и route.js-обработчики - в браузер не попадает ни строчки секретов, а на клиенте остаётся только форма и кнопка оплаты. Если нужен лендинг на Next.js с серверной интеграцией платежей и доставки, именно такая раскладка данных экономит и трафик, и нервы на аудите безопасности.
Вложенные layout, loading и error: чего не хватало в Pages Router
Вложенность - вторая по важности причина, ради которой я перехожу на App Router на новых проектах. Каждый сегмент может иметь свой layout.js, и эти layout вкладываются друг в друга. Шапка сайта, боковое меню кабинета, футер - всё описывается один раз на нужном уровне и не перерисовывается при переходах внутри раздела. В Pages Router такое приходилось эмулировать через паттерн getLayout на странице.
Файл loading.js автоматически оборачивает сегмент в Suspense. Пока грузятся данные, пользователь видит скелетон, а не белый экран, - и это работает без единой строчки ручного кода со спиннерами. На дашборде с тяжёлыми графиками стриминг дал ощущение, что интерфейс появляется мгновенно: каркас и меню рендерятся сразу, а виджеты с данными подтягиваются по мере готовности.
// app/dashboard/loading.js
export default function Loading() {
return <div className="skeleton">Загружаем данные…</div>;
}
// app/dashboard/error.js — обязательно клиентский
'use client';
export default function Error({ error, reset }) {
return (
<div>
<p>Не удалось загрузить: {error.message}</p>
<button onClick={reset}>Повторить</button>
</div>
);
}
Таблица отличий и когда стоит мигрировать
Сведу разницу в одну таблицу, чтобы было видно целиком.
| Критерий | Pages Router | App Router |
|---|---|---|
| Папка | pages/ | app/ |
| Маршрут | файл | папка + page.js |
| Компоненты по умолчанию | клиентские | серверные (RSC) |
| Данные | getServerSideProps / getStaticProps | async-компонент + fetch |
| Общий каркас | _app.js, getLayout | вложенные layout.js |
| Состояние загрузки | вручную | loading.js + Suspense |
| API-роуты | pages/api/* | route.js |
| Статус | поддерживается | рекомендуется по умолчанию |
Важный момент: оба роутера работают в одном проекте одновременно. Vercel не заставляет переписывать всё разом - app и pages уживаются, и мигрировать можно по одному разделу. Я обычно начинаю с новых страниц: их сразу собираю в App Router, а старые переношу постепенно, по мере того как их всё равно приходится трогать.
Где миграция окупается: новый проект с прицелом на SEO и скорость, много статики или контента с редким обновлением, сложная вложенная навигация с кабинетами и разделами. Где я бы не спешил: стабильный работающий проект на Pages Router без жалоб на производительность, тяжёлая завязка на клиентские библиотеки, которые ещё не дружат с RSC, или сжатые сроки, когда переписывать data fetching просто некогда. Переход - это не косметика, а смена модели загрузки данных, и закладывать на него стоит реальные часы, а не полдня.
Если команда впервые берёт App Router, первый месяц уходит на привыкание к границе сервер-клиент - самая частая ошибка новичков это useState в серверном компоненте и упавшая сборка. Дальше становится комфортно, и назад на Pages Router уже не хочется.
Быстрый SEO-лендинг под продукт
Лендинг на Next.js
от 80 000 ₽
Подробнее →Частые вопросы
App Router полностью заменил Pages Router?
Нет. Pages Router поддерживается и остаётся рабочим вариантом, его не объявляли устаревшим. App Router - рекомендуемый способ для новых проектов, но оба роутера можно держать в одном приложении и переносить страницы постепенно, без большого переписывания за раз.
Обязательно ли переписывать проект на App Router?
Если текущий проект на Pages Router работает и по скорости всё устраивает, срочной необходимости нет. Смысл появляется, когда нужны серверные компоненты ради облегчения бандла, вложенные layout или стриминг с частичной загрузкой. На новом проекте я почти всегда стартую сразу с App Router.
Чем серверные компоненты отличаются от getServerSideProps?
getServerSideProps выполнялся на сервере, но результат прокидывался в клиентский компонент, который всё равно попадал в бандл. Серверный компонент сам выполняется на сервере и в браузер не отправляется вовсе - в клиент едет только готовая разметка и явно помеченные 'use client' куски. Отсюда меньше JS и невозможность случайно раскрыть секретный ключ.
Сколько времени занимает миграция с Pages Router на App Router?
Зависит от размера. Небольшой лендинг на 5-7 страниц я переношу за 2-3 дня вместе с переделкой загрузки данных. Крупный магазин или админку с десятками маршрутов и интеграциями стоит планировать на несколько недель и переводить по разделам, а не одним заходом, чтобы не заморозить разработку остального.