Przejdź do treści

Parallel i Intercepting Routes w Next.js App Router

Twórz zaawansowane układy paneli i modale z własnym URL. Zobacz, jak prawidłowo skonfigurować najbardziej niedoceniane funkcje App Routera.

Maciej Sala

Founder StriveLab

8 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 23 lipca 2026)

Dlaczego Parallel Routes i Intercepting Routes są niedoceniane w App Routerze?

Większość tutoriali Next.js App Router skupia się na layoutach, ładowaniu i Server Components. Tymczasem oraz rozwiązują problemy, z którymi frontendowcy walczą od lat: modale z własnym URL-em, panele aplikacji z niezależnymi sekcjami i płynna nawigacja bez utraty kontekstu.

Nie są nowe. Siedzą w App Routerze od wersji 13.3. Mimo to wciąż mało kto je rozumie i jeszcze mniej osób używa. Szkoda, bo gdy raz załapiesz, do czego służą, zaczynasz je widzieć wszędzie.

Parallel Routes w Next.js: wiele widoków w jednym layoucie

Czym są Parallel Routes w App Routerze?

Parallel Routes pozwalają renderować wiele stron jednocześnie w ramach jednego layoutu. Każda z nich może mieć własny stan ładowania, obsługę błędów i aktywną podstronę. Nazwane sloty nie tworzą segmentów URL. Przykładowo app/@analytics/views/page.tsx nadal odpowiada ścieżce /views, a nie /@analytics/views.

Definiujesz je za pomocą nazwanych slotów, czyli katalogów zaczynających się od @:

Code
app/
├── layout.tsx          ← layout przyjmuje sloty jako właściwości
├── page.tsx            ← główna strona
├── @analytics/
│   ├── default.tsx
│   └── page.tsx        ← slot "analytics"
├── @notifications/
│   ├── default.tsx
│   └── page.tsx        ← slot "notifications"
└── @feed/
    ├── default.tsx
    └── page.tsx        ← slot "feed"

Jak layout korzysta ze slotów Parallel Routes?

Każdy nazwany slot jest automatycznie przekazywany do layoutu jako właściwość:

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

children to domyślny slot i odpowiada plikowi page.tsx w tym samym katalogu.

Podczas nawigacji klientowej Next.js zapamiętuje aktywną podstronę każdego slotu. Jeśli nowy URL nie pasuje do jednego ze slotów, framework zachowuje jego poprzedni widok. Po odświeżeniu strony nie ma jednak tego stanu w pamięci, dlatego dla niedopasowanego slotu renderowany jest default.tsx.

Warto pamiętać o jeszcze jednym ograniczeniu. Na tym samym poziomie segmentu nie można łączyć osobnych slotów statycznych i dynamicznych. Jeśli jeden slot staje się dynamiczny, wszystkie sloty na tym poziomie są traktowane jako dynamiczne.

Niezależne ładowanie i obsługa błędów w slotach

Każdy slot może mieć własny loading.tsx i error.tsx:

Code
app/dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/
│   ├── default.tsx
│   ├── page.tsx
│   ├── loading.tsx     ← szkielet tylko dla analytics
│   └── error.tsx       ← błąd nie przerywa działania reszty
├── @notifications/
│   ├── default.tsx
│   ├── loading.tsx
│   └── page.tsx

Jeśli slot @analytics ładuje dane z wolnego API, reszta panelu jest widoczna natychmiast. Jeśli ten slot wyrzuci błąd, error.tsx wyświetli się tylko w jego obszarze, bez wpływu na @notifications czy children.

Obowiązuje przy tym standardowa zasada Error Boundaries w App Routerze. error.tsx nie przechwytuje błędów z layout.tsx znajdującego się na tym samym poziomie, ponieważ granica błędu opakowuje dopiero jego zawartość potomną. Błąd takiego layoutu musi obsłużyć error.tsx w segmencie nadrzędnym.

Warunkowe renderowanie slotów w App Routerze

Parallel Routes doskonale nadają się do renderowania treści w zależności od stanu, na przykład roli użytkownika:

Code
// 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 (
    <div>
      <nav>{children}</nav>
      {isAdmin ? admin : user}
    </div>
  )
}

Ten warunek steruje widokiem, ale nie powinien być jedynym zabezpieczeniem dostępu. Layouty są zachowywane podczas nawigacji klientowej i nie muszą ponownie sprawdzać sesji przy każdej zmianie trasy. Autoryzację danych i operacji wykonuj także blisko źródła danych, a każdą Server Action i każdy Route Handler traktuj jak osobny punkt wejścia wymagający kontroli uprawnień.

Plik default.tsx jako wariant zapasowy dla niedopasowanych slotów

