Параллельные роуты Next.js: разбор на примере
Параллельные роуты — фишка App Router, до которой не каждый доходит. Пока ты не столкнёшься с конкретной задачей, она кажется сложной и слабо нужной. На самом деле это решение для трёх сценариев: модальные окна с собственным URL, дашборды с независимыми блоками и продвинутая навигация в одной странице. В этой заметке разберу всё на примере дашборда e-commerce, который я недавно делал.
Что это и зачем
В обычном App Router у тебя один children в layout, в который попадает текущая страница. Параллельные роуты позволяют рендерить несколько независимых страниц в один layout, каждую в своём слоте. Слоты называются по соглашению через @:
app/
dashboard/
layout.tsx # принимает children, @analytics, @orders
page.tsx # children
@analytics/
page.tsx # /dashboard рендерит главную страницу analytics
@orders/
page.tsx # /dashboard рендерит главную страницу ordersLayout получает их как пропсы:
export default function Layout({
children,
analytics,
orders,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
orders: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3 gap-4">
<section>{children}</section>
<section>{analytics}</section>
<section>{orders}</section>
</div>
);
}Каждый слот — самостоятельный сегмент маршрута: со своим loading, error, можно навигировать между разными страницами в каждом слоте независимо.
Кейс 1. Модалка с собственным URL
Самый частый и приземлённый вариант. Хочется, чтобы при клике на товар открывалась модалка с детальной карточкой, у неё был свой URL, и при обновлении страницы пользователь видел тот же товар уже в полноэкранной странице.
Структура:
app/
layout.tsx # принимает children, @modal
page.tsx # лента товаров
product/[id]/
page.tsx # полная страница товара
@modal/
default.tsx # пустой слот
(.)product/[id]/
page.tsx # та же страница в модалке через interceptingПрефикс (.) — intercepting routes. Он перехватывает навигацию из текущего сегмента, так что клик в ленте откроется как модалка, а ввод URL напрямую — как полноценная страница. По факту мы получили две презентации одной страницы.
// app/@modal/(.)product/[id]/page.tsx
import { Modal } from '@/components/Modal';
import { Product } from '@/components/Product';
export default async function Page({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: params.id } });
if (!product) return null;
return (
<Modal>
<Product product={product} />
</Modal>
);
}Modal — клиентский компонент, который рендерит portal в <body> и закрывается через router.back(). Это решение я использовал на двух проектах: для просмотра карточек товаров и для деталей задач в trello-подобном интерфейсе.
Кейс 2. Дашборд с независимыми блоками
На дашборде e-commerce у меня были три блока: «продажи за день», «топ товаров», «последние заказы». Каждый загружается со своего API, у каждого своя пагинация, и они не должны блокировать друг друга при загрузке.
app/dashboard/
layout.tsx
@sales/
page.tsx
loading.tsx
@top/
page.tsx
loading.tsx
@orders/
page.tsx
loading.tsx
[page]/
page.tsxВ layout я расположил их в сетке. Каждый слот рисует свой loading.tsx, пока тянет данные — пользователь видит частично готовый дашборд почти сразу, не ждёт самого медленного запроса.
// app/dashboard/@orders/page.tsx
import Link from 'next/link';
export default async function Page() {
const orders = await fetch('https://api/orders?limit=10', { cache: 'no-store' }).then((r) => r.json());
return (
<ul>
{orders.map((o: any) => (
<li key={o.id}>
<Link href={`/dashboard/orders/${o.id}`}>{o.title}</Link>
</li>
))}
</ul>
);
}Внутри слота @orders можно сделать ещё один сегмент [page], и пагинация заказов будет работать независимо от остальных слотов. URL станет вроде /dashboard/orders/2, и при перелистывании обновится только этот слот, остальные останутся с теми же данными.
Кейс 3. Аутентификация через слот
Реже используемый, но удобный приём: вместо того чтобы оборачивать всё приложение в <AuthGuard>, ты делаешь параллельный слот, который рендерит логин-форму на тех страницах, где нет сессии.
app/
layout.tsx
@auth/
page.tsx # пустой
login/
page.tsx # форма входаВ layout рендеришь {auth} как overlay. На страницах, где требуется авторизация, ты делаешь redirect('/login') — браузер «открывает» @auth/login, фон остаётся прежним. Я этим приёмом не злоупотребляю, но в одном проекте он оказался удобнее, чем guard.
Default.tsx — обязателен
Когда у тебя есть слот, в котором не каждая страница перекрывает все маршруты — Next ругается. Решение: добавь default.tsx в каждый слот, в котором возвращай null или плейсхолдер. Это страница «по умолчанию», которая отрисуется, если для текущего URL слот не определил поведение.
// app/@modal/default.tsx
export default function Default() {
return null;
}Без этого ты получишь ошибку «не найден маршрут для слота X», и понять её непросто, пока не запомнишь правило.
Loading и error на уровне слота
Каждый слот может иметь собственные loading.tsx и error.tsx. Это значит, что один блок дашборда может «крутиться», пока другие уже отрендерены, а если один упадёт — error boundary не убьёт остальные. На сложных дашбордах эта изоляция бесценна.
Когда не использовать
- Если у тебя простая страница без сложных секций — не нужно. Параллельные роуты дороже когнитивно, чем обычные компоненты.
- Если данные между слотами зависят друг от друга — параллелизм рушится. Слоты не умеют легко обмениваться данными, и придётся передавать состояние через store.
- Если у тебя SSG: параллельные роуты лучше работают с SSR / streaming, чем со статикой.
Тонкости
Параллельные слоты сохраняются между навигациями только если ты не вводишь URL руками. При первом заходе на «голый» URL Next попытается отрендерить дефолт каждого слота. Если у тебя был открыт пагинатор внутри слота — он сбросится. Это поведение ожидаемое, но первое время удивляет.
Intercepting routes требуют точного префикса: (.) — текущий сегмент, (..) — родительский, (..)(..) — два уровня вверх, (...) — корень. Перепутать легко, поэтому я держу комментарии в файле, что именно перехватывает этот маршрут.
Что копать дальше
Параллельные роуты + intercepting — это конструктор, который при первом знакомстве кажется лишним, а потом начинает использоваться в каждом крупном проекте. Лучший способ привыкнуть — собрать пример с модалкой и дашбордом самому. Дальше уже будешь видеть, где ещё можно применить, и в чём-то даже спасать UX, который без них бы выглядел угрюмо.