lenec ru

← все посты

Параллельные роуты Next.js: разбор на примере

18K

Параллельные роуты — фишка 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 рендерит главную страницу orders

Layout получает их как пропсы:

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, который без них бы выглядел угрюмо.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.