Kurs Next.js · Moduł 9: Integracje i zaawansowane funkcje
Typed Routes - Bezpieczne linki z TypeScript
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
Link z walidacją
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> // OKTypeScript 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'); // ErrorFunkcja 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ę.
Breadcrumbs z Typed Routes
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> // OKSprawdzany 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 buildMoż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 devSprawdź 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 RouteNext.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 <Link href="{route.path}">
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. Typed Routes w Next.js zapewniają:
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż elementy w prawidłowej kolejności: export const → fetchCache → =