Kurs Next.js · Moduł 2: Routing i layouty

Zaawansowany routing w Next.js 16 - Parallel Routes, Intercepting Routes i Route Groups

12 min czytania ·
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:

  1. Parallel Routes - renderowanie wielu tras jednocześnie
  2. Intercepting Routes - przechwytywanie nawigacji i pokazywanie alternatywnych widoków
  3. 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.tsx

Podstawowy 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

  1. Niezależne ładowanie - każdy slot może być ładowany niezależnie z własnym stanem ładowania
  2. Niezależne błędy - błąd w jednym slocie nie wpływa na inne
  3. Selektywna nawigacja - możesz nawigować w obrębie jednego slotu bez wpływu na inne
  4. 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 roota app/

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

  1. Lepsze UX - szybkie modale bez pełnego przeładowania strony
  2. Adresy do udostępnienia - URL się zmienia, więc można udostępnić link
  3. Pełna strona po bezpośrednim wejściu - bezpośrednie wejście na URL pokazuje pełną stronę
  4. 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: /settings

Przykł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     # /profile

Zwróć 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

  1. Organizacja kodu - logiczne grupowanie bez wpływu na URL
  2. Różne layouty - możliwość stosowania różnych layoutów dla różnych sekcji
  3. Lepsza struktura projektu - łatwiejsze zarządzanie dużymi aplikacjami
  4. 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.tsx

Przykł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.tsx

3. 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:

  1. Parallel Routes - renderuj wiele tras jednocześnie dla niezależnych sekcji
  2. Intercepting Routes - przechwytuj nawigację dla lepszego UX dzięki modalom i nakładkom
  3. 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. 1. Do czego służy plik default.tsx w Parallel Routes?

  2. 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.

Przydatne artykuły