Kurs Next.js · Moduł 9: Integracje i zaawansowane funkcje

Typed Routes - Bezpieczne linki z TypeScript

8 min czytania
W tej lekcji12

W systemach nawigacyjnych Metropolii Quantum 2150 jeden zły adres wysyła transport do nieistniejącego doku. W aplikacji wygląda to niewinnie: literówka w href, TypeScript milczy, a użytkownik po kliknięciu trafia na stronę 404. Matrix, strażnik tras Metropolii, każe więc sprawdzać każdą ścieżkę jeszcze przed startem. Umożliwia to Typed Routes - funkcja Next.js, która zapewnia type-safety dla ścieżek w aplikacji.

Problem: Błędy w ścieżkach

Bez Typed Routes ścieżki są zwykłymi stringami, więc kompilator przyjmie każdy tekst:

1// Łatwo o literówkę - brak walidacji w TypeScript
2<Link href="/prodcuts">Produkty</Link>  // Literówka!
3<Link href="/users/123/setings">Ustawienia</Link>  // Znowu literówka!
4
5// Brak sprawdzania parametrów
6redirect('/products/' + productId);  // Co jeśli productId jest undefined?

Wszystkie trzy linie się skompilują, a błędy wyjdą dopiero u użytkownika.

Rozwiązanie: Typed Routes

Typed Routes automatycznie generuje typy dla wszystkich ścieżek w aplikacji na podstawie struktury katalogu app.

Włączenie Typed Routes

Od Next.js 15.5 opcja typedRoutes jest stabilna i ustawiasz ją na najwyższym poziomie konfiguracji. Wcześniej była ukryta w obiekcie experimental:

1// next.config.js
2module.exports = {
3  typedRoutes: true,
4};

Po włączeniu i uruchomieniu next dev, next build lub next typegen Next.js wygeneruje w katalogu .next/types plik z definicjami wszystkich tras. Projekt musi używać TypeScriptu, a tsconfig.json musi obejmować .next/types/**/*.ts.

Podstawowe użycie

Komponent Link z next/link przyjmuje teraz w href tylko istniejące ścieżki:

1import Link from 'next/link';
2
3function Navigation() {
4  return (
5    <nav>
6      {/* Poprawne ścieżki - TypeScript OK */}
7      <Link href="/">Home</Link>
8      <Link href="/products">Produkty</Link>
9      <Link href="/about">O nas</Link>
10
11      {/* Błędna ścieżka - TypeScript Error! */}
12      <Link href="/prodcuts">Produkty</Link>
13      {/* Type '"prodcuts"' is not assignable to type 'Route' */}
14    </nav>
15  );
16}

Literówka /prodcuts staje się błędem kompilacji z komunikatem, że tekst nie pasuje do typu Route. Poprawne linki działają dokładnie tak jak wcześniej.

Dynamiczne segmenty

Segmenty dynamiczne, jak [id], przyjmują dowolną wartość w swoim miejscu, ale reszta ścieżki musi się zgadzać:

1// Dla struktury: app/products/[id]/page.tsx
2<Link href="/products/123">Produkt 123</Link>  // OK
3
4// Dla struktury: app/users/[userId]/posts/[postId]/page.tsx
5<Link href="/users/456/posts/789">Post</Link>  // OK

TypeScript sprawdza kształt adresu, a nie to, czy produkt 123 istnieje w bazie.

useRouter z typami

W App Routerze typowane są też metody routera z next/navigation: push, replace i prefetch:

1'use client';
2
3import { useRouter } from 'next/navigation';
4
5function ProductActions({ productId }: { productId: string }) {
6  const router = useRouter();
7
8  const handleEdit = () => {
9    // TypeScript sprawdza poprawność ścieżki
10    router.push(`/products/${productId}/edit`);
11  };
12
13  const handleDelete = () => {
14    // Poprawne
15    router.push('/products');
16  };
17
18  const handleBroken = () => {
19    // TypeScript Error jeśli /prodcts nie istnieje
20    router.push('/prodcts');
21  };
22
23  return (
24    <div>
25      <button onClick={handleEdit}>Edytuj</button>
26      <button onClick={handleDelete}>Usuń</button>
27    </div>
28  );
29}

Szablon /products/${productId}/edit jest akceptowany, bo pasuje do wzorca trasy, a /prodcts zostanie odrzucone.

redirect() z typami

Ścieżki przekazywane do redirect() warto trzymać w tej samej dyscyplinie:

