Czym są Parallel Routes i dlaczego warto je znać
Ręka do góry — kto nigdy nie budował dashboardu z wieloma panelami, żeby potem walczyć z routingiem przez dobre kilka godzin? Tradycyjne podejście w aplikacjach SPA to ręczne zarządzanie stanem modali, utrata kontekstu przy odświeżeniu strony i zero możliwości udostępnienia linku do konkretnego widoku. Brzmi znajomo?
Parallel Routes i Intercepting Routes w Next.js App Router rozwiązują te problemy na poziomie systemu plików. Zamiast kombinować ze skomplikowaną logiką zarządzania stanem, po prostu definiujesz odpowiednią strukturę folderów — a framework robi resztę.
W tym artykule przejdziemy przez oba mechanizmy od podstaw. Zbudujemy kilka praktycznych przykładów (w tym wzorzec modala z deep linking), a na koniec omówimy najczęstsze pułapki, które potrafią zepsuć nawet doświadczonym programistom cały dzień. Wszystkie przykłady kodu są kompatybilne z Next.js 16 i uwzględniają zmiany z 2026 roku.
Parallel Routes: Wiele widoków w jednym layoucie
Parallel Routes pozwalają jednocześnie renderować wiele niezależnych stron (tzw. slotów) w ramach jednego layoutu. Każdy slot działa jak osobna trasa — ma własny stan nawigacji, własne pliki loading.tsx i error.tsx, a co najważniejsze — może się aktualizować niezależnie od reszty.
Sloty definiujemy konwencją @folder. Folder z prefiksem @ automatycznie staje się slotem i jest przekazywany jako prop do layoutu na tym samym poziomie drzewa katalogów.
Podstawowa struktura katalogów
Wyobraźmy sobie dashboard z trzema panelami — przeglądem, projektami i aktywnością zespołu:
app/
├── dashboard/
│ ├── @overview/
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ └── default.tsx
│ ├── @projects/
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ └── default.tsx
│ ├── @activity/
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ └── default.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ └── default.tsx
Każdy folder @overview, @projects i @activity to slot. Next.js automatycznie przekaże je jako propsy do layoutu:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
overview,
projects,
activity,
}: {
children: React.ReactNode;
overview: React.ReactNode;
projects: React.ReactNode;
activity: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3 gap-6 p-8">
<section className="col-span-2">
{children}
<div className="grid grid-cols-2 gap-4 mt-6">
{overview}
{activity}
</div>
</section>
<aside className="col-span-1">
{projects}
</aside>
</div>
);
}
children to niejawny slot — odpowiada plikowi page.tsx w katalogu dashboard/. Nie musisz tworzyć dla niego folderu @children, ale uwaga — w Next.js 16 musisz mieć plik default.tsx na tym samym poziomie. Łatwo o tym zapomnieć.
Niezależne stany ładowania i błędów
To jest chyba moja ulubiona rzecz w Parallel Routes. Każdy slot może mieć własny loading.tsx i error.tsx, więc kiedy jeden panel ciągnie dane z wolnego API, pozostałe po prostu się renderują:
// app/dashboard/@activity/loading.tsx
export default function ActivityLoading() {
return (
<div className="animate-pulse space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
</div>
);
}
// app/dashboard/@activity/error.tsx
'use client';
export default function ActivityError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-4 bg-red-50 rounded-lg">
<p className="text-red-600">Nie udało się załadować aktywności</p>
<button
onClick={reset}
className="mt-2 text-sm text-red-500 underline"
>
Spróbuj ponownie
</button>
</div>
);
}
To ogromna zmiana w porównaniu z sytuacją, gdy jeden błąd w jednym komponencie wywala biały ekran na całym dashboardzie. Szczerze mówiąc, sam nie raz na to trafiałem.
Warunkowe renderowanie slotów
Parallel Routes świetnie się sprawdzają przy renderowaniu różnych widoków w zależności od roli użytkownika:
// app/dashboard/layout.tsx
import { auth } from '@/lib/auth';
export default async function DashboardLayout({
children,
admin,
user,
}: {
children: React.ReactNode;
admin: React.ReactNode;
user: React.ReactNode;
}) {
const session = await auth();
const isAdmin = session?.user?.role === 'admin';
return (
<main>
{children}
{isAdmin ? admin : user}
</main>
);
}
W tym wzorcu sloty @admin i @user zawierają zupełnie inne widoki — admin widzi statystyki i zarządzanie, a zwykły użytkownik swoje dane i ustawienia. Proste i czytelne.
Plik default.tsx: Fundament stabilnego routingu
Okej, teraz porozmawiajmy o default.tsx — bo to prawdopodobnie najczęstsze źródło bólu głowy przy pracy z Parallel Routes. Zrozumienie, kiedy i dlaczego jest potrzebny, zaoszczędzi ci naprawdę sporo czasu.
Kiedy Next.js potrzebuje default.tsx?
Rozróżniamy dwa typy nawigacji:
- Soft navigation — kliknięcie komponentu
<Link>. Next.js pamięta aktywny stan każdego slotu i renderuje go poprawnie. - Hard navigation — odświeżenie strony albo wejście bezpośrednio przez URL. Next.js nie zna aktywnego stanu slotów i szuka pliku
default.tsxjako fallbacku.
Jeśli default.tsx nie istnieje, Next.js renderuje 404. Co więcej, w Next.js 16 wprowadzono breaking change: brak pliku default.js w dowolnym slocie powoduje błąd budowania. Wcześniej aplikacja po prostu cicho renderowała 404 w runtime — teraz dowiesz się o problemie od razu.
Wzorce implementacji default.tsx
// Najczęstszy wzorzec — zwróć null, gdy slot nie powinien
// nic wyświetlać
// app/dashboard/@modal/default.tsx
export default function ModalDefault() {
return null;
}
// Alternatywnie — przekieruj na stronę 404
// app/dashboard/@overview/default.tsx
import { notFound } from 'next/navigation';
export default function OverviewDefault() {
notFound();
}
// Lub wyświetl ten sam widok co page.tsx
// app/dashboard/@projects/default.tsx
export { default } from './page';
Zasada kciuka: dla slotów modalnych — return null. Dla slotów z treścią — re-eksportuj page.tsx lub zwróć sensowny fallback. I nie zapomnij o pliku default.tsx dla niejawnego slotu children!
Intercepting Routes: Przechwytywanie nawigacji
Intercepting Routes pozwalają załadować treść z innej trasy w kontekście bieżącego layoutu — bez zmiany tego, co użytkownik widzi w pasku adresu. To mechanizm, dzięki któremu można budować modale, podglądy, boczne panele i wszelkie widoki typu overlay.
Konwencja nazewnictwa (.)
Intercepting Routes używają notacji nawiasowej, trochę jak ścieżki względne w systemie plików:
(.)— przechwytuje trasę na tym samym poziomie segmentów routingu(..)— przechwytuje trasę jeden poziom wyżej(..)(..)— przechwytuje trasę dwa poziomy wyżej(...)— przechwytuje trasę od katalogu głównegoapp
Ważna uwaga: konwencja (..) bazuje na segmentach routingu, nie na fizycznej strukturze katalogów. Foldery parallel route (@slot) nie liczą się jako segmenty. To częste źródło błędów — serio, sprawdź to dwa razy, zanim zaczniesz debugować coś innego.
Jak działa przechwytywanie w praktyce
Rozważmy galerię zdjęć. Chcemy, żeby:
- Kliknięcie zdjęcia na liście otwierało je w modalu (bez pełnej nawigacji)
- Bezpośrednie wejście na
/photo/123renderowało pełną stronę zdjęcia - Odświeżenie strony z otwartym modalem pokazywało pełną stronę
Struktura katalogów:
app/
├── @modal/
│ ├── (.)photo/
│ │ └── [id]/
│ │ └── page.tsx ← widok w modalu
│ └── default.tsx ← zwraca null
├── photo/
│ └── [id]/
│ └── page.tsx ← pełna strona zdjęcia
├── layout.tsx
├── page.tsx ← lista zdjęć
└── default.tsx
Gdy użytkownik klika <Link href="/photo/123">, Next.js przechwytuje nawigację i zamiast renderować pełną stronę, ładuje komponent z @modal/(.)photo/[id]/page.tsx w slocie @modal. URL w przeglądarce zmienia się na /photo/123, ale layout pozostaje — lista zdjęć wciąż jest widoczna pod modalem.
Natomiast przy bezpośrednim wejściu na /photo/123 (wpisanie URL-a, odświeżenie, link z zewnątrz) — przechwycenie nie zachodzi i renderuje się normalny photo/[id]/page.tsx.
Budujemy modal z deep linking — krok po kroku
No dobra, czas na mięso. Połączenie Parallel Routes z Intercepting Routes daje nam modale, które:
- Mają własny URL — można je udostępnić linkiem
- Zamykają się przy nawigacji wstecz
- Zachowują kontekst strony w tle
- Po odświeżeniu renderują pełną stronę zamiast modala
Krok 1: Komponent Modal
Zacznijmy od reużywalnego komponentu modala:
// components/Modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef } from 'react';
export default function Modal({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const overlayRef = useRef<HTMLDivElement>(null);
const onDismiss = useCallback(() => {
router.back();
}, [router]);
// Zamknij modal klawiszem Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onDismiss]);
// Zamknij modal po kliknięciu w tło
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onDismiss();
};
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
>
<div className="relative w-full max-w-2xl rounded-xl bg-white p-6 shadow-xl">
<button
onClick={onDismiss}
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600"
aria-label="Zamknij"
>
✕
</button>
{children}
</div>
</div>
);
}
Kluczowa sprawa — modal zamyka się przez router.back(). Nawigacja wstecz usuwa trasę przechwytującą ze stosu, co odmontowuje slot @modal. Eleganckie, prawda?
Krok 2: Layout z slotem @modal
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="pl">
<body>
{children}
{modal}
</body>
</html>
);
}
Krok 3: Strona galerii
// app/page.tsx
import Link from 'next/link';
const photos = [
{ id: 1, title: 'Zachód słońca', src: '/photos/sunset.jpg' },
{ id: 2, title: 'Góry', src: '/photos/mountains.jpg' },
{ id: 3, title: 'Ocean', src: '/photos/ocean.jpg' },
];
export default function GalleryPage() {
return (
<main className="p-8">
<h1 className="text-3xl font-bold mb-8">Galeria zdjęć</h1>
<div className="grid grid-cols-3 gap-4">
{photos.map((photo) => (
<Link
key={photo.id}
href={`/photo/${photo.id}`}
className="group relative aspect-square overflow-hidden rounded-lg"
>
<img
src={photo.src}
alt={photo.title}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
<span className="absolute bottom-2 left-2 text-white font-medium">
{photo.title}
</span>
</Link>
))}
</div>
</main>
);
}
Uwaga: musisz używać komponentu <Link> z Next.js — zwykły tag <a> wywoła hard navigation i przechwycenie nie zadziała. To jeden z tych błędów, które łatwo przegapić.
Krok 4: Przechwycona trasa (widok w modalu)
// app/@modal/(.)photo/[id]/page.tsx
import Modal from '@/components/Modal';
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Pobierz dane zdjęcia (Server Component)
const photo = await getPhoto(id);
return (
<Modal>
<img
src={photo.src}
alt={photo.title}
className="w-full rounded-lg"
/>
<h2 className="mt-4 text-xl font-semibold">{photo.title}</h2>
<p className="mt-2 text-gray-600">{photo.description}</p>
</Modal>
);
}
Zwróć uwagę na typ params: Promise<{ id: string }> i await params. Od Next.js 16 parametry routingu są wyłącznie asynchroniczne — synchroniczny dostęp został całkowicie usunięty. Dotyczy to page.tsx, layout.tsx, default.tsx i route.tsx.
Krok 5: Pełna strona zdjęcia (fallback dla hard navigation)
// app/photo/[id]/page.tsx
import Link from 'next/link';
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<main className="flex min-h-screen items-center justify-center bg-gray-100 p-8">
<article className="max-w-3xl">
<Link
href="/"
className="mb-4 inline-block text-blue-600 hover:underline"
>
← Powrót do galerii
</Link>
<img
src={photo.src}
alt={photo.title}
className="w-full rounded-xl shadow-lg"
/>
<h1 className="mt-6 text-3xl font-bold">{photo.title}</h1>
<p className="mt-4 text-gray-700 leading-relaxed">
{photo.description}
</p>
</article>
</main>
);
}
Krok 6: Pliki default.tsx
// app/@modal/default.tsx
export default function ModalDefault() {
return null;
}
// app/default.tsx
export { default } from './page';
Te pliki są absolutnie konieczne. Bez app/@modal/default.tsx odświeżenie strony na /photo/123 spowoduje błąd budowania w Next.js 16 (lub 404 we wcześniejszych wersjach). Nie pomijaj ich.
Zaawansowane wzorce: Dashboard z wieloma slotami
Modale to najpopularniejszy przypadek użycia, ale Parallel Routes potrafią znacznie więcej. Oto wzorzec dashboardu analitycznego, gdzie różne sekcje ciągną dane z różnych API:
// app/analytics/layout.tsx
import { Suspense } from 'react';
export default function AnalyticsLayout({
children,
revenue,
users,
events,
}: {
children: React.ReactNode;
revenue: React.ReactNode;
users: React.ReactNode;
events: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-8 py-4">
<h1 className="text-2xl font-bold">Panel analityczny</h1>
</header>
<div className="p-8 space-y-6">
{/* Główna treść */}
{children}
{/* Sloty renderowane równolegle z niezależnym
ładowaniem i obsługą błędów */}
<div className="grid grid-cols-3 gap-6">
<Suspense fallback={<CardSkeleton />}>
{revenue}
</Suspense>
<Suspense fallback={<CardSkeleton />}>
{users}
</Suspense>
<Suspense fallback={<CardSkeleton />}>
{events}
</Suspense>
</div>
</div>
</div>
);
}
function CardSkeleton() {
return (
<div className="animate-pulse bg-white rounded-xl p-6 h-48">
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4"></div>
<div className="h-8 bg-gray-200 rounded w-2/3"></div>
</div>
);
}
Każdy slot (@revenue, @users, @events) to Server Component pobierający dane niezależnie. React streamuje gotowe fragmenty do przeglądarki — użytkownik widzi wyniki stopniowo, bez czekania na najwolniejsze zapytanie. W praktyce to daje naprawdę odczuwalną różnicę w UX.
Intercepting Routes od katalogu głównego: Globalne modale
Konwencja (...) pozwala przechwycić trasę z dowolnego miejsca w aplikacji, od poziomu katalogu app/. Idealne rozwiązanie dla globalnych modali — logowanie, rejestracja, koszyk zakupowy:
app/
├── @modal/
│ ├── (...)login/
│ │ └── page.tsx ← modal logowania
│ ├── (...)cart/
│ │ └── page.tsx ← modal koszyka
│ └── default.tsx
├── login/
│ └── page.tsx ← pełna strona logowania
├── cart/
│ └── page.tsx ← pełna strona koszyka
├── products/
│ └── page.tsx ← może zawierać Link do /cart
├── layout.tsx
└── default.tsx
Teraz <Link href="/login"> z dowolnego miejsca w aplikacji otworzy modal logowania, a bezpośrednie wejście na /login wyświetli pełną stronę. Jeden kod, dwa doświadczenia użytkownika — całkiem sprytne.
Rozwiązywanie problemów: Najczęstsze pułapki
Po kilku projektach z Parallel Routes i Intercepting Routes mogę szczerze powiedzieć, że pewne problemy powtarzają się niemal za każdym razem. Oto lista pułapek, na które prawdopodobnie trafisz (i jak je naprawić).
Problem 1: Błąd 404 po odświeżeniu strony
Objaw: Modal działa po kliknięciu linku, ale po odświeżeniu strony widzisz 404.
Przyczyna: Brak pliku default.tsx w slocie parallel route.
Rozwiązanie: Dodaj default.tsx do każdego slotu @folder oraz do katalogu z niejawnym slotem children.
Problem 2: Modal nie znika po nawigacji
Objaw: Po przejściu na inną stronę treść modala wciąż jest widoczna.
Przyczyna: Layout nie renderuje się ponownie przy nawigacji — slot @modal zachowuje ostatni stan.
Rozwiązanie: Użyj catch-all route w slocie, który zwraca null:
// app/@modal/[...catchAll]/page.tsx
export default function CatchAllModal() {
return null;
}
Problem 3: Przechwytywanie nie działa — renderuje się pełna strona
Objaw: Kliknięcie <Link> otwiera pełną stronę zamiast modala.
Możliwe przyczyny:
- Używasz tagu
<a>zamiast komponentu<Link>z Next.js - Źle policzyłeś poziomy w konwencji
(..)— pamiętaj, foldery@slotnie liczą się jako segmenty - Serwer deweloperski wymaga restartu po dodaniu nowych slotów
Problem 4: Dwa modale wyświetlają się jednocześnie
Objaw: Przy nawigacji między modalami oba są widoczne naraz.
Rozwiązanie: Umieść wszystkie intercepting routes w jednym slocie parallel route. Osobne sloty (@loginModal, @cartModal) dla różnych modali spowodują, że oba renderują się równolegle — bo to dokładnie to, co Parallel Routes robią.
Problem 5: Nowy slot nie jest wykrywany w dev serverze
Objaw: Dodałeś nowy folder @slot, ale hot reload go nie łapie.
Rozwiązanie: Zatrzymaj i uruchom ponownie serwer deweloperski. To znany problem z cache — rm -rf .next && npm run dev powinno załatwić sprawę.
Problem 6: Statyczne i dynamiczne sloty w jednym layoucie
Objaw: Strona, która powinna być generowana statycznie, przełącza się na dynamiczne renderowanie.
Przyczyna: Jeśli choćby jeden slot w layoucie jest dynamiczny, wszystkie sloty na tym samym poziomie stają się dynamiczne.
Rozwiązanie: Oddziel dynamiczne sloty do osobnego, zagnieżdżonego layoutu.
Zmiany w Next.js 16 dotyczące Parallel Routes
Next.js 16 (aktualnie najnowsza stabilna wersja) wprowadził kilka ważnych zmian, o których warto wiedzieć:
- Wymagany plik
default.js— wszystkie sloty muszą mieć jawnydefault.js. Brak = błąd budowania (nie cichy 404 jak kiedyś). - Asynchroniczne parametry —
paramswpage.tsx,layout.tsx,default.tsxiroute.tsxsą dostępne wyłącznie przezawait. Synchroniczny dostęp usunięto całkowicie. - Automatyczne typowanie slotów (od 15.5) —
LayoutPropsautomatycznie uwzględnia sloty parallel route, więc ręczna deklaracja typów nie jest już konieczna. - Per-segment prefetching — ulepszone prefetchowanie z lepszym ponownym wykorzystaniem cache, co poprawia wydajność nawigacji między slotami.
Jeśli migrujesz z Next.js 15, skorzystaj z oficjalnego codemod: npx @next/codemod@canary upgrade latest.
Kiedy stosować Parallel Routes, a kiedy nie
Parallel Routes to potężne narzędzie, ale nie zawsze potrzebne. Oto szybki przegląd:
Sięgaj po Parallel Routes, gdy:
- Potrzebujesz niezależnych stanów ładowania i błędów dla różnych sekcji strony
- Chcesz warunkowo renderować całe sekcje na podstawie danych użytkownika
- Budujesz modale z deep linking (w połączeniu z Intercepting Routes)
- Dashboard ma panele pobierające dane z różnych źródeł
Odpuść sobie, gdy:
- Prosty modal bez potrzeby URL-a — wystarczy
useState - Proste zakładki bez niezależnego ładowania —
searchParamszrobią robotę - Layout z sidebarem bez niezależnej nawigacji — zwykłe zagnieżdżone layouty wystarczą
Często zadawane pytania
Czym się różnią Parallel Routes od zwykłych zagnieżdżonych layoutów?
Zagnieżdżone layouty renderują jedną aktywną stronę w danym momencie — layout opakowuje children, a children to zawsze jeden komponent. Parallel Routes pozwalają renderować wiele niezależnych stron jednocześnie, z osobnymi stanami ładowania, błędów i nawigacji. To fundamentalna różnica — layouty to hierarchia, Parallel Routes to kompozycja równoległa.
Czy Intercepting Routes działają z tagiem <a> zamiast komponentu Link?
Nie. Intercepting Routes wymagają soft navigation, czyli nawigacji po stronie klienta przez <Link> lub router.push(). Zwykły <a> wywołuje hard navigation (pełne przeładowanie), więc przechwycenie nie zachodzi. To samo dotyczy wpisania URL-a w przeglądarkę lub odświeżenia strony.
Jak prawidłowo liczyć poziomy w konwencji (..) dla Intercepting Routes?
Konwencja (..) odnosi się do segmentów routingu, nie fizycznych folderów. Foldery @modal, @sidebar oraz grupy tras jak (marketing) czy (auth) nie są liczone jako segmenty. Liczą się tylko foldery tworzące rzeczywiste segmenty URL. W razie wątpliwości — porównaj segmenty URL obu tras. To najbezpieczniejsza metoda.
Dlaczego po odświeżeniu strony zamiast modala widzę pełną stronę lub błąd 404?
To zamierzone zachowanie. Intercepting Routes działają wyłącznie podczas soft navigation. Odświeżenie to hard navigation — Next.js nie może odtworzyć kontekstu przechwycenia i renderuje normalną trasę. Błąd 404 oznacza brak default.tsx. W Next.js 16 brak tego pliku to błąd budowania, co w sumie jest lepsze — dowiadujesz się o problemie wcześniej.
Czy mogę używać Parallel Routes w Server Components bez utraty wydajności?
Tak, i szczerze mówiąc to jeden z głównych powodów ich istnienia. Sloty to domyślnie Server Components — dane pobierasz na serwerze, React streamuje gotowe fragmenty do klienta. Jedno zastrzeżenie: jeśli dowolny slot jest dynamiczny, wszystkie sloty w tym samym layoucie przełączają się na renderowanie dynamiczne. Warto o tym pamiętać przy planowaniu struktury.