Cours Next.js · Module 9: Intégrations et fonctions avancées
Typed Routes - Des liens sûrs avec TypeScript
Dans cette leçon12
Dans les systèmes de navigation de la Métropole Quantum 2150, une seule mauvaise adresse envoie un transport vers un quai qui n'existe pas. Dans une application, cela semble anodin : une faute de frappe dans href, TypeScript ne dit rien, et après un clic l'utilisateur tombe sur une page 404. Matrix, le gardien des routes de la Métropole, exige donc que chaque chemin soit vérifié avant le départ. C'est ce que permettent les Typed Routes - une fonctionnalité de Next.js qui apporte la sécurité des types aux chemins de ton application.
Problème : les erreurs dans les chemins
Sans Typed Routes, les chemins sont de simples chaînes, donc le compilateur accepte n'importe quel texte :
1// Facile de faire une faute de frappe - aucune validation en TypeScript
2<Link href="/prodcuts">Products</Link> // Faute de frappe !
3<Link href="/users/123/setings">Settings</Link> // Encore une faute de frappe !
4
5// Aucune vérification des paramètres
6redirect('/products/' + productId); // Et si productId est undefined ?Les trois lignes compilent, et les erreurs n'apparaissent que chez l'utilisateur.
Solution : les Typed Routes
Les Typed Routes génèrent automatiquement des types pour tous les chemins de l'application à partir de la structure du dossier app.
Activer les Typed Routes
Depuis Next.js 15.5, l'option typedRoutes est stable et se définit au premier niveau de la configuration. Auparavant, elle se cachait dans l'objet experimental :
1// next.config.js
2module.exports = {
3 typedRoutes: true,
4};Après l'activation et le lancement de next dev, next build ou next typegen, Next.js génère dans le dossier .next/types un fichier contenant les définitions de toutes les routes. Le projet doit utiliser TypeScript, et tsconfig.json doit inclure .next/types/**/*.ts.
Utilisation de base
Link avec validation
Le composant Link de next/link n'accepte désormais dans href que des chemins existants :
1import Link from 'next/link';
2
3function Navigation() {
4 return (
5 <nav>
6 {/* Chemins corrects - TypeScript OK */}
7 <Link href="/">Home</Link>
8 <Link href="/products">Products</Link>
9 <Link href="/about">About</Link>
10
11 {/* Chemin invalide - Erreur TypeScript ! */}
12 <Link href="/prodcuts">Products</Link>
13 {/* Type '"prodcuts"' is not assignable to type 'Route' */}
14 </nav>
15 );
16}La faute /prodcuts devient une erreur de compilation, avec un message indiquant que le texte ne correspond pas au type Route. Les liens corrects fonctionnent exactement comme avant.
Segments dynamiques
Les segments dynamiques comme [id] acceptent n'importe quelle valeur à leur place, mais le reste du chemin doit correspondre :
1// Pour la structure : app/products/[id]/page.tsx
2<Link href="/products/123">Product 123</Link> // OK
3
4// Pour la structure : app/users/[userId]/posts/[postId]/page.tsx
5<Link href="/users/456/posts/789">Post</Link> // OKTypeScript vérifie la forme de l'adresse, pas l'existence du produit 123 dans la base de données.
useRouter avec des types
Dans l'App Router, les méthodes du routeur de next/navigation sont typées elles aussi : push, replace et 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 valide le chemin
10 router.push(`/products/${productId}/edit`);
11 };
12
13 const handleDelete = () => {
14 // Correct
15 router.push('/products');
16 };
17
18 const handleBroken = () => {
19 // Erreur TypeScript si /prodcts n'existe pas
20 router.push('/prodcts');
21 };
22
23 return (
24 <div>
25 <button onClick={handleEdit}>Modifier</button>
26 <button onClick={handleDelete}>Supprimer</button>
27 </div>
28 );
29}Le template /products/${productId}/edit est accepté car il correspond au motif de la route, tandis que /prodcts est rejeté.
redirect() avec des types
Il vaut la peine d'appliquer la même discipline aux chemins passés à redirect() :
1import { redirect } from 'next/navigation';
2
3async function ProtectedPage() {
4 const session = await getSession();
5
6 if (!session) {
7 // TypeScript valide le chemin
8 redirect('/login');
9 }
10
11 if (!session.isVerified) {
12 // Correct
13 redirect('/verify-email');
14 }
15
16 return <Dashboard />;
17}La documentation de Next.js cite explicitement le typage de Link et des méthodes du routeur, donc avec redirect() le choix le plus sûr est d'utiliser des constantes typées Route, que tu découvriras dans un instant.
L'assistant Route
Next.js génère un type Route, importé depuis le paquet next, que tu peux utiliser dans tes propres fonctions :
1import type { Route } from 'next';
2
3// Fonction n'acceptant que des chemins valides
4function navigateTo(path: Route) {
5 window.location.href = path;
6}
7
8navigateTo('/products'); // OK
9navigateTo('/prodcuts'); // ErreurLa fonction n'accepte que l'adresse d'une page existante, donc l'erreur apparaît dès l'appel.
Type pour les chemins dynamiques
Quand tu construis une adresse à partir d'une chaîne non littérale, TypeScript ne connaît pas sa valeur, donc tu as besoin de l'assertion as Route :
1import type { Route } from 'next';
2
3// Fonction générant un lien produit
4function getProductUrl(id: string): Route {
5 return `/products/${id}` as Route;
6}
7
8// Utilisation
9<Link href={getProductUrl('123')}>Product</Link>Une assertion est une promesse faite au compilateur, alors enferme-la dans une seule fonction utilitaire au lieu de la disperser dans toute l'application.
Travailler avec les paramètres de recherche
Les paramètres de requête ne font pas partie de la définition de la route, tu peux donc les ajouter sous forme d'objet ou dans la chaîne :
1import Link from 'next/link';
2
3function ProductFilters() {
4 return (
5 <div>
6 {/* Chemin avec query params */}
7 <Link
8 href={{
9 pathname: '/products',
10 query: { category: 'electronics', sort: 'price' },
11 }}
12 >
13 Électronique (tri par prix)
14 </Link>
15
16 {/* Ou sous forme de chaîne */}
17 <Link href="/products?category=electronics&sort=price">
18 Électronique
19 </Link>
20 </div>
21 );
22}Dans les deux cas, c'est le chemin /products qui est vérifié, tandis que les paramètres category et sort restent libres.
Exemple pratique - Navigation e-commerce
Dans une vraie application, tu gardes les liens au même endroit. L'interface NavigationItem exige que le champ href soit de type 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: 'Accueil', href: '/' },
12 { label: 'Produits', href: '/products' },
13 { label: 'Catégories', href: '/categories' },
14 { label: 'À propos', href: '/about' },
15 { label: 'Contact', href: '/contact' },
16];
17
18export const userNavigation: NavigationItem[] = [
19 { label: 'Profil', href: '/account/profile' },
20 { label: 'Commandes', href: '/account/orders' },
21 { label: 'Paramètres', href: '/account/settings' },
22];Supprimer la page /contact du dossier app signale immédiatement une erreur dans ce tableau.
Le composant de navigation se contente d'afficher ce tableau :
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 sait que c’est un chemin valide
12 className="hover:text-blue-500"
13 >
14 {item.label}
15 </Link>
16 ))}
17 </nav>
18 );
19}Le composant ne contient aucune adresse tapée à la main, donc il n'y a pas de place pour une faute de frappe.
Fil d'Ariane avec les Typed Routes
Le fil d'Ariane a un href optionnel, car le dernier élément est la page courante, qui n'est pas un lien :
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// Utilisation
34<Breadcrumbs
35 items={[
36 { label: 'Accueil', href: '/' },
37 { label: 'Produits', href: '/products' },
38 { label: 'Électronique', href: '/products?category=electronics' },
39 { label: 'Smartphone X' }, // Dernier élément sans lien
40 ]}
41/>Le type href?: Route permet d'omettre le lien, mais si tu en fournis un, il doit être valide.
Limites des Typed Routes
1. Segments dynamiques
TypeScript vérifie le motif de la route, mais pas la valeur insérée à la place du segment :
1// TypeScript ne peut pas valider la valeur d'un segment dynamique
2const id = getUserInput();
3<Link href={`/products/${id}`}>Product</Link> // Accepte n'importe quelle chaîne
4
5// Tu peux utiliser une assertion de type si tu es sûr
6<Link href={`/products/${id}` as Route}>Product</Link>Valide séparément les données de l'utilisateur, car le type ne te dira pas si un tel produit existe.
2. Liens externes
Les adresses extérieures à l'application ne sont pas de type Route :
1// Les URLs externes ne sont pas de type Route
2<Link href="https://google.com">Google</Link> // Erreur
3
4// Utilise une simple balise <a> pour les liens externes
5<a href="https://google.com">Google</a>Pour les liens externes, utilise une simple balise a, idéalement avec rel="noopener noreferrer" quand elle s'ouvre dans un nouvel onglet.
3. Routes catch-all
Un segment catch-all [...slug] accepte un nombre quelconque de parties après le préfixe :
1// Pour app/docs/[...slug]/page.tsx
2// TypeScript vérifie le préfixe mais pas le chemin complet
3<Link href="/docs/getting-started/installation">Docs</Link> // OKSeul le préfixe /docs/ est vérifié, et l'existence d'une page de documentation donnée ne se révèle qu'à l'exécution.
La configuration des segments est vérifiée aussi
Le plugin TypeScript intégré à Next.js surveille aussi les exports de configuration de segment. Un tel export se compose des mots export const, du nom de l'option, du signe = et de la valeur :
1// app/products/page.tsx
2export const fetchCache = 'force-cache';Le plugin t'avertit si tu tapes une valeur qui n'existe pas, comme 'force-cash'. Souviens-toi que dans Next.js 16, avec Cache Components activé, l'option fetchCache disparaît au profit de la directive 'use cache'.
Migrer un projet existant
Étape 1 : Activer typedRoutes
La migration commence par la même modification de configuration que dans un nouveau projet :
1// next.config.js
2module.exports = {
3 typedRoutes: true,
4};Rien ne casse encore après l'enregistrement du fichier, car les types des routes n'ont pas encore été générés.
Étape 2 : Lancer le build ou le dev
Les types sont créés au lancement du serveur de développement ou du build :
1npm run dev
2# ou
3npm run buildTu peux aussi utiliser next typegen, qui génère les types sans démarrer de serveur.
Étape 3 : Corriger les erreurs TypeScript
Vérifie maintenant les types dans tout le projet. Le script type-check correspond généralement à tsc --noEmit ajouté dans package.json :
1# Vérifie les erreurs
2npm run type-check
3
4# Problèmes courants :
5# - Fautes de frappe dans les chemins
6# - Pages inexistantes
7# - Liens externes dans <Link>Chaque erreur signalée est un vrai lien qui aurait mené nulle part.
Débogage
Si les types ne fonctionnent pas correctement, supprime les fichiers générés et laisse Next.js les recréer :
1# Supprime les types générés et régénère
2rm -rf .next/types
3npm run devVérifie le fichier généré dans le dossier .next/types. Dans les anciennes versions, il s'appelait link.d.ts, les versions récentes peuvent utiliser un autre nom :
1// .next/types/link.d.ts
2// Ce fichier contient tous les types Route générésNext.js y génère aussi les assistants globaux PageProps, LayoutProps et RouteContext, qui typent les paramètres des pages selon le chemin. Ne modifie pas ces fichiers à la main, car ils seront écrasés.
Résumé
Les Typed Routes dans Next.js apportent :
- Sécurité des types - les erreurs de chemin détectées à la compilation
- Autocomplétion - l'IDE suggère les chemins disponibles
- Refactoring - modifier la structure du routage révèle automatiquement les erreurs
- Documentation - les types documentent les chemins disponibles
Mon conseil : active typedRoutes dès le début d'un projet et garde tous les liens de navigation dans des tableaux typés Route. Dans la leçon suivante, tu découvriras next-intl, où les chemins reçoivent en plus un préfixe de langue.
Retiens : dans la Métropole Quantum, chaque trajet est vérifié avant le départ, et les Typed Routes transforment un lien cassé en erreur de compilation.
Code de cette leçon : App.tsx
1// Démo : Typed Routes - liens sûrs avec TypeScript
2// Le typedRoutes expérimental de Next.js garantit le type-safety pour Link et router
3import React, { useState } from 'react';
4
5// Configuration dans next.config.js :
6// /** @type {import('next').NextConfig} */
7// const nextConfig = {
8// experimental: {
9// typedRoutes: true,
10// },
11// };
12//
13// Une fois activé, Next.js génère les types dans .next/types :
14// - Le href de Link est vérifié par le type-checker
15// - router.push() requiert un chemin valide
16// - Erreurs de compilation pour les routes inexistantes
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: "Page d'accueil", valid: true, component: 'app/page.tsx' },
28 { path: '/products', description: 'Liste des produits', valid: true, component: 'app/products/page.tsx' },
29 { path: '/products/neural-link', description: 'Détail produit (slug)', valid: true, component: 'app/products/[slug]/page.tsx', params: { slug: 'neural-link' } },
30 { path: '/dashboard', description: 'Tableau de bord utilisateur', valid: true, component: 'app/dashboard/page.tsx' },
31 { path: '/dashboard/settings', description: 'Paramètres du tableau de bord', valid: true, component: 'app/dashboard/settings/page.tsx' },
32 { path: '/api/health', description: 'Endpoint de health check', valid: true, component: 'app/api/health/route.ts' },
33 { path: '/nonexistent', description: "Cette route n'existe pas !", valid: false, component: '???' },
34 { path: '/dashbord', description: 'Faute de frappe dans le nom de la route !', 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' : 'Erreur de type'}
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 Sans Typed Routes
92 </h4>
93 <pre style={{ margin: 0, color: '#b0bec5', fontSize: '0.75rem', lineHeight: 1.6 }}>
94{
95`// Aucune vérification à la compilation
96<Link href="/dashbord"> // Faute de frappe !
97<Link href="/old-page"> // N'existe pas !
98
99// Erreur découverte seulement à l'exécution
100// Page 404, mauvaise navigation...`}
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 Avec Typed Routes
109 </h4>
110 <pre style={{ margin: 0, color: '#b0bec5', fontSize: '0.75rem', lineHeight: 1.6 }}>
111{
112`// TypeScript vérifie à la compilation
113<Link href="/dashbord"> // Erreur TS !
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 Liens type-safe avec TypeScript - erreurs détectées à la compilation
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 }}>Toutes les 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 }}>Erreurs de type seulement</button>
166 </div>
167
168 {displayed.map(r => (
169 <TypedLinkExample key={r.path} route={r} />
170 ))}
171
172 <CodeComparison />
173 </div>
174 );
175}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Les Typed Routes dans Next.js apportent :
Tâches pratiques dans le jeu
- Tri horizontal
Remets les éléments dans le bon ordre : export const → fetchCache → =