Kurs Next.js · Moduł 2: Routing i layouty
Zaawansowany routing w Next.js 16 - Parallel Routes, Intercepting Routes i Route Groups
W tej lekcji6
W Metropolii Quantum zaawansowany system transportowy pozwala na jednoczesne podróżowanie wieloma ścieżkami równolegle, przechwytywanie i przekierowywanie ruchu w locie oraz logiczne grupowanie tras bez wpływu na ich adresy. Podobnie Next.js 16 oferuje zaawansowane techniki routingu, które pozwalają na tworzenie złożonych interfejsów użytkownika z elegancją i efektywnością.
W tym rozdziale poznasz trzy potężne wzorce routingu w App Routerze:
- Parallel Routes - renderowanie wielu tras jednocześnie
- Intercepting Routes - przechwytywanie nawigacji i pokazywanie alternatywnych widoków
- Route Groups - organizowanie tras bez wpływu na URL
Parallel Routes - Równoległe renderowanie tras
Czym są Parallel Routes?
Parallel Routes pozwalają na jednoczesne renderowanie wielu stron (lub części stron) w tym samym layoucie. To jak holograficzne wyświetlacze w Metropolii Quantum, które mogą pokazywać wiele niezależnych strumieni informacji jednocześnie.
Konwencja nazewnictwa - @folder
Parallel Routes używają specjalnej konwencji nazewnictwa z prefiksem @:
app/
├── layout.tsx
├── page.tsx
├── @analytics/ # Slot "analytics"
│ ├── default.tsx
│ └── page.tsx
├── @team/ # Slot "team"
│ ├── default.tsx
│ └── page.tsx
└── @activity/ # Slot "activity"
├── default.tsx
└── page.tsxPodstawowy przykład - Dashboard z wieloma panelami
Wyobraź sobie panel kontrolny Metropolii Quantum, który jednocześnie wyświetla analitykę, aktywność zespołu i ostatnie wydarzenia:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
activity,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
activity: React.ReactNode;
}) {
return (
<div className="quantum-dashboard">
<div className="main-content">
{children}
</div>
<div className="grid grid-cols-3 gap-6 mt-6">
<div className="panel">
<h2>Analityka kwantowa</h2>
{analytics}
</div>
<div className="panel">
<h2>Zespół</h2>
{team}
</div>
<div className="panel">
<h2>Ostatnia aktywność</h2>
{activity}
</div>
</div>
</div>
);
}// app/dashboard/@analytics/page.tsx
async function getAnalytics() {
const res = await fetch('https://api.quantum.com/analytics', {
next: { revalidate: 60 }
});
return res.json();
}
export default async function Analytics() {
const data = await getAnalytics();
return (
<div className="analytics-panel">
<div className="stat">
<span className="label">Energia kwantowa</span>
<span className="value">{data.energy}%</span>
</div>
<div className="stat">
<span className="label">Wydajność systemu</span>
<span className="value">{data.efficiency}%</span>
</div>
</div>
);
}// app/dashboard/@team/page.tsx
async function getTeam() {
const res = await fetch('https://api.quantum.com/team', {
next: { revalidate: 300 }
});
return res.json();
}
export default async function Team() {
const members = await getTeam();
return (
<ul className="team-list">
{members.map(member => (
<li key={member.id} className="team-member">
<img src={member.avatar} alt={member.name} />
<span>{member.name}</span>
<span className="status">{member.status}</span>
</li>
))}
</ul>
);
}// app/dashboard/@activity/page.tsx
async function getActivity() {
const res = await fetch('https://api.quantum.com/activity', {
cache: 'no-store' // Zawsze świeże dane
});
return res.json();
}
export default async function Activity() {
const activities = await getActivity();
return (
<div className="activity-feed">
{activities.map(activity => (
<div key={activity.id} className="activity-item">
<span className="timestamp">{activity.time}</span>
<p>{activity.description}</p>
</div>
))}
</div>
);
}Zalety Parallel Routes
- Niezależne ładowanie - każdy slot może być ładowany niezależnie z własnym stanem ładowania
- Niezależne błędy - błąd w jednym slocie nie wpływa na inne
- Selektywna nawigacja - możesz nawigować w obrębie jednego slotu bez wpływu na inne
- Warunkowe renderowanie - możesz warunkowo pokazywać/ukrywać sloty
Default.tsx - Obsługa brakujących slotów
Gdy użytkownik nawiguje w sposób, który nie pasuje do struktury slotów (np. odświeża stronę pod adresem, którego slot nie obsługuje), Next.js używa pliku default.tsx. W Next.js 16 taki plik jest obowiązkowy w każdym slocie, bo bez niego next build kończy się błędem:
// app/dashboard/@analytics/default.tsx
export default function DefaultAnalytics() {
return <div>Brak danych analitycznych</div>;
}Warunkowe renderowanie slotów
// app/dashboard/layout.tsx
import { getCurrentUser } from '@/lib/auth';
export default async function DashboardLayout({
children,
analytics,
team,
activity,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
activity: React.ReactNode;
}) {
// Rola pochodzi z sesji: params zawiera tylko dynamiczne segmenty adresu
const user = await getCurrentUser();
const isAdmin = user?.role === 'admin';
return (
<div className="quantum-dashboard">
<div className="main-content">{children}</div>
<div className="panels">
{analytics}
{team}
{isAdmin && activity} {/* Tylko dla adminów */}
</div>
</div>
);
}Intercepting Routes - Przechwytywanie nawigacji
Czym są Intercepting Routes?
Intercepting Routes pozwalają na przechwytywanie nawigacji i wyświetlanie alternatywnego widoku, podczas gdy URL się zmienia. To jak kwantowe tunele w Metropolii Quantum - pozwalają na krótkie ścieżki bez zmiany docelowej lokalizacji.
Konwencja nazewnictwa
Intercepting Routes używają specjalnych prefiksów:
(.)- przechwytuje segmenty na tym samym poziomie(..)- przechwytuje segmenty jeden poziom wyżej(..)(..)- przechwytuje segmenty dwa poziomy wyżej(...)- przechwytuje segmenty z rootaapp/
Przykład 1: Modal z obrazkiem
Klasyczny przypadek użycia - galeria zdjęć, gdzie kliknięcie otwiera modal, ale URL się zmienia:
app/
├── layout.tsx
├── page.tsx # Galeria /
├── photos/
│ └── [id]/
│ └── page.tsx # Pełna strona zdjęcia /photos/123
└── @modal/
├── default.tsx # Zwraca null, gdy modal jest zamknięty (wymagany w Next.js 16)
└── (.)photos/
└── [id]/
└── page.tsx # Modal przechwytujący /photos/123@modal jest slotem, a nie segmentem adresu, więc zdjęcia z app/photos przechwytuje (.)photos. Zapis (..)photos na tym poziomie kończy next build błędem „Cannot use (..) marker at the root level, use (.) instead”.
// app/page.tsx - Galeria
import Link from 'next/link';
import Image from 'next/image';
const photos = [
{ id: '1', url: '/photos/quantum-city-1.jpg', title: 'Dystrykt Kwantowy' },
{ id: '2', url: '/photos/quantum-city-2.jpg', title: 'Centralna Plaza' },
{ id: '3', url: '/photos/quantum-city-3.jpg', title: 'Skyline nocą' },
];
export default function Gallery() {
return (
<div className="gallery-grid">
{photos.map(photo => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<div className="photo-card">
<Image
src={photo.url}
alt={photo.title}
width={300}
height={200}
/>
<p>{photo.title}</p>
</div>
</Link>
))}
</div>
);
}// app/photos/[id]/page.tsx - Pełna strona zdjęcia
import Image from 'next/image';
async function getPhoto(id: string) {
// Pobierz dane zdjęcia
return {
id,
url: `/photos/quantum-city-${id}.jpg`,
title: 'Quantum City',
description: 'Widok na Metropolię Quantum',
};
}
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const photo = await getPhoto((await params).id);
return (
<div className="photo-full-page">
<h1>{photo.title}</h1>
<Image
src={photo.url}
alt={photo.title}
width={1200}
height={800}
/>
<p>{photo.description}</p>
</div>
);
}// app/@modal/(.)photos/[id]/page.tsx - Modal
'use client';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { use, useEffect, useState } from 'react';
export default function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
const [photo, setPhoto] = useState(null);
useEffect(() => {
// Pobierz dane zdjęcia
fetch(`/api/photos/${id}`)
.then(res => res.json())
.then(setPhoto);
}, [id]);
if (!photo) return null;
return (
<div
className="modal-overlay"
onClick={() => router.back()}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<button
className="modal-close"
onClick={() => router.back()}
aria-label="Zamknij"
>
×
</button>
<Image
src={photo.url}
alt={photo.title}
width={800}
height={600}
/>
<h2>{photo.title}</h2>
<p>{photo.description}</p>
</div>
</div>
);
}// app/layout.tsx - Layout z modalem
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="pl">
<body>
{children}
{modal}
</body>
</html>
);
}Slot @modal potrzebuje też pliku default.tsx, który nic nie renderuje, gdy modal jest zamknięty:
// app/@modal/default.tsx
export default function Default() {
return null;
}Przykład 2: Login modal z intercepting route
app/
├── layout.tsx
├── page.tsx
├── login/
│ └── page.tsx # Pełna strona logowania /login
└── @auth/
├── default.tsx # Zwraca null, gdy modal jest zamknięty
└── (.)login/
└── page.tsx # Modal logowania// app/@auth/(.)login/page.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export default function LoginModal() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Logika logowania
await loginUser(email, password);
router.back();
};
return (
<div className="modal-overlay" onClick={() => router.back()}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Zaloguj się do Metropolii Quantum</h2>
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Hasło"
required
/>
<button type="submit">Zaloguj</button>
</form>
</div>
</div>
);
}Root layout musi też przyjąć prop auth i wyrenderować {auth}, tak jak {modal} w przykładzie 1.
Zalety Intercepting Routes
- Lepsze UX - szybkie modale bez pełnego przeładowania strony
- Adresy do udostępnienia - URL się zmienia, więc można udostępnić link
- Pełna strona po bezpośrednim wejściu - bezpośrednie wejście na URL pokazuje pełną stronę
- Zachowanie kontekstu - tło pozostaje widoczne pod modalem
Route Groups - Organizacja tras
Czym są Route Groups?
Route Groups pozwalają na logiczne grupowanie tras bez wpływu na strukturę URL. To jak dzielnice w Metropolii Quantum - organizują miasto, ale nie wpływają na adresy budynków.
Konwencja nazewnictwa - (folder)
Route Groups używają nawiasów okrągłych w nazwach folderów:
app/
├── (marketing)/ # Grupa - nie wpływa na URL
│ ├── layout.tsx # Layout dla marketingu
│ ├── about/
│ │ └── page.tsx # URL: /about
│ └── contact/
│ └── page.tsx # URL: /contact
├── (shop)/ # Grupa - nie wpływa na URL
│ ├── layout.tsx # Layout dla sklepu
│ ├── products/
│ │ └── page.tsx # URL: /products
│ └── cart/
│ └── page.tsx # URL: /cart
└── (dashboard)/ # Grupa - nie wpływa na URL
├── layout.tsx # Layout dla dashboardu
├── analytics/
│ └── page.tsx # URL: /analytics
└── settings/
└── page.tsx # URL: /settingsPrzykład 1: Różne layouty dla różnych sekcji
// app/(marketing)/layout.tsx
import MarketingHeader from '@/components/MarketingHeader';
import MarketingFooter from '@/components/MarketingFooter';
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="marketing-layout">
<MarketingHeader />
<main className="marketing-content">
{children}
</main>
<MarketingFooter />
</div>
);
}// app/(dashboard)/layout.tsx
import DashboardSidebar from '@/components/DashboardSidebar';
import DashboardHeader from '@/components/DashboardHeader';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard-layout">
<DashboardHeader />
<div className="dashboard-container">
<DashboardSidebar />
<main className="dashboard-content">
{children}
</main>
</div>
</div>
);
}Przykład 2: Organizacja według funkcjonalności
app/
├── (auth)/
│ ├── login/
│ │ └── page.tsx # /login
│ ├── register/
│ │ └── page.tsx # /register
│ └── forgot-password/
│ └── page.tsx # /forgot-password
├── (public)/
│ ├── about/
│ │ └── page.tsx # /about
│ └── blog/
│ └── [slug]/
│ └── page.tsx # /blog/:slug
└── (protected)/
├── layout.tsx # Wspólny layout grupy
├── dashboard/
│ └── page.tsx # /dashboard
└── profile/
└── page.tsx # /profileZwróć uwagę, że w grupie (protected) nie ma pliku proxy.ts: proxy (w Next.js 15 i starszych middleware.ts) może być tylko jedno, w katalogu głównym projektu. Te trasy chronisz więc matcherem w głównym proxy.ts oraz sprawdzaniem sesji blisko danych.
Przykład 3: Kilka głównych layoutów
Route Groups pozwalają na tworzenie wielu root layoutów w tej samej aplikacji:
app/
├── (shop)/
│ ├── layout.tsx # Root layout dla sklepu
│ └── products/
│ └── page.tsx
└── (blog)/
├── layout.tsx # Root layout dla bloga
└── posts/
└── page.tsx// app/(shop)/layout.tsx
export default function ShopRootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="pl">
<body className="shop-theme">
{children}
</body>
</html>
);
}// app/(blog)/layout.tsx
export default function BlogRootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="pl">
<body className="blog-theme">
{children}
</body>
</html>
);
}Zalety Route Groups
- Organizacja kodu - logiczne grupowanie bez wpływu na URL
- Różne layouty - możliwość stosowania różnych layoutów dla różnych sekcji
- Lepsza struktura projektu - łatwiejsze zarządzanie dużymi aplikacjami
- Separacja odpowiedzialności - różne zespoły mogą pracować nad różnymi grupami
Łączenie wszystkich trzech technik
Prawdziwa moc App Router objawia się, gdy łączysz te trzy techniki razem:
app/
├── (marketing)/
│ ├── layout.tsx
│ ├── page.tsx
│ └── @modal/
│ ├── default.tsx
│ └── (.)contact/
│ └── page.tsx
├── (dashboard)/
│ ├── layout.tsx
│ ├── @analytics/
│ │ ├── default.tsx
│ │ └── page.tsx
│ ├── @team/
│ │ ├── default.tsx
│ │ └── page.tsx
│ └── @activity/
│ ├── default.tsx
│ └── page.tsx
└── (shop)/
├── layout.tsx
├── products/
│ └── [id]/
│ └── page.tsx
└── @cart/
├── default.tsx
└── (.)products/
└── [id]/
└── page.tsxPrzykład: E-commerce z zaawansowanym routingiem
// app/(shop)/layout.tsx
export default function ShopLayout({
children,
cart,
}: {
children: React.ReactNode;
cart: React.ReactNode;
}) {
return (
<div className="shop-layout">
<ShopHeader />
<main>{children}</main>
{cart}
<ShopFooter />
</div>
);
}// app/(shop)/products/[id]/page.tsx
async function getProduct(id: string) {
const res = await fetch(`https://api.quantum.com/products/${id}`);
return res.json();
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const product = await getProduct((await params).id);
return (
<div className="product-page">
<h1>{product.name}</h1>
<p>{product.description}</p>
<button>Dodaj do koszyka</button>
</div>
);
}// app/(shop)/@cart/(.)products/[id]/page.tsx
'use client';
import { use } from 'react';
import { useRouter } from 'next/navigation';
export default function QuickViewModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
return (
<div className="modal-overlay" onClick={() => router.back()}>
<div className="quick-view" onClick={(e) => e.stopPropagation()}>
<ProductQuickView productId={id} />
<button onClick={() => router.back()}>Zamknij</button>
</div>
</div>
);
}Oba modale są komponentami klienckimi, więc nie mogą użyć await. Od Next.js 15 params jest obietnicą, a Next.js 16 nie pozwala już odczytać go synchronicznie, dlatego rozpakowujemy go hookiem use z Reacta.
Najlepsze praktyki
1. Używaj Parallel Routes do niezależnych sekcji
// Dobre - niezależne panele
app/dashboard/
├── @analytics/
├── @team/
└── @notifications/
// Złe - zależne komponenty
app/dashboard/
├── @header/ // To powinien być komponent, nie slot
├── @footer/ // To powinien być komponent, nie slot
└── @content/2. Używaj Intercepting Routes do modali i nakładek
// Dobre
app/
├── photos/[id]/page.tsx
└── @modal/(.)photos/[id]/page.tsx
// Złe - modal jako osobna trasa
app/
├── photos/[id]/page.tsx
└── modal/photos/[id]/page.tsx3. Używaj Route Groups do organizacji
// Dobre - logiczne grupowanie
app/
├── (marketing)/
├── (dashboard)/
└── (auth)/
// Złe - grupowanie według typu pliku
app/
├── (pages)/
├── (components)/
└── (api)/Podsumowanie
Zaawansowane techniki routingu w Next.js 16 to jak zaawansowana sieć transportowa Metropolii Quantum - pozwalają na tworzenie złożonych, wydajnych i eleganckich interfejsów użytkownika:
- Parallel Routes - renderuj wiele tras jednocześnie dla niezależnych sekcji
- Intercepting Routes - przechwytuj nawigację dla lepszego UX dzięki modalom i nakładkom
- Route Groups - organizuj kod bez wpływu na URL
Te techniki dają ci niesamowitą kontrolę nad strukturą aplikacji i doświadczeniem użytkownika. Podobnie jak inżynierowie Metropolii Quantum projektują wielopoziomowe systemy transportowe dla maksymalnej efektywności, tak i ty możesz projektować zaawansowane struktury routingu dla optymalnego UX.
Kluczem do sukcesu jest zrozumienie, kiedy używać każdej techniki i jak je łączyć, aby stworzyć spójną, wydajną aplikację Next.js 16.
Kod do tej lekcji: App.tsx
import React, { useState } from 'react';
// Symulacja app/layout.tsx ze slotami @feed i @stats oraz trasą przechwytującą @modal/(.)photos/[id]
const photos = [
{ id: '1', title: 'Dystrykt Kwantowy' },
{ id: '2', title: 'Centralna Plaza' },
{ id: '3', title: 'Skyline nocą' },
];
const panel = { flex: 1, padding: '12px', border: '1px solid #64ffda', borderRadius: '8px' };
const button = { margin: '4px', padding: '6px 10px', background: '#1a1a2e', color: '#64ffda', border: '1px solid #64ffda', borderRadius: '6px', cursor: 'pointer' };
export default function ParallelInterceptingDemo() {
const [url, setUrl] = useState('/');
const [modalId, setModalId] = useState<string | null>(null);
// Kliknięcie Link (nawigacja w aplikacji): trasę przechwytuje @modal/(.)photos/[id]
const openPhoto = (id: string) => { setUrl('/photos/' + id); setModalId(id); };
// Odświeżenie strony (F5): przechwytywania nie ma, renderuje się pełna strona app/photos/[id]/page.tsx
const refresh = () => setModalId(null);
// router.back(): powrót do galerii
const back = () => { setUrl('/'); setModalId(null); };
const fullPage = url !== '/' && modalId === null;
const photo = photos.find((p) => '/photos/' + p.id === url);
return (
<div style={{ fontFamily: 'monospace', background: '#0f0f23', color: '#e0e0e0', minHeight: '100vh', padding: '16px' }}>
<p>Adres: <strong>{url}</strong></p>
{fullPage && photo ? (
<div style={panel}>
<h2>Pełna strona: app/photos/[id]/page.tsx</h2>
<p>{photo.title}</p>
<button style={button} onClick={back}>Wróć do galerii</button>
</div>
) : (
<div style={{ display: 'flex', gap: '12px' }}>
<div style={panel}>
<h3>Slot @feed</h3>
{photos.map((p) => (
<button key={p.id} style={button} onClick={() => openPhoto(p.id)}>{p.title}</button>
))}
</div>
<div style={panel}>
<h3>Slot @stats</h3>
<p>Zdjęć w galerii: {photos.length}</p>
<p>Oba sloty renderują się jednocześnie w tym samym layoucie.</p>
</div>
</div>
)}
{modalId && photo && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ ...panel, flex: 'none', background: '#1a1a2e', maxWidth: '320px' }}>
<h3>Modal z @modal/(.)photos/[id]</h3>
<p>{photo.title}</p>
<p>Galeria została pod spodem, a adres zmienił się na {url}.</p>
<button style={button} onClick={back}>Zamknij (router.back)</button>
<button style={button} onClick={refresh}>Odśwież stronę (F5)</button>
</div>
</div>
)}
</div>
);
}Hasła z dokumentacji w tej lekcji
Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Do czego służy plik default.tsx w Parallel Routes?
2. Który zapis przechwytuje (intercepting) segment trasy o jeden poziom wyżej?
Zadania praktyczne w grze
- Edytor kodu
Zbuduj layout z dwoma równoległymi slotami @main i @sidebar: wyeksportuj domyślnie komponent DashboardLayout({ main, sidebar }), który wyświetla oba sloty obok siebie w jednym kontenerze z display: flex albo grid (np. div ze style={{ display: 'flex' }} albo z klasą flex).
- Edytor kodu
Połącz w jednym pliku app/products/[id]/page.tsx kilka funkcji App Routera, krok po kroku: 1) export async function generateMetadata({ params }) zwraca { title: 'Produkt ' + id }; 2) export default async function ProductPage({ params }) odczytuje id przez await params; 3) jeśli id nie składa się z samych cyfr, wywołaj notFound() z next/navigation; 4) w przeciwnym razie zwróć main z nagłówkiem h1 „Produkt {id}” i komponentem Link (next/link) prowadzącym do /products.