Skip to main content

Паралельні маршрути та перехоплення маршрутів у Next.js

Паралельні маршрути (parallel routes) відображають кілька незалежних сторінок в одному layout через іменовані слоти @folder. Перехоплюючі маршрути (intercepting routes) захоплюють навігацію до дочірнього сегмента і рендерять модальне вікно замість нього, тоді як URL або залишається батьківським, або змінюється на перехоплений шлях - залежно від того, як користувач туди потрапив.

Теорія

TL;DR

  • Паралельні маршрути використовують синтаксис @folder і передаються в layout як окремі React-пропси
  • Кожен слот має власні loading.tsx і error.tsx та фетчить дані незалежно
  • Перехоплюючі маршрути використовують (.)folder, (..)folder або (...)folder щоб захопити навігацію до відповідного сегмента
  • Паралельні - для мультипанельних дашбордів, перехоплюючі - для модалок, які закриваються кнопкою «назад»
  • Разом вони реалізують Instagram-паттерн: модалка при клієнтській навігації, повна сторінка при прямому відкритті URL

Короткий приклад

tsx
// app/dashboard/layout.tsx export default function DashboardLayout({ children, stats, notifications, }: { children: React.ReactNode; stats: React.ReactNode; // app/dashboard/@stats/page.tsx notifications: React.ReactNode; // app/dashboard/@notifications/page.tsx }) { return ( <div className="flex h-screen"> <aside className="w-80 border-r">{stats}</aside> <div className="flex-1 flex flex-col"> <header>{notifications}</header> <main>{children}</main> </div> </div> ); }

Слоти stats і notifications фетчать дані паралельно. Якщо stats кидає помилку, notifications і children продовжують рендеритись - за умови що в кожного слота є власний error.tsx.

Головна різниця

Паралельні маршрути заповнюють іменовані слоти з папок-сусідів на одному URL-рівні. Вони рендеряться одночасно, кожен як окреме дерево React Server Component з незалежним завантаженням даних. Перехоплюючі маршрути не додають нового місця в макеті. Вони перехоплюють рендеринг для конкретного сегмента і показують свій компонент замість нього, а отже той самий URL може відображати два різних інтерфейси залежно від того, чи прийшов користувач туди за посиланням чи ввів адресу напряму.

Коли що використовувати

  • Мультипанельний дашборд де панелі звертаються до різних API: паралельні маршрути
  • Модальне вікно або дравер, який відкривається поверх сторінки і закривається кнопкою «назад»: перехоплюючі маршрути
  • Умовний layout для авторизованих і неавторизованих користувачів: паралельні маршрути з умовним рендерингом
  • Оверлей пошуку як модалка за посиланням, але повна сторінка при прямому відкритті: комбінація обох

Прості двоколонкові макети вирішуються через CSS без паралельних маршрутів. Перехоплюючі маршрути не потрібні для модалок без власного URL.

Як працюють слоти

Папки з @ на початку - це слоти. Next.js передає їх як пропси до найближчого батьківського layout. Ім'я папки стає іменем пропса: @stats стає пропсом stats.

app/dashboard/ layout.tsx <- отримує { children, stats, notifications } page.tsx <- стає children @stats/ page.tsx <- стає пропсом stats loading.tsx <- показується тільки коли stats завантажується error.tsx <- ловить помилки тільки в stats @notifications/ page.tsx <- стає пропсом notifications error.tsx

З власного досвіду: якщо забути default.tsx в слоті, Next.js кидає 404 при переході на URL де в слота немає відповідного сегмента. Достатньо додати default.tsx що повертає null.

Перехоплюючі маршрути: синтаксис і рівні

Префікс визначає на якому рівні відбувається перехоплення:

(.)photo - перехоплює сегмент на тому ж рівні (..)photo - перехоплює на один рівень вище (..)(..)photo - на два рівні вище (...)photo - перехоплює від кореня app

Класичний приклад: сторінка /feed має слот @modal. Всередині нього (.)photo/[id]/page.tsx перехоплює навігацію до /photo/[id] при переходах з /feed. URL змінюється на /photo/123, але Next.js рендерить компонент модалки замість повної сторінки. Якщо відкрити /photo/123 напряму, перехоплення не спрацьовує і завантажується повна сторінка.

Таблиця порівняння

