Паралельні та перехоплені маршрути у Next.js 16: модалки, дашборди й багатослотні лейаути (2026)

Практичний посібник з паралельних і перехоплених маршрутів у Next.js 16: модалки через deep-link, багатослотні дашборди, умовний рендер і streaming на слот. З типовими помилками та їх діагностикою.

Паралельні маршрути Next.js 16 (2026)

Оновлено: 2 липня 2026 р.

Паралельні маршрути у Next.js 16 — це механізм App Router, який дозволяє рендерити кілька незалежних сторінок у межах одного лейауту через іменовані слоти (папки з префіксом @), а перехоплені маршрути ((.), (..), (...)) підмінюють цільовий маршрут у поточному контексті. Типовий приклад: відкриття деталей запису як модального вікна без повного переходу. Разом ці два примітиви покривають десь 90% сценаріїв багатопанельних дашбордів, вкладених модалок і умовних лейаутів, які раніше доводилося писати на клієнті через стан і портали.

  • Паралельні маршрути позначаються папками @slotName усередині сегменту та рендеряться як окремі пропси лейауту разом із children.
  • Файл default.js обовʼязковий для кожного слота, інакше жорсткі перезавантаження після soft-навігації повертають 404.
  • Префікси перехоплення: (.) це той самий рівень, (..) на рівень вище, (..)(..) на два рівні вище, (...) від кореня app/.
  • Класичний паттерн «модалка через deep-link»: паралельний слот @modal плюс перехоплення (.)photo/[id], який залишає фонову сторінку живою.
  • Кожен слот має власні loading.tsx і error.tsx, тож streaming й ізоляція помилок працюють незалежно.
  • У Next.js 16 паралельні слоти повністю сумісні з Partial Prerendering і dynamicIO: статичні слоти пре-рендеряться, динамічні стрімляться.

Що таке паралельні маршрути в Next.js?

Паралельні маршрути дозволяють оголосити кілька незалежних дерев маршрутизації в межах одного лейауту. У класичному App Router лейаут отримує один проп children, і на цьому історія закінчується. У Next.js 16, коли ви створюєте всередині сегменту папку з префіксом @ (наприклад, @analytics), фреймворк генерує додатковий проп із тим самим іменем і передає його в той самий layout.tsx. Це означає, що URL /dashboard одночасно активує три піддерева: базове children, @analytics і @team. Кожне з них має власний page.tsx, loading.tsx, error.tsx і навіть власний not-found.tsx.

Для стаф-інженерів це критична зміна ментальної моделі. Маршрутизатор більше не мапить URL на одну сторінку, а на композицію сторінок. Такий підхід усуває цілий клас багів, які виникали, коли ми ліпили паралельні панелі через клієнтський стан у ProviderContext: стрибки, дублювання запитів, втрата стану при перезавантаженні. Паралельні слоти зберігають і кешують стан URL для кожного піддерева окремо, а React 19 Server Components дозволяють рендерити їх узагалі без гідрації, якщо всередині нема інтерактиву. Офіційна документація Parallel Routes у Next.js детально описує внутрішню механіку, зокрема поведінку Router Cache.

Анатомія слота: конвенції @folder і default.js

Розгляньмо мінімальний приклад дашборду з двома слотами. Дерево файлів виглядає так:

app/
  dashboard/
    layout.tsx
    page.tsx              // children
    @analytics/
      page.tsx
      loading.tsx
      default.tsx
    @team/
      page.tsx
      default.tsx

Лейаут отримує обидва слоти як явні пропси:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div className="grid grid-cols-12 gap-6">
      <section className="col-span-8">{children}</section>
      <aside className="col-span-4 space-y-6">
        {analytics}
        {team}
      </aside>
    </div>
  )
}

default.tsx — це фолбек, який Next рендерить, коли слот не має активного стану в URL. Він потрібен для двох сценаріїв: (1) жорсткого перезавантаження після soft-навігації, під час якої слот змінив свій сегмент, і (2) навігації між сегментами, де слот взагалі не існує. Якщо default.tsx відсутній, ви отримаєте 404 при F5. Чесно кажучи, це топ-1 баг у продакшені у команд, які тільки-но мігрували з pages/. Я на нього наступав тричі за перший місяць після виходу Next 16. Мінімальний фолбек часто виглядає як export default function Default() { return null }, але для дашбордів варто рендерити скелетон, щоб уникнути layout shift.

Перехоплені маршрути: (.), (..), (...)