Po pełnym załadowaniu strony Next.js nie może odtworzyć zapamiętanego stanu slotu, jeśli ten nie pasuje do bieżącego URL-a. Do tego służy wariant zapasowy w pliku default.tsx:

Code
// app/dashboard/@notifications/default.tsx
export default function NotificationsDefault() {
  return <p>Brak nowych powiadomień.</p>
}

Od Next.js 16 każdy nazwany slot wymaga jawnego default.tsx, a jego brak zatrzymuje kompilację. children jest slotem domyślnym. Gdy również może pozostać bez dopasowanej strony po pełnym załadowaniu, dodaj default.tsx obok layoutu. Bez niego taka trasa zakończy się stroną 404.

Intercepting Routes w Next.js: modale z prawdziwym URL-em

Problem UX, który rozwiązują Intercepting Routes

Wyobraź sobie galerię zdjęć. Klikasz miniaturkę i otwiera się modal z powiększonym zdjęciem. URL w przeglądarce zmienia się na /photo/123. Gdy wkleisz ten URL bezpośrednio, widzisz pełną stronę zdjęcia, nie modal.

To zachowanie znane z Instagrama, Pinteresta czy Dribbble. Bez Intercepting Routes implementacja wymaga skomplikowanego zarządzania stanem i historią przeglądarki. Z nimi wynika z routingu.

Konwencja nazewnictwa Intercepting Routes

Intercepting Routes używają specjalnej notacji w nazwach katalogów:

NotacjaZnaczenie
(.)Przechwytuje trasę na tym samym poziomie
(..)Przechwytuje trasę jeden poziom wyżej
(..)(..)Przechwytuje trasę dwa poziomy wyżej
(...)Przechwytuje trasę od katalogu app/

Poziomy są liczone według segmentów URL, a nie katalogów w systemie plików. Katalogi nazwanych slotów, takie jak @modal, nie są segmentami i nie zwiększają liczby wymaganych (..). To dlatego @modal/(.)photo może przechwycić główną trasę /photo, mimo że w drzewie plików wygląda na głębiej zagnieżdżoną.

Przykład Intercepting Routes: galeria z modalem

Struktura plików:

Code
app/
├── layout.tsx
├── @modal/
│   ├── default.tsx           ← pusty wariant zapasowy
│   ├── (.)photo/[id]/
│   │   └── page.tsx          ← modal z przechwyconą trasą
│   └── [...catchAll]/
│       └── page.tsx          ← zamyka slot przy innej nawigacji
├── gallery/
│   └── page.tsx              ← lista zdjęć
└── photo/[id]/
    └── page.tsx              ← pełna strona zdjęcia po bezpośrednim wejściu

Layout z slotem na modal:

Code
// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <html lang="pl">
      <body>
        {children}
        {modal}
      </body>
    </html>
  )
}

Domyślny wariant zapasowy jest pusty, gdy modal nie jest aktywny:

Code
// app/@modal/default.tsx
export default function ModalDefault() {
  return null
}

Galeria musi otwierać zdjęcia przez Link. Dzięki temu Next.js wykonuje nawigację klientową i może przechwycić trasę:

Code
// app/gallery/page.tsx
import Image from 'next/image'
import Link from 'next/link'
 
const photoIds = ['1', '2', '3']
 
export default function GalleryPage() {
  return (
    <ul className="grid grid-cols-3 gap-4">
      {photoIds.map((id) => (
        <li key={id}>
          <Link href={`/photo/${id}`}>
            <Image
              src={`/photos/${id}.jpg`}
              alt={`Otwórz zdjęcie ${id}`}
              width={600}
              height={400}
            />
          </Link>
        </li>
      ))}
    </ul>
  )
}

Samą obsługę dialogu warto oddzielić od zawartości strony. Natywny element dialog zapewnia obsługę klawisza Escape, przenosi fokus do okna i oznacza tło jako nieaktywne:

Code
// app/ui/modal.tsx
'use client'
 
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
 
export function Modal({ children }: { children: React.ReactNode }) {
  const dialogRef = useRef<HTMLDialogElement>(null)
  const router = useRouter()
 
  useEffect(() => {
    dialogRef.current?.showModal()
  }, [])
 
  function closeModal() {
    dialogRef.current?.close()
  }
 
  return (
    <dialog
      ref={dialogRef}
      aria-label="Podgląd zdjęcia"
      className="m-auto max-w-3xl rounded-lg bg-white p-4 backdrop:bg-black/60"
      onClose={() => router.back()}
      onClick={(event) => {
        if (event.target === event.currentTarget) {
          closeModal()
        }
      }}
    >
      <button
        type="button"
        aria-label="Zamknij podgląd"
        onClick={closeModal}
        className="absolute top-2 right-2 text-gray-500"
      >

      </button>
      {children}
    </dialog>
  )
}