ХарактеристикаПаралельні маршрутиПерехоплюючі маршрути
Синтаксис@slot/page.tsx(.)slot/page.tsx, (..)slot/page.tsx
РендерингКілька слотів в layout одночасноЗамінює рендеринг дочірнього сегмента
Поведінка URLКожен слот відповідає своєму шляхуURL змінюється, але рендеринг перевизначається
Завантаження данихПовністю незалежне для кожного слотаВикористовує контекст батьківського layout
Ізоляція помилокВласний error.tsx для кожного слотаПотрібен error.tsx у папці перехоплення
Де застосовуватиДашборди, умовна авторизаціяФотомодалки, логін-оверлеї, панелі пошуку

Як це працює всередині

На етапі збірки Next.js сканує папки з @ і генерує пропси слотів для layout-ів як частину файлової конвенції App Router. Під час навігації RSC-запити для кожного слота запускаються паралельно без waterfall-ефекту. Перехоплюючі маршрути працюють через пріоритет збігу сегментів: (.) відповідає поточному сегменту шляху, (..) піднімається на один рівень вище, а перехоплений компонент призупиняє рендеринг дочірнього маршруту до моменту закриття.

Поширені помилки

Відсутній default.tsx в слоті

tsx
// app/dashboard/@stats/ не має default.tsx // Перехід на /dashboard/settings -> Next.js видає 404, // бо @stats не має сегмента для цього URL // Рішення: додати app/dashboard/@stats/default.tsx export default function StatsDefault() { return null; }

Відсутній loading.tsx для кожного слота

Без loading.tsx в кожному слоті окремо повільний слот блокує відображення всього layout. Додай @stats/loading.tsx і @notifications/loading.tsx незалежно. Кожен стає власною Suspense-межею.

Перехоплення без префікса (.)

app/settings/profile/page.tsx <- звичайний дочірній маршрут, змінює URL, перехоплення немає app/settings/(.)profile/page.tsx <- перехоплює /settings/profile і показує модалку

Без (.) Next.js обробляє це як звичайний вкладений маршрут. URL змінюється і модалка ніколи не з'явиться.

Немає ізоляції помилок у перехоплюючому маршруті

tsx
// app/settings/(.)profile/page.tsx // Якщо userId недійсний, без error.tsx в цій папці весь layout впаде const user = await fetchUser(searchParams.userId);

Додай app/settings/(.)profile/error.tsx щоб помилка оброблялась локально. Батьківський layout залишиться недоторканим.

Посилання напряму на URL слота

<Link href="/dashboard/@stats"> трактує папку @stats як реальний URL-сегмент, що ламає роутинг. Слоти - це пропси layout, а не навігаційні шляхи.

Де використовується в реальних проектах

  • Vercel dashboard: паралельні слоти для аналітики і деталей використання, кожен звертається до окремого API
  • Linear: перехоплюючі маршрути для модального вікна швидкого пошуку поверх будь-якого проектного view
  • GitHub: паралельні слоти для readme і issues репозиторія в одному layout
  • Instagram-style feed: канонічний приклад з документації Next.js, де слот @modal комбінується з (.)photo/[id]
  • Supabase dashboard: перехоплюючі модалки для SQL-редактора поверх таблиць

Питання на співбесіді

Q: Чим відрізняється завантаження даних у паралельному слоті від вкладеної сторінки?
A: Кожен слот запускає власний fetch() як незалежний RSC без waterfall-ефекту. Вкладена сторінка чекає на fetch батьківського layout перед тим як рендерити дочірній компонент.

Q: Який URL залишається при відкритті модалки через перехоплюючий маршрут?
A: URL змінюється на перехоплений шлях (наприклад /photo/123), але layout рендерить компонент модалки. При прямому відкритті цього URL перехоплення не спрацьовує і завантажується повна сторінка.

Q: Що станеться якщо паралельний слот кидає помилку і не має error.tsx?
A: Помилка піде вгору до найближчої межі помилок. Без error.tsx для кожного слота окремо весь layout впаде. З ним тільки цей слот покаже UI помилки.

Q: Чи можна комбінувати паралельні та перехоплюючі маршрути?
A: Так, і це рекомендований паттерн для фотомодалок. Слот @modal живе в layout стрічки, а всередині нього (.)photo/[id] перехоплює навігацію на сторінку фото. Слот керує станом модалки, перехоплення відповідає за збіг сегментів.

