Cours Next.js · Module 2: Routing et layouts
Navigation avec le composant Link et useRouter
Dans cette leçon6
À Metropolis Quantum, le système de transport permet aux habitants de se déplacer en douceur entre les différents districts et installations. Ces véhicules avancés, appelés Q-Links, peuvent transporter instantanément les passagers vers presque n'importe quel endroit de la ville, sans avoir à recharger toute l'infrastructure de transport.
Dans le monde de Next.js 16, le composant Link et le hook useRouter jouent un rôle similaire. Ces outils permettent de créer une navigation fluide et efficace entre les différentes parties de l'application, sans avoir à recharger toute la page.
Link - le composant de transport le plus important
Le composant Link est l'élément de navigation fondamental de Next.js 16, fonctionnant comme un Q-Link à Metropolis Quantum - il emmène l'utilisateur vers une destination donnée sans délais ni perturbations inutiles.
Utilisation de base du composant Link
1import Link from 'next/link';
2
3export default function Navigation() {
4 return (
5 <nav className="flex space-x-6 py-4">
6 <Link
7 href="/"
8 className="text-indigo-600 hover:text-indigo-800 transition-colors"
9 >
10 Accueil
11 </Link>
12 <Link
13 href="/destinations"
14 className="text-indigo-600 hover:text-indigo-800 transition-colors"
15 >
16 Destinations
17 </Link>
18 <Link
19 href="/flights"
20 className="text-indigo-600 hover:text-indigo-800 transition-colors"
21 >
22 Vols
23 </Link>
24 <Link
25 href="/about"
26 className="text-indigo-600 hover:text-indigo-800 transition-colors"
27 >
28 À propos
29 </Link>
30 </nav>
31 );
32}Ce code simple crée une barre de navigation de base qui permet aux utilisateurs de se déplacer entre les différentes sections de l'application. Remarque que le composant Link accepte des propriétés comme href (la cible de navigation) et qu'on peut lui passer des attributs HTML classiques comme className pour le style.
Principaux avantages du composant Link par rapport à la balise <a> classique
Tout comme les Q-Links à Metropolis Quantum, qui offrent bien plus que les véhicules traditionnels, le composant Link de Next.js 16 surpasse la balise HTML <a> classique à bien des égards :
- Prefetching - charge automatiquement la page cible en arrière-plan lorsque le lien est visible dans la fenêtre du navigateur, ce qui accélère drastiquement la navigation.
- Navigation côté client - les transitions entre les pages se font sans rechargement complet, en ne mettant à jour que le nécessaire.
- Optimisation automatique - les composants
Linksont automatiquement optimisés pour l'application App Router, en tenant compte de fonctionnalités comme le prefetching des routes dynamiques. - Gestion du focus - après la navigation, le focus est correctement transféré, ce qui améliore l'accessibilité de l'application.
Options avancées de Link
Le composant Link offre plusieurs options avancées qui enrichissent ses fonctionnalités :
1. Routes dynamiques
1// Composant carte de destination
2function DestinationCard({ destination }: { destination: { id: string; name: string; shortDescription: string } }) {
3 return (
4 <Link
5 href={`/destinations/${destination.id}`}
6 className="block p-4 border rounded-lg hover:shadow-md transition-shadow"
7 >
8 <h2 className="text-xl font-semibold">{destination.name}</h2>
9 <p className="text-gray-600">{destination.shortDescription}</p>
10 </Link>
11 );
12}2. Passer des paramètres de requête
1// Composant filtre de recherche
2function SearchFilter({ currentCategory, currentSort }: { currentCategory: string; currentSort: string }) {
3 return (
4 <div className="flex space-x-4">
5 <Link
6 href={{
7 pathname: '/search',
8 query: { category: 'planets', sort: currentSort },
9 }}
10 className={`px-3 py-1 rounded ${currentCategory === 'planets' ? 'bg-indigo-600 text-white' : 'bg-gray-200'}`}
11 >
12 Planètes
13 </Link>
14 <Link
15 href={{
16 pathname: '/search',
17 query: { category: 'moons', sort: currentSort },
18 }}
19 className={`px-3 py-1 rounded ${currentCategory === 'moons' ? 'bg-indigo-600 text-white' : 'bg-gray-200'}`}
20 >
21 Lunes
22 </Link>
23 <Link
24 href={{
25 pathname: '/search',
26 query: { category: 'stars', sort: currentSort },
27 }}
28 className={`px-3 py-1 rounded ${currentCategory === 'stars' ? 'bg-indigo-600 text-white' : 'bg-gray-200'}`}
29 >
30 Étoiles
31 </Link>
32 </div>
33 );
34}3. Remplacer le comportement par défaut
1// Composant bouton « Remonter en haut »
2function ScrollToTopButton() {
3 const handleClick = (e) => {
4 e.preventDefault();
5 window.scrollTo({ top: 0, behavior: 'smooth' });
6 };
7
8 return (
9 <Link
10 href="#top"
11 onClick={handleClick}
12 className="fixed bottom-4 right-4 p-2 bg-indigo-600 text-white rounded-full"
13 aria-label="Remonter en haut"
14 >
15 <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
16 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
17 </svg>
18 </Link>
19 );
20}4. Gestion du scroll et du focus
1<Link
2 href="/destinations"
3 scroll={false} // Désactive le défilement automatique vers le haut de la page
4>
5 Destinations (sans défilement)
6</Link>5. Prefetch
1<Link
2 href="/rarely-visited-page"
3 prefetch={false} // Désactive le prefetching automatique
4>
5 Page rarement visitée
6</Link>Par défaut, Link précharge automatiquement les pages visibles dans la fenêtre d'affichage. Tu peux contrôler cela avec la propriété prefetch.
Utiliser les balises <a> avec le composant Link
Depuis Next.js 13, le composant Link rend lui-même la balise <a> et lui transmet les attributs appropriés : dans Next.js 16, tu n'as donc pas besoin d'imbriquer une balise <a> supplémentaire à l'intérieur du composant Link. Cette amélioration permet un code plus concis :
1// Utilisation correcte dans Next.js 16
2<Link
3 href="/destinations"
4 className="text-blue-600 hover:underline"
5 target="_blank"
6 rel="noopener noreferrer"
7>
8 Voir les destinations
9</Link>useRouter - navigation programmatique
Alors que le composant Link est idéal pour la navigation déclarative (visible dans l'UI), il arrive qu'on ait besoin de naviguer de façon programmatique - à l'image de Metropolis Quantum, où l'intelligence artificielle centrale peut diriger les Q-Links vers de nouveaux emplacements en fonction de diverses conditions ou événements.
Dans Next.js 16, le hook useRouter du paquet next/navigation permet exactement ce type de navigation dynamique.
Utilisation de base de useRouter
1'use client';
2
3import { useRouter } from 'next/navigation';
4
5export default function BookingForm() {
6 const router = useRouter();
7
8 const handleSubmit = async (event) => {
9 event.preventDefault();
10
11 // Logique de traitement du formulaire...
12 const bookingSuccess = await submitBooking(formData);
13
14 if (bookingSuccess) {
15 // Après une soumission réussie, redirige l'utilisateur
16 router.push('/booking/confirmation');
17 }
18 };
19
20 return (
21 <form onSubmit={handleSubmit} className="space-y-6">
22 {/* Champs du formulaire... */}
23 <button
24 type="submit"
25 className="w-full py-2 px-4 bg-indigo-600 text-white rounded"
26 >
27 Réserver un voyage
28 </button>
29 </form>
30 );
31}Remarque : N'oublie pas que useRouter est un hook React, il ne peut donc être utilisé que dans les composants Client (marqués avec 'use client';).
Méthodes du hook useRouter
Le hook useRouter fournit plusieurs méthodes utiles :
1. router.push(url)
Navigue vers l'URL donnée, en ajoutant une nouvelle entrée à l'historique du navigateur :
1const router = useRouter();
2
3function navigateToHome() {
4 router.push('/');
5}
6
7function navigateToDestination(destinationId) {
8 router.push(`/destinations/${destinationId}`);
9}
10
11function navigateWithQueryParams() {
12 router.push('/search?category=planets&sort=distance');
13}2. router.replace(url)
Similaire à push, mais remplace l'entrée actuelle de l'historique au lieu d'en ajouter une nouvelle :
1function redirectToLogin() {
2 router.replace('/login'); // L'utilisateur ne pourra pas revenir à la page précédente avec le bouton « Retour »
3}3. router.refresh()
Rafraîchit la page actuelle, en récupérant à nouveau les données tout en préservant l'état du DOM :
1function refreshData() {
2 router.refresh(); // Récupère à nouveau les données du serveur sans rechargement complet de la page
3}4. router.back()
Navigue vers la page précédente dans l'historique du navigateur :
1function goBack() {
2 router.back();
3}5. router.forward()
Navigue vers la page suivante dans l'historique du navigateur :
1function goForward() {
2 router.forward();
3}6. router.prefetch(href)
Précharge l'URL donnée :
1function prefetchImportantPage() {
2 router.prefetch('/important-page');
3}Exemples pratiques d'utilisation de useRouter
Exemple 1 : Assistant de navigation
1'use client';
2
3import { useState, useEffect } from 'react';
4import { useRouter } from 'next/navigation';
5
6export default function NavigationAssistant() {
7 const router = useRouter();
8 const [destination, setDestination] = useState('');
9 const [suggestions, setSuggestions] = useState([]);
10
11 useEffect(() => {
12 if (destination.length < 2) {
13 setSuggestions([]);
14 return;
15 }
16
17 // Simulation de récupération des suggestions depuis l'API
18 const fetchSuggestions = async () => {
19 // Dans une vraie application, on récupérerait les données depuis l'API
20 const mockSuggestions = [
21 { id: 'mars', name: 'Mars', type: 'planet' },
22 { id: 'mars-colony-alpha', name: 'Mars Colony Alpha', type: 'colony' },
23 { id: 'martian-canyon', name: 'Great Martian Canyon', type: 'landmark' }
24 ].filter(item =>
25 item.name.toLowerCase().includes(destination.toLowerCase())
26 );
27
28 setSuggestions(mockSuggestions);
29 };
30
31 fetchSuggestions();
32 }, [destination]);
33
34 const handleSelect = (suggestion) => {
35 // Sélection de la route en fonction du type de suggestion
36 switch(suggestion.type) {
37 case 'planet':
38 router.push(`/destinations/${suggestion.id}`);
39 break;
40 case 'colony':
41 router.push(`/colonies/${suggestion.id}`);
42 break;
43 case 'landmark':
44 router.push(`/landmarks/${suggestion.id}`);
45 break;
46 default:
47 router.push(`/search?q=${encodeURIComponent(suggestion.name)}`);
48 }
49 };
50
51 return (
52 <div className="relative w-full max-w-md">
53 <input
54 type="text"
55 value={destination}
56 onChange={(e) => setDestination(e.target.value)}
57 className="w-full p-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
58 />
59
60 {suggestions.length > 0 && (
61 <div className="absolute z-10 w-full mt-1 bg-white rounded-lg shadow-lg border border-gray-200">
62 <ul>
63 {suggestions.map((suggestion) => (
64 <li
65 key={suggestion.id}
66 className="px-4 py-3 hover:bg-gray-100 cursor-pointer flex items-center"
67 onClick={() => handleSelect(suggestion)}
68 >
69 <div className="w-8 h-8 mr-3 flex items-center justify-center rounded-full bg-indigo-100 text-indigo-800">
70 {suggestion.type === 'planet' ? 'P' : suggestion.type === 'colony' ? 'C' : 'S'}
71 </div>
72 <div>
73 <div className="font-medium">{suggestion.name}</div>
74 <div className="text-sm text-gray-500 capitalize">{suggestion.type}</div>
75 </div>
76 </li>
77 ))}
78 </ul>
79 </div>
80 )}
81 </div>
82 );
83}Exemple 2 : Formulaire de réservation en plusieurs étapes
1'use client';
2
3import { useState } from 'react';
4import { useRouter } from 'next/navigation';
5
6export default function MultiStepBookingForm() {
7 const router = useRouter();
8 const [step, setStep] = useState(1);
9 const [formData, setFormData] = useState({
10 destination: '',
11 departureDate: '',
12 returnDate: '',
13 passengers: 1,
14 contactEmail: '',
15 contactPhone: ''
16 });
17
18 const updateFormData = (field, value) => {
19 setFormData(prev => ({ ...prev, [field]: value }));
20 };
21
22 const goToNextStep = () => {
23 setStep(prev => prev + 1);
24 };
25
26 const goToPreviousStep = () => {
27 setStep(prev => prev - 1);
28 };
29
30 const submitForm = async () => {
31 try {
32 // Ici, on enverrait normalement les données à l'API
33 console.log('Sending data:', formData);
34
35 // Simulation de l'attente de la réponse de l'API
36 await new Promise(resolve => setTimeout(resolve, 1000));
37
38 // Redirige vers la page de confirmation avec l'ID de réservation
39 router.push('/booking/confirmation?id=QV123456789');
40 } catch (error) {
41 console.error('Booking error:', error);
42 // Gestion des erreurs
43 }
44 };
45
46 return (
47 <div className="max-w-3xl mx-auto p-6 bg-white rounded-lg shadow-md">
48 <div className="mb-8">
49 <div className="flex items-center">
50 {[1, 2, 3].map((i) => (
51 <div key={i} className="flex items-center">
52 <div className={`w-10 h-10 rounded-full flex items-center justify-center ${
53 i === step ? 'bg-indigo-600 text-white' :
54 i < step ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-700'
55 }`}>
56 {i < step ? 'OK' : i}
57 </div>
58 {i < 3 && (
59 <div className={`h-1 w-16 ${i < step ? 'bg-green-500' : 'bg-gray-200'}`}></div>
60 )}
61 </div>
62 ))}
63 </div>
64 <div className="flex justify-between mt-2">
65 <span>Destination</span>
66 <span>Détails du voyage</span>
67 <span>Coordonnées</span>
68 </div>
69 </div>
70
71 {step === 1 && (
72 <div className="space-y-4">
73 <h2 className="text-2xl font-semibold">Choisis une destination</h2>
74
75 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
76 {['Mars', 'Europa', 'Tytan'].map((dest) => (
77 <div
78 key={dest}
79 onClick={() => updateFormData('destination', dest)}
80 className={`p-4 border rounded-lg cursor-pointer ${
81 formData.destination === dest ? 'border-indigo-600 bg-indigo-50' : 'border-gray-200'
82 }`}
83 >
84 <h3 className="font-semibold">{dest}</h3>
85 <p className="text-sm text-gray-600">Découvre les merveilles de {dest}</p>
86 </div>
87 ))}
88 </div>
89
90 <div className="flex justify-end mt-6">
91 <button
92 onClick={goToNextStep}
93 disabled={!formData.destination}
94 className={`px-6 py-2 rounded-lg ${
95 formData.destination ? 'bg-indigo-600 text-white' : 'bg-gray-200 text-gray-500 cursor-not-allowed'
96 }`}
97 >
98 Suivant
99 </button>
100 </div>
101 </div>
102 )}
103
104 {step === 2 && (
105 <div className="space-y-4">
106 <h2 className="text-2xl font-semibold">Détails du voyage</h2>
107
108 <div className="space-y-4">
109 <div>
110 <label className="block mb-1">Date de départ</label>
111 <input
112 type="date"
113 value={formData.departureDate}
114 onChange={(e) => updateFormData('departureDate', e.target.value)}
115 className="w-full p-2 border border-gray-300 rounded"
116 min={new Date().toISOString().split('T')[0]}
117 />
118 </div>
119
120 <div>
121 <label className="block mb-1">Date de retour</label>
122 <input
123 type="date"
124 value={formData.returnDate}
125 onChange={(e) => updateFormData('returnDate', e.target.value)}
126 className="w-full p-2 border border-gray-300 rounded"
127 min={formData.departureDate || new Date().toISOString().split('T')[0]}
128 />
129 </div>
130
131 <div>
132 <label className="block mb-1">Nombre de passagers</label>
133 <input
134 type="number"
135 value={formData.passengers}
136 onChange={(e) => updateFormData('passengers', e.target.value)}
137 className="w-full p-2 border border-gray-300 rounded"
138 min="1"
139 max="10"
140 />
141 </div>
142 </div>
143
144 <div className="flex justify-between mt-6">
145 <button
146 onClick={goToPreviousStep}
147 className="px-6 py-2 border border-gray-300 rounded-lg"
148 >
149 Retour
150 </button>
151 <button
152 onClick={goToNextStep}
153 disabled={!formData.departureDate || !formData.returnDate}
154 className={`px-6 py-2 rounded-lg ${
155 formData.departureDate && formData.returnDate ?
156 'bg-indigo-600 text-white' :
157 'bg-gray-200 text-gray-500 cursor-not-allowed'
158 }`}
159 >
160 Suivant
161 </button>
162 </div>
163 </div>
164 )}
165
166 {step === 3 && (
167 <div className="space-y-4">
168 <h2 className="text-2xl font-semibold">Coordonnées</h2>
169
170 <div className="space-y-4">
171 <div>
172 <label className="block mb-1">Email</label>
173 <input
174 type="email"
175 value={formData.contactEmail}
176 onChange={(e) => updateFormData('contactEmail', e.target.value)}
177 className="w-full p-2 border border-gray-300 rounded"
178 />
179 </div>
180
181 <div>
182 <label className="block mb-1">Téléphone</label>
183 <input
184 type="tel"
185 value={formData.contactPhone}
186 onChange={(e) => updateFormData('contactPhone', e.target.value)}
187 className="w-full p-2 border border-gray-300 rounded"
188 />
189 </div>
190 </div>
191
192 <div className="mt-6 p-4 bg-gray-50 rounded-lg">
193 <h3 className="font-semibold mb-2">Récapitulatif</h3>
194 <div className="grid grid-cols-2 gap-2 text-sm">
195 <div>Destination :</div>
196 <div className="font-medium">{formData.destination}</div>
197
198 <div>Date de départ :</div>
199 <div className="font-medium">{formData.departureDate}</div>
200
201 <div>Date de retour :</div>
202 <div className="font-medium">{formData.returnDate}</div>
203
204 <div>Passagers :</div>
205 <div className="font-medium">{formData.passengers}</div>
206 </div>
207 </div>
208
209 <div className="flex justify-between mt-6">
210 <button
211 onClick={goToPreviousStep}
212 className="px-6 py-2 border border-gray-300 rounded-lg"
213 >
214 Retour
215 </button>
216 <button
217 onClick={submitForm}
218 disabled={!formData.contactEmail || !formData.contactPhone}
219 className={`px-6 py-2 rounded-lg ${
220 formData.contactEmail && formData.contactPhone ?
221 'bg-indigo-600 text-white' :
222 'bg-gray-200 text-gray-500 cursor-not-allowed'
223 }`}
224 >
225 Réserver un voyage
226 </button>
227 </div>
228 </div>
229 )}
230 </div>
231 );
232}usePathname et useSearchParams - surveillance des routes
À Metropolis Quantum, le système de surveillance du trafic suit en permanence la position des Q-Links, permettant à l'IA centrale d'ajuster les paramètres de transport en temps réel. Dans Next.js 16, les hooks usePathname et useSearchParams remplissent une fonction similaire.
usePathname - suivre le chemin actuel
1'use client';
2
3import Link from 'next/link';
4import { usePathname } from 'next/navigation';
5
6export default function MainNavigation() {
7 const pathname = usePathname();
8
9 const navItems = [
10 { label: 'Accueil', href: '/' },
11 { label: 'Destinations', href: '/destinations' },
12 { label: 'Vols', href: '/flights' },
13 { label: 'Offres', href: '/offers' },
14 { label: 'À propos', href: '/about' }
15 ];
16
17 return (
18 <nav className="bg-white shadow">
19 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
20 <div className="flex justify-between h-16">
21 <div className="flex">
22 <div className="shrink-0 flex items-center">
23 <img className="h-8 w-auto" src="/logo.svg" alt="Quantum Voyages" />
24 </div>
25 <div className="hidden sm:ml-6 sm:flex sm:space-x-8">
26 {navItems.map((item) => {
27 const isActive = pathname === item.href ||
28 (item.href !== '/' && pathname.startsWith(item.href));
29
30 return (
31 <Link
32 key={item.href}
33 href={item.href}
34 className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
35 isActive
36 ? 'border-indigo-500 text-gray-900'
37 : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
38 }`}
39 >
40 {item.label}
41 </Link>
42 );
43 })}
44 </div>
45 </div>
46 <div className="hidden sm:ml-6 sm:flex sm:items-center">
47 <Link
48 href="/dashboard"
49 className="px-4 py-2 text-sm rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
50 >
51 Mon compte
52 </Link>
53 </div>
54 </div>
55 </div>
56 </nav>
57 );
58}Dans cet exemple, usePathname nous permet de déterminer le chemin actuel, ce qui permet de mettre en évidence l'élément actif du menu de navigation.
useSearchParams - accéder aux paramètres de requête
1'use client';
2
3import { useSearchParams } from 'next/navigation';
4
5export default function SearchResultsHeader() {
6 const searchParams = useSearchParams();
7
8 const query = searchParams.get('q') || '';
9 const category = searchParams.get('category') || 'all';
10 const sort = searchParams.get('sort') || 'relevance';
11
12 return (
13 <div className="mb-6">
14 <h1 className="text-3xl font-bold mb-2">
15 {query ? `Résultats de recherche pour "${query}"` : 'Toutes les destinations'}
16 </h1>
17 <div className="flex items-center text-sm text-gray-600">
18 <span>Catégorie : <strong className="capitalize">{category}</strong></span>
19 <span className="mx-2">•</span>
20 <span>Tri : <strong className="capitalize">{
21 sort === 'relevance' ? 'Pertinence' :
22 sort === 'price-asc' ? 'Prix : du plus bas' :
23 sort === 'price-desc' ? 'Prix : du plus élevé' :
24 sort === 'rating' ? 'Note' : 'Pertinence'
25 }</strong></span>
26 </div>
27 </div>
28 );
29}Ce code montre comment useSearchParams permet de lire et d'utiliser les paramètres de requête de l'URL, qui peuvent déterminer divers aspects de la page affichée.
Combiner useSearchParams avec la navigation
1'use client';
2
3import { useSearchParams, useRouter, usePathname } from 'next/navigation';
4
5export default function SearchFilters() {
6 const router = useRouter();
7 const pathname = usePathname();
8 const searchParams = useSearchParams();
9
10 // Récupère les paramètres de recherche actuels
11 const currentCategory = searchParams.get('category') || 'all';
12 const currentSort = searchParams.get('sort') || 'relevance';
13 const currentQuery = searchParams.get('q') || '';
14
15 // Fonction pour mettre à jour les paramètres tout en préservant ceux existants
16 const createQueryString = (name, value) => {
17 const params = new URLSearchParams(searchParams);
18 params.set(name, value);
19
20 return params.toString();
21 };
22
23 const handleCategoryChange = (category) => {
24 router.push(`${pathname}?${createQueryString('category', category)}`);
25 };
26
27 const handleSortChange = (sort) => {
28 router.push(`${pathname}?${createQueryString('sort', sort)}`);
29 };
30
31 return (
32 <div className="mb-8 space-y-4">
33 <div>
34 <h2 className="text-lg font-medium mb-2">Catégories</h2>
35 <div className="flex flex-wrap gap-2">
36 {['all', 'planets', 'moons', 'stations'].map((category) => (
37 <button
38 key={category}
39 onClick={() => handleCategoryChange(category)}
40 className={`px-4 py-2 rounded-full text-sm ${
41 currentCategory === category
42 ? 'bg-indigo-600 text-white'
43 : 'bg-gray-200 text-gray-800 hover:bg-gray-300'
44 }`}
45 >
46 {category === 'all' ? 'Toutes' :
47 category === 'planets' ? 'Planètes' :
48 category === 'moons' ? 'Lunes' : 'Stations spatiales'}
49 </button>
50 ))}
51 </div>
52 </div>
53
54 <div>
55 <h2 className="text-lg font-medium mb-2">Tri</h2>
56 <select
57 value={currentSort}
58 onChange={(e) => handleSortChange(e.target.value)}
59 className="w-full sm:w-auto p-2 border border-gray-300 rounded-md"
60 >
61 <option value="relevance">Pertinence</option>
62 <option value="price-asc">Prix : du plus bas</option>
63 <option value="price-desc">Prix : du plus élevé</option>
64 <option value="rating">Note</option>
65 <option value="distance">Distance</option>
66 </select>
67 </div>
68 </div>
69 );
70}Cet exemple montre comment combiner efficacement useSearchParams avec useRouter et usePathname pour créer des filtres interactifs qui mettent à jour l'URL et le contenu de la page sans rechargement complet.
Intercepting Routes
À Metropolis Quantum, un Q-Link peut parfois changer d'itinéraire en plein vol pour traiter une demande spéciale d'un passager sans interrompre le trajet principal. Dans Next.js 16, ce concept est implémenté via les « Intercepting Routes ».
Concept de base de l'interception de routes
L'interception de routes permet d'« intercepter » la navigation normale vers un chemin donné et d'afficher une UI alternative, souvent une surcouche (modale) sur la page actuelle, au lieu d'un changement complet de page.
L'implémentation repose sur une convention spéciale de nommage des dossiers :
(.)- intercepte les segments du même niveau(..)- intercepte les segments d'un niveau au-dessus(..)(..)- intercepte les segments de deux niveaux au-dessus(...)- intercepte les segments depuis la racine de l'application
Exemple : Galerie de photos avec modale
Structure des répertoires :
1app/
2 ├── destinations/
3 │ ├── page.tsx # Page liste des destinations /destinations
4 │ └── [planet]/
5 │ ├── layout.tsx # Affiche {children} et le slot {modal}
6 │ ├── page.tsx # Page d'une planète précise /destinations/mars
7 │ ├── gallery/
8 │ │ └── page.tsx # Galerie complète /destinations/mars/gallery
9 │ └── @modal/
10 │ ├── default.tsx # Renvoie null quand la modale est fermée (obligatoire dans Next.js 16)
11 │ └── (.)gallery/
12 │ └── page.tsx # Intercepte /destinations/mars/gallery sous forme de modaleLe dossier du slot @modal n'est pas un segment de l'URL : la galerie du même segment [planet] est donc interceptée par (.)gallery, et non (..)gallery (qui intercepterait /destinations/gallery).
Implémentation :
1// app/destinations/page.tsx - Accueil des destinations
2import Link from 'next/link';
3
4export default function DestinationsPage() {
5 const destinations = [
6 { id: 'mars', name: 'Mars', imageUrl: '/images/mars.jpg' },
7 { id: 'europa', name: 'Europa', imageUrl: '/images/europa.jpg' },
8 { id: 'titan', name: 'Titan', imageUrl: '/images/titan.jpg' }
9 ];
10
11 return (
12 <div className="container mx-auto py-12 px-4">
13 <h1 className="text-4xl font-bold mb-8">Destinations galactiques</h1>
14
15 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
16 {destinations.map(destination => (
17 <Link
18 key={destination.id}
19 href={`/destinations/${destination.id}`}
20 className="block group"
21 >
22 <div className="relative h-64 rounded-lg overflow-hidden shadow-lg">
23 <img
24 src={destination.imageUrl}
25 alt={destination.name}
26 className="w-full h-full object-cover transition-transform group-hover:scale-105"
27 />
28 <div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent flex items-end">
29 <h2 className="text-white text-2xl font-bold p-4">
30 {destination.name}
31 </h2>
32 </div>
33 </div>
34 </Link>
35 ))}
36 </div>
37 </div>
38 );
39}1// app/destinations/[planet]/page.tsx - Page d'une planète précise
2import Link from 'next/link';
3
4export default async function PlanetPage({ params }: { params: Promise<{ planet: string }> }) {
5 const { planet } = await params;
6
7 // Données de galerie simulées
8 const galleryImages = Array.from({ length: 6 }, (_, i) => ({
9 id: i + 1,
10 src: `/images/${planet}/${i + 1}.jpg`,
11 alt: `Photo of ${planet} #${i + 1}`
12 }));
13
14 return (
15 <div className="container mx-auto py-12 px-4">
16 <h1 className="text-4xl font-bold mb-8 capitalize">
17 {planet}
18 </h1>
19
20 <div className="mb-12">
21 {/* Informations sur la planète... */}
22 </div>
23
24 <section>
25 <div className="flex justify-between items-center mb-6">
26 <h2 className="text-2xl font-bold">Galerie</h2>
27 <Link
28 href={`/destinations/${planet}/gallery`}
29 className="text-indigo-600 hover:text-indigo-800"
30 >
31 Tout voir
32 </Link>
33 </div>
34
35 <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
36 {galleryImages.slice(0, 3).map(image => (
37 <Link
38 key={image.id}
39 href={`/destinations/${planet}/gallery?image=${image.id}`}
40 className="block aspect-square rounded-lg overflow-hidden"
41 >
42 <img
43 src={image.src}
44 alt={image.alt}
45 className="w-full h-full object-cover hover:scale-105 transition-transform"
46 />
47 </Link>
48 ))}
49 </div>
50 </section>
51 </div>
52 );
53}1// app/destinations/[planet]/gallery/page.tsx - Galerie complète
2import Link from 'next/link';
3import { notFound } from 'next/navigation';
4
5export default async function GalleryPage({
6 params,
7 searchParams,
8}: {
9 params: Promise<{ planet: string }>;
10 searchParams: Promise<{ image?: string }>;
11}) {
12 const { planet } = await params;
13 const imageId = (await searchParams).image;
14
15 // Vérifie que la planète existe
16 const validPlanets = ['mars', 'europa', 'titan'];
17 if (!validPlanets.includes(planet)) {
18 notFound();
19 }
20
21 // Données de galerie simulées
22 const galleryImages = Array.from({ length: 12 }, (_, i) => ({
23 id: i + 1,
24 src: `/images/${planet}/${i + 1}.jpg`,
25 alt: `Photo of ${planet} #${i + 1}`,
26 title: `${planet.charAt(0).toUpperCase() + planet.slice(1)} - vue #${i + 1}`
27 }));
28
29 return (
30 <div className="container mx-auto py-12 px-4">
31 <div className="flex items-center mb-8">
32 <Link
33 href={`/destinations/${planet}`}
34 className="text-indigo-600 hover:underline mr-4"
35 >
36 ← Retour vers {planet}
37 </Link>
38 <h1 className="text-3xl font-bold capitalize">
39 Galerie : {planet}
40 </h1>
41 </div>
42
43 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
44 {galleryImages.map(image => (
45 <Link
46 key={image.id}
47 href={`/destinations/${planet}/gallery?image=${image.id}`}
48 className="block aspect-square rounded-lg overflow-hidden"
49 >
50 <img
51 src={image.src}
52 alt={image.alt}
53 className="w-full h-full object-cover hover:scale-105 transition-transform"
54 />
55 </Link>
56 ))}
57 </div>
58 </div>
59 );
60}Le slot @modal arrive dans le layout du segment [planet] sous forme de prop modal, à côté de children. Le fichier default.tsx indique quoi afficher dans le slot quand aucune modale n'est ouverte :
1// app/destinations/[planet]/layout.tsx - affiche la page et le slot @modal
2export default function PlanetLayout({
3 children,
4 modal,
5}: {
6 children: React.ReactNode;
7 modal: React.ReactNode;
8}) {
9 return (
10 <>
11 {children}
12 {modal}
13 </>
14 );
15}1// app/destinations/[planet]/@modal/default.tsx - modale fermée
2export default function Default() {
3 return null;
4}La modale lit params avec le hook use(), car dans Next.js 16 params est une Promise, y compris dans un Client Component :
1// app/destinations/[planet]/@modal/(.)gallery/page.tsx - Modale de galerie
2'use client';
3
4import { useRouter, useSearchParams } from 'next/navigation';
5import { use, useEffect, useRef, useState } from 'react';
6
7export default function GalleryModal({ params }: { params: Promise<{ planet: string }> }) {
8 const router = useRouter();
9 const searchParams = useSearchParams();
10 const overlayRef = useRef(null);
11 const { planet } = use(params);
12 const imageId = searchParams.get('image');
13 const [isClosing, setIsClosing] = useState(false);
14
15 // Sans ?image (lien « Tout voir »), ouvre la galerie complète : une navigation complète contourne l'interception
16 useEffect(() => {
17 if (!imageId) {
18 window.location.assign(`/destinations/${planet}/gallery`);
19 }
20 }, [imageId, planet]);
21
22 // Gère la touche ÉCHAP
23 useEffect(() => {
24 const handleKeyDown = (e) => {
25 if (e.key === 'Escape') {
26 handleClose();
27 }
28 };
29
30 window.addEventListener('keydown', handleKeyDown);
31 return () => window.removeEventListener('keydown', handleKeyDown);
32 }, []);
33
34 const handleClose = () => {
35 setIsClosing(true);
36 setTimeout(() => {
37 router.back();
38 }, 300);
39 };
40
41 const handleOverlayClick = (e) => {
42 if (e.target === overlayRef.current) {
43 handleClose();
44 }
45 };
46
47 if (!imageId) return null;
48
49 // Récupère les données de l'image
50 const imageIndex = parseInt(imageId) - 1;
51 const galleryImages = Array.from({ length: 12 }, (_, i) => ({
52 id: i + 1,
53 src: `/images/${planet}/${i + 1}.jpg`,
54 alt: `Photo of ${planet} #${i + 1}`,
55 title: `${planet.charAt(0).toUpperCase() + planet.slice(1)} - vue #${i + 1}`,
56 description: `Description détaillée de l'image #${i + 1} de la planète ${planet}.`
57 }));
58
59 const image = galleryImages[imageIndex] || galleryImages[0];
60 const prevImageId = image.id > 1 ? image.id - 1 : null;
61 const nextImageId = image.id < galleryImages.length ? image.id + 1 : null;
62
63 return (
64 <div
65 ref={overlayRef}
66 onClick={handleOverlayClick}
67 className={`fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4 transition-opacity duration-300 ${
68 isClosing ? 'opacity-0' : 'opacity-100'
69 }`}
70 >
71 <div
72 className={`relative bg-white rounded-lg max-w-6xl w-full max-h-[90vh] overflow-hidden shadow-xl transition-transform duration-300 ${
73 isClosing ? 'scale-95' : 'scale-100'
74 }`}
75 onClick={(e) => e.stopPropagation()}
76 >
77 <div className="flex h-full">
78 <div className="w-2/3 bg-black flex items-center justify-center">
79 <img
80 src={image.src}
81 alt={image.alt}
82 className="max-h-[85vh] max-w-full object-contain"
83 />
84 </div>
85 <div className="w-1/3 p-6 flex flex-col h-[85vh]">
86 <button
87 onClick={handleClose}
88 className="absolute top-4 right-4 w-8 h-8 rounded-full flex items-center justify-center bg-gray-200 hover:bg-gray-300"
89 aria-label="Fermer"
90 >
91 ×
92 </button>
93
94 <h2 className="text-2xl font-bold mb-2">{image.title}</h2>
95 <p className="text-gray-600 mb-6">{image.description}</p>
96
97 <div className="mt-auto flex justify-between">
98 {prevImageId ? (
99 <button
100 onClick={() => router.replace(`/destinations/${planet}/gallery?image=${prevImageId}`)}
101 className="px-4 py-2 rounded border border-gray-300 hover:bg-gray-100"
102 >
103 ← Précédente
104 </button>
105 ) : <div />}
106
107 {nextImageId && (
108 <button
109 onClick={() => router.replace(`/destinations/${planet}/gallery?image=${nextImageId}`)}
110 className="px-4 py-2 rounded border border-gray-300 hover:bg-gray-100"
111 >
112 Suivante →
113 </button>
114 )}
115 </div>
116 </div>
117 </div>
118 </div>
119 </div>
120 );
121}Dans cet exemple avancé :
- Lors de la navigation vers
/destinations/mars/gallery?image=1, la page est interceptée par la modale - L'utilisateur peut parcourir les images dans la modale sans quitter le contexte de la page principale
- Après la fermeture de la modale, l'utilisateur revient à la page précédente sans perdre le contexte
Parallel Routing
À Metropolis Quantum, les Q-Links avancés peuvent se diviser en deux chemins pour desservir simultanément différentes destinations, puis se rejoindre, optimisant ainsi le flux du transport urbain. Dans Next.js 16, l'équivalent de cette technologie est le « Parallel Routing ».
Concept de base des parallel routes
Les parallel routes dans Next.js 16 permettent de rendre plusieurs pages ou composants dans la même vue, ce qui est particulièrement utile pour :
- Charger du contenu en parallèle avec différentes priorités
- Naviguer de façon indépendante entre plusieurs composants de page
- Créer des mises en page complexes avec plusieurs régions de navigation
L'implémentation utilise des dossiers avec le préfixe @ :
1app/
2 ├── dashboard/
3 │ ├── layout.tsx # Mise en page de la page
4 │ ├── page.tsx # Contenu principal
5 │ ├── @stats/ # Slot « stats »
6 │ │ ├── page.tsx # Page par défaut dans le slot « stats »
7 │ │ └── default.tsx # Obligatoire depuis Next.js 16
8 │ └── @activity/ # Slot « activity »
9 │ ├── page.tsx # Page par défaut dans le slot « activity »
10 │ └── default.tsx # Obligatoire depuis Next.js 16Depuis Next.js 16, chaque slot a besoin de son propre fichier default.tsx : sans lui, next build échoue. Next.js affiche ce fichier lorsqu'il ne parvient pas à faire correspondre la page du slot à l'adresse actuelle, par exemple après un rafraîchissement du navigateur sur une sous-page. La version la plus simple n'affiche rien :
1// app/dashboard/@stats/default.tsx
2export default function Default() {
3 return null;
4}Ce fichier ne change rien à l'apparence de la page, mais il satisfait l'exigence de Next.js 16. Si tu préfères afficher une page 404 dans ce cas, appelle notFound() dedans.
Exemple : Tableau de bord d'une agence de voyages spatiaux
Implémentation :
1// app/dashboard/layout.tsx - Mise en page avec parallel routes
2export default function DashboardLayout({
3 children, // Contenu principal
4 stats, // Slot @stats
5 activity // Slot @activity
6}) {
7 return (
8 <div className="container mx-auto px-4 py-8">
9 <h1 className="text-3xl font-bold mb-8">Panneau de contrôle Quantum Voyages</h1>
10
11 <div className="grid grid-cols-12 gap-8">
12 {/* Contenu principal */}
13 <div className="col-span-12 lg:col-span-6">
14 {children}
15 </div>
16
17 {/* Panneau de statistiques */}
18 <div className="col-span-12 md:col-span-6 lg:col-span-3">
19 <div className="bg-white shadow rounded-lg p-4 h-full">
20 {stats}
21 </div>
22 </div>
23
24 {/* Panneau d'activité */}
25 <div className="col-span-12 md:col-span-6 lg:col-span-3">
26 <div className="bg-white shadow rounded-lg p-4 h-full">
27 {activity}
28 </div>
29 </div>
30 </div>
31 </div>
32 );
33}1// app/dashboard/page.tsx - Contenu principal
2export default function DashboardPage() {
3 return (
4 <div className="bg-white shadow rounded-lg p-6">
5 <h2 className="text-2xl font-semibold mb-4">Aperçu des réservations</h2>
6
7 <div className="space-y-6">
8 <div className="p-4 border rounded-lg">
9 <h3 className="font-semibold">Prochain voyage</h3>
10 <p className="mt-2">Mars - Colony Alpha, 12 juin 2150</p>
11 <div className="mt-4 flex justify-between">
12 <span className="text-indigo-600">Statut : Confirmé</span>
13 <button className="text-sm text-indigo-600 hover:underline">
14 Détails
15 </button>
16 </div>
17 </div>
18
19 <div className="p-4 border rounded-lg">
20 <h3 className="font-semibold">Historique des voyages</h3>
21 <ul className="mt-2 space-y-2">
22 <li className="flex justify-between">
23 <span>Europa - Research Station</span>
24 <span>24 mars 2150</span>
25 </li>
26 <li className="flex justify-between">
27 <span>Lune - Armstrong Base</span>
28 <span>12 janvier 2150</span>
29 </li>
30 </ul>
31 </div>
32
33 <div className="p-4 border rounded-lg">
34 <h3 className="font-semibold">Destinations recommandées</h3>
35 <ul className="mt-2 space-y-2">
36 <li>
37 <a href="/destinations/titan" className="text-indigo-600 hover:underline">
38 Titan - colonies récemment ouvertes
39 </a>
40 </li>
41 <li>
42 <a href="/destinations/venus-orbiter" className="text-indigo-600 hover:underline">
43 Venus Orbiter - séjour scientifique
44 </a>
45 </li>
46 </ul>
47 </div>
48 </div>
49 </div>
50 );
51}1// app/dashboard/@stats/page.tsx - Statistiques
2export default function StatsPage() {
3 // Dans une vraie application, ces données seraient récupérées depuis l'API
4 const stats = [
5 { label: 'Total des vols', value: '7' },
6 { label: 'Planètes visitées', value: '3' },
7 { label: 'Points de fidélité', value: '12,540' },
8 { label: 'Statut de membre', value: 'Gold' }
9 ];
10
11 return (
12 <div>
13 <h2 className="text-xl font-semibold mb-4">Statistiques de voyage</h2>
14
15 <div className="space-y-4">
16 {stats.map((stat, index) => (
17 <div key={index} className="flex justify-between">
18 <span className="text-gray-600">{stat.label} :</span>
19 <span className="font-semibold">{stat.value}</span>
20 </div>
21 ))}
22 </div>
23
24 <div className="mt-6 pt-4 border-t">
25 <div className="flex justify-between items-center">
26 <span className="text-gray-600">Prochain niveau de statut :</span>
27 <span className="text-sm font-medium">Platinum</span>
28 </div>
29 <div className="mt-2 w-full bg-gray-200 rounded-full h-2.5">
30 <div className="bg-indigo-600 h-2.5 rounded-full" style={{ width: '70%' }}></div>
31 </div>
32 <p className="mt-1 text-xs text-right text-gray-500">7 460 points avant le niveau suivant</p>
33 </div>
34 </div>
35 );
36}1// app/dashboard/@activity/page.tsx - Activité
2export default function ActivityPage() {
3 // Dans une vraie application, ces données seraient récupérées depuis l'API
4 const activities = [
5 { type: 'reservation', message: 'Tu as réservé un voyage vers Mars', time: 'il y a 2 heures' },
6 { type: 'check-in', message: 'Enregistrement en ligne effectué pour le vol QV-105', time: 'il y a 3 jours' },
7 { type: 'payment', message: 'Paiement de 12 500 CR effectué', time: 'il y a 1 semaine' },
8 { type: 'review', message: 'Tu as rédigé un avis pour Armstrong Base', time: 'il y a 2 semaines' }
9 ];
10
11 return (
12 <div>
13 <h2 className="text-xl font-semibold mb-4">Activité récente</h2>
14
15 <div className="space-y-4">
16 {activities.map((activity, index) => (
17 <div key={index} className="flex items-start">
18 <div className={`mt-1 rounded-full w-2 h-2 mr-2 ${
19 activity.type === 'reservation' ? 'bg-green-500' :
20 activity.type === 'check-in' ? 'bg-blue-500' :
21 activity.type === 'payment' ? 'bg-purple-500' :
22 'bg-yellow-500'
23 }`}></div>
24 <div>
25 <p className="text-sm">{activity.message}</p>
26 <p className="text-xs text-gray-500">{activity.time}</p>
27 </div>
28 </div>
29 ))}
30 </div>
31
32 <div className="mt-6 flex justify-center">
33 <button className="text-sm text-indigo-600 hover:underline">
34 Voir toutes les activités
35 </button>
36 </div>
37 </div>
38 );
39}Cet exemple montre comment les parallel routes permettent de créer des mises en page complexes où différentes parties de la page peuvent être mises à jour et chargées de façon indépendante.
Résumé
Tout comme le système de transport avancé de Metropolis Quantum permet aux habitants de se déplacer en douceur entre les différentes parties de la ville, la navigation dans Next.js 16 offre aux développeurs et aux utilisateurs un système extrêmement efficace et intuitif pour se déplacer dans l'application.
Éléments clés de la navigation dans Next.js 16 :
Composant Link - l'élément de navigation fondamental qui permet des transitions rapides entre les pages sans rechargement complet, grâce au prefetching automatique et aux optimisations.
useRouter - un hook permettant la navigation programmatique, idéal pour diriger dynamiquement les utilisateurs en fonction de conditions ou d'événements précis.
usePathname et useSearchParams - des hooks pour surveiller le chemin actuel et les paramètres de requête, permettant des ajustements réactifs de l'UI.
Intercepting Routes - une fonctionnalité avancée permettant d'intercepter la navigation vers des chemins précis et d'afficher une UI alternative, souvent sous forme de modale.
Parallel Routing - permet de rendre plusieurs pages ou composants dans une seule vue, idéal pour les tableaux de bord complexes et les interfaces comportant plusieurs régions de navigation.
Ces outils, utilisés ensemble, permettent de créer des systèmes de navigation efficaces, intuitifs et avancés qui améliorent considérablement l'expérience utilisateur et les performances de l'application.
Dans le prochain chapitre, nous explorerons les techniques de regroupement et d'organisation des routes qui aident à maintenir l'ordre et la structure dans des applications plus complexes, tout comme la planification par zones à Metropolis Quantum aide à garder la ville organisée et efficace.
Tu as repéré une erreur dans cette leçon ?