1import { redirect } from 'next/navigation';
2
3async function ProtectedPage() {
4  const session = await getSession();
5
6  if (!session) {
7    // TypeScript sprawdza ścieżkę
8    redirect('/login');
9  }
10
11  if (!session.isVerified) {
12    // Poprawne
13    redirect('/verify-email');
14  }
15
16  return <Dashboard />;
17}

Dokumentacja Next.js wymienia wprost typowanie Link i metod routera, dlatego przy redirect() najbezpieczniej korzystać ze stałych typu Route, które poznasz za chwilę.

Pomocnik Route

Next.js generuje typ Route importowany z pakietu next, którego możesz używać we własnych funkcjach:

1import type { Route } from 'next';
2
3// Funkcja akceptująca tylko poprawne ścieżki
4function navigateTo(path: Route) {
5  window.location.href = path;
6}
7
8navigateTo('/products');  // OK
9navigateTo('/prodcuts');  // Error

Funkcja przyjmie tylko adres istniejącej strony, więc błąd pojawi się już przy wywołaniu.

Typ dla dynamicznych ścieżek

Gdy adres składasz z nieliteralnego stringa, TypeScript nie zna jego wartości, więc potrzebujesz asercji as Route:

1import type { Route } from 'next';
2
3// Funkcja generująca link do produktu
4function getProductUrl(id: string): Route {
5  return `/products/${id}` as Route;
6}
7
8// Użycie
9<Link href={getProductUrl('123')}>Produkt</Link>

Asercja to obietnica złożona kompilatorowi, dlatego zamknij ją w jednej funkcji pomocniczej, zamiast rozsiewać po całej aplikacji.

Praca z parametrami wyszukiwania

Parametry zapytania nie są częścią definicji trasy, więc możesz je dodać obiektem albo w stringu:

1import Link from 'next/link';
2
3function ProductFilters() {
4  return (
5    <div>
6      {/* Ścieżka z query params */}
7      <Link
8        href={{
9          pathname: '/products',
10          query: { category: 'electronics', sort: 'price' },
11        }}
12      >
13        Elektronika (sortuj po cenie)
14      </Link>
15
16      {/* Lub jako string */}
17      <Link href="/products?category=electronics&sort=price">
18        Elektronika
19      </Link>
20    </div>
21  );
22}

W obu przypadkach sprawdzana jest ścieżka /products, a parametry category i sort pozostają dowolne.

Praktyczny przykład - Nawigacja E-commerce

W prawdziwej aplikacji linki trzymasz w jednym miejscu. Interfejs NavigationItem wymaga, żeby pole href było typu Route:

1// types/navigation.ts
2import type { Route } from 'next';
3
4export interface NavigationItem {
5  label: string;
6  href: Route;
7  icon?: React.ReactNode;
8}
9
10export const mainNavigation: NavigationItem[] = [
11  { label: 'Home', href: '/' },
12  { label: 'Produkty', href: '/products' },
13  { label: 'Kategorie', href: '/categories' },
14  { label: 'O nas', href: '/about' },
15  { label: 'Kontakt', href: '/contact' },
16];
17
18export const userNavigation: NavigationItem[] = [
19  { label: 'Profil', href: '/account/profile' },
20  { label: 'Zamówienia', href: '/account/orders' },
21  { label: 'Ustawienia', href: '/account/settings' },
22];

Usunięcie strony /contact z katalogu app od razu podkreśli błąd w tej tablicy.

Komponent nawigacji tylko renderuje tę tablicę:

1// components/MainNav.tsx
2import Link from 'next/link';
3import { mainNavigation } from '@/types/navigation';
4
5export function MainNav() {
6  return (
7    <nav className="flex gap-6">
8      {mainNavigation.map((item) => (
9        <Link
10          key={item.href}
11          href={item.href}  // TypeScript wie, że to poprawna ścieżka
12          className="hover:text-blue-500"
13        >
14          {item.label}
15        </Link>
16      ))}
17    </nav>
18  );
19}

Komponent nie zawiera żadnego adresu wpisanego ręcznie, więc nie ma w nim miejsca na literówkę.

Okruszki chleba mają opcjonalne href, bo ostatni element to bieżąca strona, która nie jest linkiem:

1// components/Breadcrumbs.tsx
2import Link from 'next/link';
3import type { Route } from 'next';
4
5interface BreadcrumbItem {
6  label: string;
7  href?: Route;
8}
9
10interface BreadcrumbsProps {
11  items: BreadcrumbItem[];
12}
13
14export function Breadcrumbs({ items }: BreadcrumbsProps) {
15  return (
16    <nav aria-label="Breadcrumb" className="flex items-center gap-2 text-sm">
17      {items.map((item, index) => (
18        <span key={index} className="flex items-center gap-2">
19          {index > 0 && <span className="text-gray-400">/</span>}
20          {item.href ? (
21            <Link href={item.href} className="text-blue-600 hover:underline">
22              {item.label}
23            </Link>
24          ) : (
25            <span className="text-gray-600">{item.label}</span>
26          )}
27        </span>
28      ))}
29    </nav>
30  );
31}
32
33// Użycie
34<Breadcrumbs
35  items={[
36    { label: 'Home', href: '/' },
37    { label: 'Produkty', href: '/products' },
38    { label: 'Elektronika', href: '/products?category=electronics' },
39    { label: 'Smartphone X' },  // Ostatni element bez linku
40  ]}
41/>

Typ href?: Route pozwala pominąć link, ale jeśli już go podasz, musi być poprawny.

Ograniczenia Typed Routes

1. Dynamiczne segmenty

TypeScript sprawdza wzorzec trasy, ale nie wartość wstawianą w miejsce segmentu:

1// TypeScript nie może sprawdzić wartości dynamicznego segmentu
2const id = getUserInput();
3<Link href={`/products/${id}`}>Produkt</Link>  // Przyjmie każdy string
4
5// Możesz użyć asercji typu jeśli jesteś pewien
6<Link href={`/products/${id}` as Route}>Produkt</Link>

Dane od użytkownika waliduj osobno, bo typ nie powie ci, czy taki produkt istnieje.

2. Zewnętrzne linki

Adresy spoza aplikacji nie są typu Route:

1// Zewnętrzne URL nie są Route
2<Link href="https://google.com">Google</Link>  // Error
3
4// Użyj zwykłego <a> dla zewnętrznych linków
5<a href="https://google.com">Google</a>

Dla linków zewnętrznych używaj zwykłego znacznika a, najlepiej z rel="noopener noreferrer" przy otwieraniu w nowej karcie.

3. Catch-all routes

Segment catch-all [...slug] przyjmuje dowolną liczbę członów po prefiksie:

1// Dla app/docs/[...slug]/page.tsx
2// TypeScript sprawdzi prefix, ale nie całą ścieżkę
3<Link href="/docs/getting-started/installation">Docs</Link>  // OK

Sprawdzany jest tylko prefiks /docs/, a to, czy dana podstrona dokumentacji istnieje, wychodzi dopiero w trakcie działania.

Konfiguracja segmentów też jest sprawdzana

Wbudowany plugin TypeScript Next.js pilnuje także eksportów konfiguracji segmentu. Taki eksport składa się ze słów export const, nazwy opcji, znaku = i wartości:

1// app/products/page.tsx
2export const fetchCache = 'force-cache';

Plugin ostrzeże, jeśli wpiszesz nieistniejącą wartość, np. 'force-cash'. Pamiętaj, że w Next.js 16 z włączonym Cache Components opcja fetchCache znika na rzecz dyrektywy 'use cache'.

Migracja istniejącego projektu

Krok 1: Włącz typedRoutes

Migracja zaczyna się od tej samej zmiany w konfiguracji co w nowym projekcie:

1// next.config.js
2module.exports = {
3  typedRoutes: true,
4};

Po zapisaniu pliku nic się jeszcze nie psuje, bo typy tras nie są jeszcze wygenerowane.

Krok 2: Uruchom build lub dev

Typy powstają przy uruchomieniu serwera deweloperskiego lub budowania:

1npm run dev
2# lub
3npm run build

Możesz też użyć next typegen, które generuje typy bez uruchamiania serwera.

Krok 3: Napraw błędy TypeScript

Teraz sprawdź typy w całym projekcie. Skrypt type-check to zwykle tsc --noEmit dodany w package.json:

1# Sprawdź błędy
2npm run type-check
3
4# Typowe problemy:
5# - Literówki w ścieżkach
6# - Nieistniejące strony
7# - Zewnętrzne linki w <Link>

Każdy zgłoszony błąd to realny link, który prowadziłby donikąd.

Debugowanie

Jeśli typy nie działają poprawnie, usuń wygenerowane pliki i pozwól Next.js utworzyć je od nowa:

1# Usuń wygenerowane typy i wygeneruj ponownie
2rm -rf .next/types
3npm run dev