Перехоплення маршруту — це підміна цільового URL у поточному навігаційному контексті. Коли користувач клікає посилання <Link href="/feed/photo/42">, звичайний App Router рендерить app/feed/photo/[id]/page.tsx. Але якщо у сегменті app/feed/ є папка (.)photo/[id]/, Next «перехопить» перехід і покаже саме її лише при soft-навігації. Пряме відкриття або F5 віддадуть повний маршрут, тому deep-link продовжує працювати як зазвичай.

Синтаксис префіксів має чітку семантику рівнів:

ПрефіксЗначенняТиповий кейс
(.)Той самий сегментМодалка деталей у тій самій колонці
(..)Один сегмент вищеПерехоплення сусіднього маршруту
(..)(..)Два сегменти вищеМодалка з глибоко вкладеного списку
(...)Від кореня app/Глобальна модалка (логін, шорткати)

Важливо: рахуються сегменти маршруту, а не рівні файлової системи. Групи роутів у дужках (наприклад, (marketing)) не рахуються за сегмент, і це найчастіший ступор при діагностиці. Деталі є в довіднику Intercepting Routes, який я раджу тримати відкритим у сусідній вкладці, коли будуєте перше перехоплення.

Це найпоширеніший продакшен-паттерн, і водночас той, який найважче правильно скомпонувати з першого разу. Мета проста: клік на карточку у стрічці відкриває деталі як модалку поверх стрічки, refresh на URL деталей віддає повну сторінку, а кнопка «назад» закриває модалку.

app/
  layout.tsx
  @modal/
    default.tsx           // return null
    (.)photo/
      [id]/
        page.tsx          // модалка
  photo/
    [id]/
      page.tsx            // повна сторінка (deep-link, F5)
  feed/
    page.tsx              // список карточок з <Link href="/photo/42" />

Кореневий лейаут відрендерить @modal як overlay:

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <html lang="uk">
      <body>
        {children}
        {modal}
        <div id="modal-root" />
      </body>
    </html>
  )
}

Компонент модалки повинен уміти закриватися через router.back(), щоб історія браузера залишалася коректною:

// app/@modal/(.)photo/[id]/page.tsx
"use client"
import { useRouter } from "next/navigation"

export default function PhotoModal({ params }: { params: { id: string } }) {
  const router = useRouter()
  return (
    <dialog open onClose={() => router.back()} className="modal">
      <button onClick={() => router.back()} aria-label="Закрити">×</button>
      {/* Тут можна тягнути дані Server Component-ом через <Suspense> */}
      <PhotoDetail id={params.id} />
    </dialog>
  )
}

Дашборд із незалежними табами

Другий продакшен-кейс, який я регулярно бачу у SaaS-адмінках, це багатопанельний дашборд, де кожна панель має власний фільтр і pagination, синхронізований з URL. До паралельних маршрутів це вимагало велетенського контекст-провайдера й ручного мерджу query-params. Тепер усе набагато простіше:

app/analytics/
  layout.tsx
  @users/
    page.tsx
    [range]/page.tsx      // /analytics/users/last-7-days
  @revenue/
    page.tsx
    [range]/page.tsx
  @funnel/
    page.tsx

Кожен слот тримає власний сегмент і кешується Router Cache незалежно. Хук useSelectedLayoutSegment('users') віддає активний сегмент конкретного слота, що дозволяє малювати активний таб-хайлайт без клієнтського стану. Комбінація паралельних маршрутів із Partial Prerendering у Next.js 16 дає прогресивний рендер: shell і статичні слоти віддаються миттєво, а динамічні дошиваються стрімом. У парі з директивою dynamicIO ви явно контролюєте, які слоти можуть бути пре-рендерені, а які ні.

Умовний рендер слотів за роллю користувача

Слот у лейауті це звичайний React node, тому нічого не заважає обгорнути його в перевірку прав. Класичний приклад: адмін бачить панель модерації, звичайний користувач її не бачить. У Next.js 16 з появою proxy.ts (заміна middleware.ts, деталі у нашому гіді з міграції на proxy.ts) можна безпечно прочитати сесію в лейауті і показати або приховати слот:

// app/dashboard/layout.tsx
import { getSession } from "@/lib/auth"

export default async function DashboardLayout({
  children,
  moderation,
}: {
  children: React.ReactNode
  moderation: React.ReactNode
}) {
  const session = await getSession()
  return (
    <>
      {children}
      {session?.role === "admin" ? moderation : null}
    </>
  )
}

Streaming, loading.tsx і межі помилок на слот