Q: Senior: Як default.tsx впливає на стрімінг у паралельних слотах і коли він його блокує?
A: default.tsx заповнює слот коли для поточного URL немає відповідного сегмента. Next.js рендерить його одразу, без стрімінгу. Якщо default.tsx містить важкий компонент або повільний імпорт, він блокує стрімінг слота навіть коли основний контент вже готовий. Використовуй default.tsx тільки для простих null-повернень або легких скелетонів.

Приклади

Дашборд з незалежними панелями

Дашборд у стилі Vercel, де бічна панель статистики завантажується і обробляє помилки незалежно від основного контенту.

tsx
// app/dashboard/layout.tsx export default function DashboardLayout({ children, stats, notifications, }: { children: React.ReactNode; stats: React.ReactNode; notifications: React.ReactNode; }) { return ( <div className="flex h-screen"> <aside className="w-80 border-r p-4">{stats}</aside> <div className="flex-1 flex flex-col"> <header className="border-b p-4">{notifications}</header> <main className="flex-1 p-6">{children}</main> </div> </div> ); } // app/dashboard/@stats/page.tsx export default async function Stats() { const data = await fetch("https://api.example.com/stats", { cache: "no-store", }).then((r) => r.json()); return ( <div> <p>Запити: {data.requests}</p> <p>Помилки: {data.errors}</p> </div> ); } // app/dashboard/@stats/error.tsx "use client"; export default function StatsError() { return <p>Не вдалось завантажити статистику.</p>; } // app/dashboard/@stats/loading.tsx export default function StatsLoading() { return <div className="animate-pulse h-20 bg-gray-100 rounded" />; }

Кожен слот фетчить незалежно. Якщо API статистики недоступний, основний графік і сповіщення продовжують рендеритись.

Фотомодалка з перехоплюючими маршрутами

Instagram-паттерн: клієнтська навігація показує модалку, пряме відкриття URL завантажує повну сторінку.

tsx
// Структура файлів: // app/feed/page.tsx -> /feed (сітка фото) // app/feed/@modal/(.)photo/[id]/page.tsx -> перехоплює /photo/[id] з /feed // app/feed/@modal/default.tsx -> null (без модалки за замовчуванням) // app/photo/[id]/page.tsx -> /photo/123 (повна сторінка при прямому відкритті) // app/feed/layout.tsx export default function FeedLayout({ children, modal, }: { children: React.ReactNode; modal: React.ReactNode; }) { return ( <> {children} {modal} </> ); } // app/feed/@modal/(.)photo/[id]/page.tsx export default async function PhotoModal({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const photo = await getPhoto(id); return ( <div className="fixed inset-0 bg-black/50 flex items-center justify-center"> <div className="bg-white rounded-lg p-4 max-w-2xl"> <img src={photo.url} alt={photo.title} className="w-full" /> <h2 className="mt-2 text-lg font-medium">{photo.title}</h2> </div> </div> ); } // app/photo/[id]/page.tsx export default async function PhotoPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const photo = await getPhoto(id); return ( <div className="max-w-4xl mx-auto"> <img src={photo.url} alt={photo.title} className="w-full" /> <h1 className="mt-4 text-2xl font-bold">{photo.title}</h1> <p className="mt-2 text-gray-600">{photo.description}</p> </div> ); } // app/feed/@modal/default.tsx export default function ModalDefault() { return null; }

Клік на фото у стрічці веде на /photo/123. Префікс (.)photo перехоплює цей перехід всередині layout стрічки і рендерить модалку. Пряме відкриття /photo/123 обходить перехоплення і завантажує повну сторінку.

Умовний layout для авторизованих користувачів

tsx
// app/layout.tsx import { getUser } from "@/lib/auth"; export default async function RootLayout({ children, dashboard, login, }: { children: React.ReactNode; dashboard: React.ReactNode; // app/@dashboard/page.tsx login: React.ReactNode; // app/@login/page.tsx }) { const user = await getUser(); // Обидва слоти - повноцінні компоненти зі своїми даними. // Layout лише вирішує який з них показати. return ( <html lang="uk"> <body>{user ? dashboard : login}</body> </html> ); }

Обидва слоти @dashboard і @login мають власне завантаження даних і межі помилок. Layout лише вирішує який з них відобразити на основі перевірки сесії.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?