Sprawdź wygenerowany plik w katalogu .next/types. W starszych wersjach nazywał się link.d.ts, nowsze wydania mogą używać innej nazwy:

1// .next/types/link.d.ts
2// Ten plik zawiera wszystkie wygenerowane typy Route

Next.js generuje tam też globalne pomocniki PageProps, LayoutProps i RouteContext, które typują parametry stron według ścieżki. Nie edytuj tych plików ręcznie, bo zostaną nadpisane.

Podsumowanie

Typed Routes w Next.js zapewniają:

  • Bezpieczeństwo typów - błędy w ścieżkach wykryte w compile-time
  • Autocomplete - IDE podpowiada dostępne ścieżki
  • Refactoring - zmiana struktury routingu automatycznie pokazuje błędy
  • Dokumentacja - typy służą jako dokumentacja dostępnych ścieżek

Moja rada: włącz typedRoutes na starcie projektu i trzymaj wszystkie linki nawigacji w tablicach typu Route. W następnej lekcji poznasz next-intl, gdzie ścieżki dostaną jeszcze prefiks języka.

Zapamiętaj: w Metropolii Quantum każdy kurs jest sprawdzany przed startem, a Typed Routes zamieniają zepsuty link w błąd kompilacji.

Kod do tej lekcji: App.tsx
1// Demo: Typed Routes - bezpieczne linki z TypeScript
2// Next.js experimental typedRoutes zapewnia type-safety dla Link i router
3import React, { useState } from 'react';
4
5// Konfiguracja w next.config.js:
6// /** @type {import('next').NextConfig} */
7// const nextConfig = {
8//   experimental: {
9//     typedRoutes: true,
10//   },
11// };
12//
13// Po włączeniu Next.js generuje typy w .next/types:
14// - Link href jest type-checked
15// - router.push() wymaga prawidłowej ścieżki
16// - Błędy kompilacji przy nieistniejących trasach
17
18interface Route {
19  path: string;
20  params?: Record<string, string>;
21  description: string;
22  valid: boolean;
23  component: string;
24}
25
26const routes: Route[] = [
27  { path: '/', description: 'Home page', valid: true, component: 'app/page.tsx' },
28  { path: '/products', description: 'Products listing', valid: true, component: 'app/products/page.tsx' },
29  { path: '/products/neural-link', description: 'Product detail (slug)', valid: true, component: 'app/products/[slug]/page.tsx', params: { slug: 'neural-link' } },
30  { path: '/dashboard', description: 'User dashboard', valid: true, component: 'app/dashboard/page.tsx' },
31  { path: '/dashboard/settings', description: 'Dashboard settings', valid: true, component: 'app/dashboard/settings/page.tsx' },
32  { path: '/api/health', description: 'Health check endpoint', valid: true, component: 'app/api/health/route.ts' },
33  { path: '/nonexistent', description: 'This route does not exist!', valid: false, component: '???' },
34  { path: '/dashbord', description: 'Typo in route name!', valid: false, component: '???' },
35];
36
37function TypedLinkExample({ route }: { route: Route }) {
38  return (
39    <div style={{
40      background: route.valid ? 'rgba(100,255,218,0.05)' : 'rgba(244,67,54,0.05)',
41      border: `1px solid ${route.valid ? 'rgba(100,255,218,0.15)' : 'rgba(244,67,54,0.2)'}`,
42      borderRadius: '8px', padding: '14px', marginBottom: '8px',
43    }}>
44      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
45        <div>
46          <code style={{
47            color: route.valid ? '#64ffda' : '#f44336',
48            fontSize: '0.9rem',
49          }}>
50            &lt;Link href="{route.path}"&gt;
51          </code>
52          {route.params && (
53            <span style={{ color: '#ff9800', fontSize: '0.75rem', marginLeft: '8px' }}>
54              params: {JSON.stringify(route.params)}
55            </span>
56          )}
57        </div>
58        <span style={{
59          background: route.valid ? 'rgba(76,175,80,0.15)' : 'rgba(244,67,54,0.15)',
60          color: route.valid ? '#4caf50' : '#f44336',
61          padding: '2px 10px', borderRadius: '10px', fontSize: '0.7rem',
62        }}>
63          {route.valid ? 'Type OK' : 'Type Error'}
64        </span>
65      </div>
66      <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '6px' }}>
67        <span style={{ color: '#78909c', fontSize: '0.8rem' }}>{route.description}</span>
68        <code style={{ color: '#78909c', fontSize: '0.7rem' }}>{route.component}</code>
69      </div>
70      {!route.valid && (
71        <div style={{
72          marginTop: '8px', padding: '8px',
73          background: 'rgba(244,67,54,0.08)', borderRadius: '4px',
74          fontFamily: 'monospace', fontSize: '0.75rem', color: '#f44336',
75        }}>
76          Type error: Type '"{route.path}"' is not assignable to type 'Route'.
77        </div>
78      )}
79    </div>
80  );
81}
82
83function CodeComparison() {
84  return (
85    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '20px' }}>
86      <div style={{
87        background: 'rgba(244,67,54,0.05)', border: '1px solid rgba(244,67,54,0.15)',
88        borderRadius: '10px', padding: '16px',
89      }}>
90        <h4 style={{ color: '#f44336', marginBottom: '8px', fontSize: '0.85rem' }}>
91          Bez Typed Routes
92        </h4>
93        <pre style={{ margin: 0, color: '#b0bec5', fontSize: '0.75rem', lineHeight: 1.6 }}>
94{
95`// Brak weryfikacji w compile-time
96<Link href="/dashbord">  // Typo!
97<Link href="/old-page">  // Nie istnieje!
98
99// Błąd odkryty dopiero w runtime
100// 404 page, zła nawigacja...`}
101        </pre>
102      </div>
103      <div style={{
104        background: 'rgba(76,175,80,0.05)', border: '1px solid rgba(76,175,80,0.15)',
105        borderRadius: '10px', padding: '16px',
106      }}>
107        <h4 style={{ color: '#4caf50', marginBottom: '8px', fontSize: '0.85rem' }}>
108          Z Typed Routes
109        </h4>
110        <pre style={{ margin: 0, color: '#b0bec5', fontSize: '0.75rem', lineHeight: 1.6 }}>
111{
112`// TypeScript sprawdza w compile-time
113<Link href="/dashbord">  // TS Error!
114// Type '"/dashbord"' is not
115// assignable to type 'Route'
116
117<Link href="/dashboard"> // OK!
118router.push("/products") // OK!`}
119        </pre>
120      </div>
121    </div>
122  );
123}
124
125export default function TypedRoutesDemo() {
126  const [showAll, setShowAll] = useState(true);
127
128  const displayed = showAll ? routes : routes.filter(r => !r.valid);
129
130  return (
131    <div style={{
132      background: '#0f0f23', minHeight: '100vh', padding: '24px',
133      color: '#fff', fontFamily: 'system-ui, sans-serif',
134    }}>
135      <h1 style={{ color: '#64ffda', marginBottom: '4px' }}>Typed Routes</h1>
136      <p style={{ color: '#b0bec5', marginBottom: '20px' }}>
137        Bezpieczne linki z TypeScript - błędy wychwycone w compile-time
138      </p>
139
140      <div style={{
141        background: 'rgba(0,0,0,0.3)', borderRadius: '8px',
142        padding: '12px', marginBottom: '20px',
143      }}>
144        <code style={{ color: '#78909c', fontSize: '0.8rem' }}>
145          // next.config.js
146        </code>
147        <br />
148        <code style={{ color: '#64ffda', fontSize: '0.8rem' }}>
149          experimental: {'{'} typedRoutes: true {'}'}
150        </code>
151      </div>
152
153      <div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
154        <button onClick={() => setShowAll(true)} style={{
155          background: showAll ? '#64ffda' : 'transparent',
156          color: showAll ? '#0f0f23' : '#b0bec5',
157          border: '1px solid rgba(100,255,218,0.3)',
158          padding: '6px 14px', borderRadius: '20px', cursor: 'pointer',
159        }}>All Routes</button>
160        <button onClick={() => setShowAll(false)} style={{
161          background: !showAll ? '#f44336' : 'transparent',
162          color: !showAll ? '#fff' : '#b0bec5',
163          border: '1px solid rgba(255,255,255,0.1)',
164          padding: '6px 14px', borderRadius: '20px', cursor: 'pointer',
165        }}>Type Errors Only</button>
166      </div>
167
168      {displayed.map(r => (
169        <TypedLinkExample key={r.path} route={r} />
170      ))}
171
172      <CodeComparison />
173    </div>
174  );
175}

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. Typed Routes w Next.js zapewniają:

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż elementy w prawidłowej kolejności: export const → fetchCache → =

Przydatne artykuły