Кожен слот має власне дерево loading.tsx, error.tsx, not-found.tsx. React 19 автоматично обгортає слот у окремий <Suspense>-boundary, тому пізніші дані не блокують інші панелі. На практиці це означає, що дашборд із трьох слотів (@users тягне з Redis, @revenue з Postgres, @funnel з data warehouse із 900 мс латенси) віддає перші два практично миттєво і стрімить третій. Що показувати в loading.tsx? Скелетон розміром із фінальний контент, щоб уникнути CLS. Що НЕ показувати? Спінер по центру всього слота: це створює візуальний шум під час паралельного стрімінгу.

error.tsx у слоті ловить помилки лише свого піддерева. Це дає можливість зробити «граційний фолбек»: навіть якщо один слот впав, решта дашборду залишається робочою. Обовʼязково додайте кнопку reset(), щоб користувач міг перепробувати без перезавантаження цілої сторінки. Разом із Turbopack-білдами Next.js 16 цей паттерн дає найкращу локальну DX: HMR оновлює лише активний слот, а не все дерево.

Порівняння підходів: слоти, клієнтський стан, роут-групи

КритерійПаралельні маршрутиКлієнтський станРоут-групи
Deep-link на конкретну панельТакНі (без ручного sync)Так
Незалежний streamingТакНіНі (єдина Suspense)
Ізольовані межі помилокТакРучніНі
Стан зберігається при soft-navТак (Router Cache)НіНі
Складність освоєнняСередняНизькаНизька
Розмір клієнтського бандлуМінімальнийНайбільшийСередній

Моя рекомендація для команд така. Якщо на панель робиться fetch і треба URL-shareable стан, беріть паралельні маршрути завжди. Якщо це ефемерний UI-стан (розкрита-згорнута секція, hover-tooltip), клієнтський стан цілком нормально працює. Роут-групи ((group)) залиште для організації файлів, а не для рантайм-поведінки.

Поширені помилки та як їх діагностувати

За рік поставок цієї фічі в клієнтських проєктах я зібрав топ-5 симптомів, які повторюються з разу в раз:

  • 404 після F5 на модалці. Забули default.tsx у слоті @modal. Додайте export default function Default() { return null } і забудьте.
  • Перехоплення не спрацьовує. Ви перерахували рівні ((..) замість (.)) або кладете перехоплення в роут-групу. Next групи не рахує за сегмент. Перевірте дерево у виводі next build.
  • Модалка «моргає» на deep-link. Ви одночасно рендерите фон і перехоплений маршрут. Розділіть їх: @modal/(.)photo/[id]/page.tsx для soft-nav, окрема photo/[id]/page.tsx для прямого відкриття.
  • Слоти дублюють запити. Обидва слоти незалежно тягнуть спільний ресурс. Виносьте його у shared cache-функцію з unstable_cache або використовуйте новий use cache. Деталі є в матеріалі про директиву use cache.
  • Помилка «You cannot have two parallel pages that resolve to the same path». Ви створили @slot/page.tsx, який мапиться на той самий URL, що й children. Явно винесіть його на під-URL або перевірте, що children-сторінка не існує.

Часті запитання

Чи можна використовувати паралельні маршрути без App Router?

Ні. Паралельні маршрути це виключно фіча App Router. У Pages Router аналога не існує, там доведеться використовувати клієнтський стан або кастомний роутер поверх Next Router.

Як передати дані з батьківського лейауту до слота?

Слоти отримують дані так само, як і сторінки: через params, searchParams, серверні fetch-и або cookies()/headers(). Прямої prop-drilling з батьківського лейауту немає, це навмисне рішення команди Next. Спільні дані виносьте у cache-функцію й імпортуйте в кожному слоті, React 19 dedup дедуплікує виклики.

Чи впливають паралельні маршрути на SEO?

Ні, якщо ви правильно налаштували default.tsx і повні маршрути для deep-link. Google краулить URL так само, як звичайні маршрути, а перехоплений варіант рендериться лише при soft-навігації, коли вже є контекст користувача. Головне: переконайтеся, що прямий URL модалки віддає повноцінну сторінку з коректним generateMetadata.

Чи можна вкладати паралельні маршрути один в одного?

Так. Кожен слот це самостійне дерево, всередині якого можна оголосити свої власні @subslot. Це корисно для складних дашбордів із дрилдаунами, але тримайте глибину до 2 або 3 рівнів, інакше візуалізація дерева стає болючою для команди.

Як тестувати перехоплені маршрути?

Юніт-тестів на самі компоненти сторінки достатньо. Для E2E я рекомендую Playwright: клік на <Link> має показати модалку без повного перезавантаження (перевіряйте, що не відбувся load-event), а page.goto на той самий URL має віддати повну сторінку. Обидва сценарії тримайте як окремі тести.

Mei-Lin Wu
Про Автора Mei-Lin Wu

Front-end architect at a SaaS. Owns the build system, the design system, and the war stories about both.