Przechwycona strona może pozostać Server Componentem. Tylko powłoka modala wymaga kodu klientowego:

Code
// app/@modal/(.)photo/[id]/page.tsx
import Image from 'next/image'
import { Modal } from '@/app/ui/modal'
 
export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
 
  return (
    <Modal>
      <Image
        src={`/photos/${id}.jpg`}
        alt={`Zdjęcie ${id}`}
        width={1200}
        height={800}
        sizes="(max-width: 768px) 100vw, 768px"
        className="h-auto max-h-[80vh] w-auto"
      />
    </Modal>
  )
}

Po nawigacji klientowej niedopasowany slot zachowuje ostatni aktywny widok. Trasa catch-all jawnie go czyści, gdy użytkownik przejdzie z otwartego modala pod inny adres:

Code
// app/@modal/[...catchAll]/page.tsx
export default function ModalCatchAll() {
  return null
}

Pełna strona zdjęcia po pełnym przeładowaniu lub wejściu z bezpośredniego URL:

Code
// app/photo/[id]/page.tsx
import type { Metadata } from 'next'
import Image from 'next/image'
 
export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>
}): Promise<Metadata> {
  const { id } = await params
 
  return {
    title: `Zdjęcie ${id} w galerii`,
    description: `Zobacz zdjęcie ${id} w pełnej rozdzielczości.`,
    alternates: {
      canonical: `https://example.com/photo/${id}`,
    },
  }
}
 
export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
 
  return (
    <main className="flex min-h-screen items-center justify-center bg-gray-100">
      <div className="max-w-4xl">
        <Image
          src={`/photos/${id}.jpg`}
          alt={`Zdjęcie ${id}`}
          width={1200}
          height={800}
          priority
          className="w-full rounded-lg shadow-lg"
        />
        <div className="mt-4">
          <h1 className="text-2xl font-bold">Zdjęcie {id}</h1>
          <p className="text-gray-600">
            Pełna strona zdjęcia z galerią i opisem.
          </p>
        </div>
      </div>
    </main>
  )
}

W rzeczywistej aplikacji obie wersje powinny pobierać ten sam rekord i korzystać ze wspólnego komponentu treści. Jeśli zdjęcie nie istnieje, wywołaj notFound() zarówno w pełnej stronie, jak i w przechwyconej wersji. Zapobiega to rozbieżnościom między modalem a adresem indeksowanym przez wyszukiwarkę. Domenę example.com w linku kanonicznym zastąp adresem produkcyjnym.

Jak Intercepting Routes działają w praktyce?

  1. Użytkownik jest na /gallery i klika zdjęcie opakowane w Link. Next.js przechwytuje nawigację do /photo/123 i renderuje wersję modalną z @modal/(.)photo/[id]/page.tsx.
  2. URL zmienia się na /photo/123, ale kontekst galerii pozostaje w tle.
  3. Zamknięcie dialogu wywołuje router.back() i przywraca galerię. Nawigacja do innej strony trafia do [...catchAll], który czyści slot.
  4. Bezpośrednie wejście na /photo/123 lub odświeżenie strony renderuje pełną stronę z photo/[id]/page.tsx.

Parallel Routes i Intercepting Routes jako duet w App Routerze

Oba te mechanizmy najczęściej wykorzystuje się w parze, gdzie modal oparty na interceptowanych ścieżkach jest w rzeczywistości nazwanym slotem działającym w ramach routingu równoległego. Pozwala to na przykład na zbudowanie panelu aplikacji z wieloma sekcjami, który jednocześnie potrafi płynnie przechwytywać nawigację do szczegółów. Załóżmy, że kliknięcie na wybrane zamówienie natychmiast otwiera modal ze szczegółowymi informacjami, pozwalając użytkownikowi na komfortową pracę bez opuszczania aktualnego widoku listy.

Typowe przypadki użycia Parallel Routes i Intercepting Routes

  • E-commerce z szybkim podglądem produktu w modalu i pełną stroną produktu po bezpośrednim wejściu

  • Panel aplikacji z niezależnymi sekcjami, osobnym stanem ładowania i granicą błędu

  • Galeria / portfolio z powiększonym podglądem i URL-em do udostępniania

  • Formularze krok po kroku, w których każdy krok ma własny URL, ale layout się nie przeładowuje

  • Porównywarka z dwoma slotami dla niezależnie wybieranych produktów

Najczęstsze problemy z Parallel Routes i Intercepting Routes

„Slot nie renderuje się po nawigacji”

Najpierw rozróżnij nawigację klientową od pełnego załadowania strony. Podczas nawigacji po stronie klienta Next.js zachowuje poprzedni aktywny widok dla niedopasowanego slotu, natomiast po odświeżeniu korzysta z pliku default.tsx. Warto pamiętać, że w Next.js brak tego pliku w nazwanym slocie skutkuje błędem kompilacji.

Dodaj trasę [...catchAll]/page.tsx zwracającą null wewnątrz slotu modala, ponieważ sam plik default.tsx nie zamknie aktywnego slotu podczas nawigacji klientowej.

„Przycisk wstecz nie zamyka modala”

Upewnij się, że otwarcie nastąpiło przez Link, modal jest renderowany w nazwanym slocie, a jego zamknięcie wywołuje router.back(). Wtedy historia zawiera stronę, nad którą otwarto modal, a przycisk Dalej może ponownie go wyświetlić.

„Odświeżenie strony pokazuje modal zamiast pełnej strony”

Konwencja kropki w nawiasie (.) musi ściśle odpowiadać położeniu docelowej trasy w strukturze segmentów URL, nie wliczając przy tym katalogu @modal, ponieważ nazwany slot nie stanowi fizycznego segmentu ścieżki. Warto pamiętać, że przy pełnym odświeżeniu strony przechwycona trasa przestaje działać ze względu na brak nawigacji po stronie klienta, przez co Next.js automatycznie renderuje oryginalny plik odpowiedzialny za widok szczegółów

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

Czy Parallel i Intercepting Routes działają tylko w App Router?

To są wyłączne funkcje App Routera w Next.js, których Pages Router po prostu nie obsługuje, co oznacza, że praca na starszej architekturze wymaga wcześniejszej migracji na nowy model.

Czy mogę zagnieżdżać nazwane sloty?

Tak. Slot może zawierać pełne drzewo routingu wraz z własnymi layoutami, plikami loading.tsx oraz stronami, dzięki czemu panel aplikacji zyskuje niezależną od reszty systemu wewnętrzną nawigację.

Ile slotów mogę mieć w jednym layoucie?

Nie ma oficjalnego limitu. W praktyce od 2 do 4 slotów to typowa konfiguracja. Pamiętaj, że każdy slot to dodatkowa równoległa ścieżka renderowania, więc przy wielu slotach pilnuj kosztu pobierania danych.

Czy Intercepting Routes szkodzą SEO?

Nie z definicji. Bezpośrednie żądanie adresu, na przykład przez wpisanie ścieżki numeru zdjęcia, renderuje pełną stronę zamiast modala. Taka podstrona nadal musi być w pełni indeksowalna, posiadać poprawne metadane oraz link kanoniczny, a także prezentować treść całkowicie zgodną z pierwotnym podglądem.

Dlaczego kompilacja zgłasza brak default.tsx w slocie?

Od Next.js 16 każdy nazwany slot wymaga jawnego pliku default.tsx. Framework używa go po pełnym załadowaniu strony, gdy nie może odtworzyć aktywnego stanu slotu. Bez tego pliku kompilacja kończy się błędem.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Pages Router do App Router: Migracja w Next.js

Masz działający projekt na Pages Router i chcesz wejść w Server Components, streaming albo Server Actions. Tylko że przepisanie wszystkiego naraz to proszenie się o regresję, więc tego lepiej nie robić. Rozwiązaniem jest Pages i App Router obok siebie w jednym repozytorium i przenoszenie strona po stronie, potem testy i bezpieczne deploye.

Maciej Sala

Maciej Sala

Founder StriveLab

E2E testy w Next.js App Router – kompletny setup Cypress + CI/CD

Od instalacji, przez fixtures i custom commands, po integrację z GitHub Actions. Kompletny przewodnik po konfiguracji testów E2E Cypress w projekcie Next.js z App Router.

Maciej Sala

Maciej Sala

Founder StriveLab

Parametry w URL a SEO: jak nie zduplikować treści w React, Next.js i Astro?

Parametry w URL mają, jak przysłowiowa moneta ma dwie strony. Z jednej, napędzają filtry, sortowanie, paginację i śledzenie kampanii, a z drugiej, jednocześnie potrafią cicho popsuć widoczność serwisu przez duplikacje treści, marnowany budżet indeksowania i rozwodnione link equity. W aplikacjach React, Next.js i Astro to problem architektoniczny i właśnie dlatego samo użycie rel="canonical" go nie rozwiązuje. W tym przewodniku przechodzę od klasyfikacji parametrów do decyzji o indeksacji, normalizacji URL-i, renderowania, paginacji oraz kontroli nawigacji fasetowej.

Maciej Sala

Maciej Sala

Founder StriveLab