Next.js App Router a introduit une série de fonctionnalités utiles, telles que la prise en charge des composants serveur React, une meilleure mise en cache, des réponses en continu et des mises en page imbriquées. Ces fonctionnalités permettent aux développeurs de créer une expérience utilisateur améliorée tout en bénéficiant d'une meilleure expérience de développement. Bien que ces fonctionnalités soient utiles et conviviales, si elles sont mal utilisées, elles peuvent introduire des bogues ou des problèmes de performance.
Dans cet article, nous allons explorer les erreurs les plus courantes que vous pouvez faire avec Next.js App Router. Nous présenterons également des solutions, avec des exemples de code, pour aider à atténuer ces erreurs et améliorer l'expérience de l'utilisateur.
Notez que les exemples de cet article utilisent la structure de projet Next.js /src/app
.
Récupérer des données côté client est une pratique courante dans React depuis de nombreuses années. Il est donc facile de tomber dans le piège de faire des requêtes réseau redondantes sur le composant client, ce qui n'est pas aussi efficace en raison de la latence réseau ajoutée et de l'obligation de créer différents gestionnaires de route HTTP.
Dans l'exemple suivant, le <UserComponent>
récupère des données du côté client à l'aide du crochet useEffect
. Bien que cette approche fonctionne, elle n'est pas idéale car elle ajoute plus de requêtes réseau du côté client et peut conduire à une expérience utilisateur plus lente.
"use client" ; import { useEffect, useState } from "react" ; const UserComponent = () => { const [data, setData] = useState<{ name : string ; email : string } | null>( null ) ; useEffect(() => { const fetchData = async () => { const res = await fetch("https://jsonplaceholder.typicode.com/users/1") ; const result = await res.json() ; setData(result) ; } ; fetchData() ; }, []) ; if ( !data) return <div>Loading...</div> ; return ( <div> <h1>{data.name}</h1> <p>{data.email}</p> </div> ) ; } ; export default UserComponent ;
Pour résoudre ce problème, vous pouvez créer un composant serveur qui récupère les données côté serveur et les rend dans le composant UserComponentFixed
sur le serveur :
const fetchUser = async () => { const res = await fetch("https://jsonplaceholder.typicode.com/users/1") ; if (!res.ok) throw new Error("Failed to fetch user") ; return res.json() ; } ; const UserComponentFixed = async () => { const data = await fetchUser() ; // Récupération côté serveur return ( <div> <h1>{data.name}</h1> <p>{data.email}</p> </div> ) ; } ; export default UserComponentFixed ;
Cela garantit que le client reçoit un balisage HTML, ce qui réduit la charge du client pour la recherche et le rendu des données, améliorant ainsi l'expérience globale de l'utilisateur.
Next.js App Router implémente une approche statique d'abord, ce qui signifie que les pages sont toujours rendues comme statiques à moins qu'elles n'utilisent explicitement des API dynamiques. Bien que cette approche améliore les performances de l'application, elle peut causer des problèmes en identifiant par erreur la page comme statique lorsque vous n'utilisez pas d'API dynamiques mais que vous voulez rendre la page dynamiquement.
Dans l'exemple suivant, le composant serveur Home
récupère des données utilisateur aléatoires à chaque requête et les affiche sur la page :
// src/app/page.tsx export function random(min : number, max : number) { return Math.floor(Math.random() * (max - min) + min) ; } export const fetchUser = async (id : number) => { const res = await fetch("https://jsonplaceholder.typicode.com/users/" + id) ; if (!res.ok) throw new Error("Failed to fetch user") ; const data = await res.json() ; return { ...data } ; } ; export default async function Home() { const userId = random(1, 10) ; const data = await fetchUser(userId) ; return ( <div> <h1>{data.name}</h1> <p>{data.email}</p> </div> ) ; }
Pendant le développement, le composant Home
génère un nouveau numéro aléatoire à chaque rendu. Mais dans la version de production créée après l'exécution de la version suivante
, la page est générée de manière statique, de sorte qu'elle affiche systématiquement les détails pour le même utilisateur. Ce n'est pas le comportement attendu.
Pour résoudre ce problème, vous pouvez utiliser la fonction deconnexion pour rendre explicitement la page en tant que page dynamique. La page affichera alors des données aléatoires sur l'utilisateur à chaque rechargement :
// src/app/page.tsx import { connection } from "next/server" ; export function random(min : number, max : number) { return Math.floor(Math.random() * (max - min) + min) ; } export const fetchUser = async (id : number) => { const res = await fetch("https://jsonplaceholder.typicode.com/users/" + id) ; if (!res.ok) throw new Error("Failed to fetch user") ; const data = await res.json() ; return { ...data } ; } ; export default async function Home() { await connection() ; const userId = random(1, 10) ; const data = await fetchUser(userId) ; return ( <div> <h1>{data.name}</h1> <p>{data.email}</p> </div> ) ; }
Les actions serveur vous permettent d'exécuter des fonctions sur le serveur à partir des composants serveur et client pour des cas d'utilisation tels que la soumission de formulaires et les mutations de données.
Si vous pouvez créer une action serveur directement à l'intérieur d'un composant serveur, vous ne pouvez pas faire de même pour les composants clients. Pour définir et utiliser en toute sécurité des actions serveur dans des composants client, vous devez définir des actions serveur dans un fichier séparé et les importer dans les composants client. Ce faisant, vous vous assurez que l'action serveur est exécutée uniquement sur le serveur et que le code est exclu du bundle côté client.
Si vous créez un composant client de tâches à effectuer, il est judicieux de créer un fichier distinct pour l'action serveur et de l'importer dans le composant client, comme le montre l'exemple suivant :
"use server" ; // src/app/actions.ts export async function completeTodo(id : string) { // action serveur pour compléter la tâche }
"use client" ; // src/components/TodoItem.tsx import { completeTodo } from "@/app/actions" ; export function TodoItem() { const id = "todo_23" ; return <button onClick={() => completeTodo(id)}>Complete</button> ; }
React Suspense vous permet d'afficher un état de chargement lorsque vous attendez une réponse. Mais vous devez également savoir où placer les limites de suspense pour vous assurer qu'elles améliorent les performances de l'application et offrent une bonne expérience à l'utilisateur.
L'exemple suivant définit une limite de suspense sur la page complète :
// app/page.tsx import { Suspense } from "react" ; import UserProfile from "./components/UserProfile" ; import RecentPosts from "./components/RecentPosts" ; export default function Page() { return ( <Suspense fallback={<div>Loading page...</div>}> <UserProfile />{" "}{/* Les deux composants sont retardés si l'un d'eux est encore en cours de chargement */} <RecentPosts /> </Suspense> ) ; }
Cela bloque le rendu jusqu'à ce que tous les composants de la page aient fini de se charger. Elle peut également entraîner des temps de chargement lents et avoir un impact négatif sur les performances de l'application.
Il est plus judicieux de placer la limite de suspense sur chaque composant qui charge des données :
// app/page.tsx import { Suspense } from "react" ; import UserProfile from "./components/UserProfile" ; import RecentPosts from "./components/RecentPosts" ; export default function Page() { return ( <div> <h1>Bienvenue sur le tableau de bord</h1> {/* Placez des limites de Suspense autour de chaque section */} <Suspense fallback={<div>Loading profile...</div>}> <UserProfile /> {/* Affiche l'état de chargement uniquement pour ce composant */}</Suspense> <Suspense fallback={<div>Charge des messages récents...</div>}> <RecentPosts /> {/* Affiche un état de chargement différent pour les posts */}</Suspense> </div> ) ; }
De cette manière, l'utilisateur peut voir la page du tableau de bord avec les squelettes de chargement et les composants seront rendus au fur et à mesure de leur chargement sans se bloquer les uns les autres.
L'App Router fournit des fonctions utilitaires pour accéder aux données de la requête, telles que les en-têtes et les cookies, directement dans les gestionnaires de route et les composants du serveur. Mais vous ne pouvez pas utiliser ces fonctions dans les composants clients car il s'agit d'utilitaires côté serveur. En revanche, vous pouvez utiliser les crochets usePathname
, useSearchParams
et useParams
dans les composants client pour accéder aux informations relatives à l'itinéraire actuel.
Si vous devez accéder aux en-têtes de requête ou aux cookies dans le composant client, vous pouvez y accéder dans un composant serveur et transmettre la valeur dérivée au composant client. Dans l'exemple suivant, le composant client UserProfile
doit accéder au nom de l'utilisateur connecté, qui ne peut être récupéré qu'à partir du cookie sécurisé :
// src/components/UserProfile.tsx "use client" ; // Marqué comme un composant client type UserProfileProps = { userName : string ; } ; export default function UserProfile({ userName } : UserProfileProps) { // un peu de logique interactive pour le composant client return ( <div> <h2>Informations sur l'utilisateur</h2> <p>Connecté en tant que : {nomutilisateur}</p> </div> ) ; }
Pour résoudre ce problème, vous pouvez créer un composant serveur qui récupère le nom de l'utilisateur dans le cookie et le transmet au composant client :
// src/app/page.tsx import { cookies } from "next/headers" ; import { UserProfile } from "./components/UserProfile" ; export default async function Page() { const cookieStore = await cookies() ; const session = cookieStore.get("session") ; const user = await getUserFromSession(session) ; // il s'agit d'une fonction personnalisée return ( <div> <UserProfile userName={user.name} /> </div> ) ; }
React Context est une API réservée aux clients et n'est pas prise en charge dans les composants serveur. Ainsi, l'utilisation du contexte directement dans le layout racine ne fonctionnerait pas, comme le montre l'exemple suivant :
// src/app/layout.tsx import { createContext } from "react" ; // createContext n'est pas pris en charge dans les composants serveur export const ThemeContext = createContext({}) ;
export default function RootLayout({ children }) { return ( <html> <body> <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider> </body> </html> ) ; }
Pour résoudre ce problème, vous pouvez créer un composant client qui exporte le contexte et l'utilise dans le composant serveur :
// src/components/ThemeProvider.tsx "use client" ; import { createContext } from "react" ; export const ThemeContext = createContext({}) ; export default function ThemeProvider({ children, } : { children : React.ReactNode ; }) { return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider> ; }
// src/app/layout.tsx import ThemeProvider from "./components/ThemeProvider" ; export default function RootLayout({ children, } : { children : React.ReactNode ; }) { return ( <html> <body><ThemeProvider>{children}</ThemeProvider> </body> </html> ) ; }
Il est préférable d'utiliser les fournisseurs de contexte aussi profondément que possible dans l'arborescence de l'application pour permettre à Next.js d'optimiser autant que possible. Ainsi, si le ThemeProvider
n'est utilisé que dans la route /dashboard/settings
, rendez-le dans le fichier layout.tsx
le plus proche pour obtenir les meilleures performances.
Les composants serveur et client fonctionnent en tandem, mais chacun a une fonctionnalité spécifique. Utilisez les composants clients pour la gestion de l'état et l'interactivité. Vous pouvez utiliser des crochets React dans un composant client, mais pas dans un composant serveur, comme dans l'exemple suivant :
// src/components/CheckoutForm.tsx "use client" ; import { useState, useEffect } from "react" ; export default function CheckoutForm() { const [name, setName] = useState("") ;
useEffect(() => { // une logique d'effet }, []) ; return <form>{/* formulaire interactif */}</form> ; }
Les composants serveur sont les mieux adaptés à la collecte de données en raison des avantages qu'ils offrent en termes de sécurité et de performances. Ils sont également préférables lorsque vous avez besoin de bibliothèques lourdes pour la manipulation des données, telles que des bibliothèques de gestion ou d'analyse des données. Voici un exemple :
// src/components/UserDetails.tsx import { fetchUser } from "./UserAvatar" ; const UserDetailsComponent = async () => { const data = await fetchUser(5) ; return ( <div> No store :
<h1>{data.name}</h1> <p>{data.email}</p> </div> ) ; } ; export default UserDetailsComponent ;
L'exemple suivant illustre l'utilisation des composants client et serveur sur la même page :
// src/app/page.tsx import UserDetails from "./components/UserDetails" ; export default function Page() { // La fonction Page est un composant serveur return ( <div> <h1>Dashboard</h1>{/* Inclusion d'un composant client pour gérer l'état dynamique */}<CheckoutForm /> <UserDetails /> </div> ) ; }
La dernière version deNext.js prend également en charge l'ancienne structure de répertoire Pages Router afin d'assurer une transition en douceur pour les projets existants qui souhaitent adopter progressivement les fonctionnalités d'App Router.
La fonction getServerSideProps
est utilisée pour récupérer les données côté serveur et passer le résultat au composant de la page. Ainsi, lors de la migration, il peut être tentant de convertir la fonction en un gestionnaire de route et de la consommer à l'aide d'un appel API fetch
. Mais en ajoutant les composants serveur React, vous pouvez placer le code de récupération des données côté serveur directement dans un composant serveur.
Dans l'exemple suivant, la fonction getServerSideProps
est utilisée pour récupérer des données côté serveur et les transmettre au composant de page Orders
, et le composant Orders
est ensuite utilisé pour rendre les données côté client :
// pages/orders.js (Pages Router) import React from "react" ; export default function Orders({ orders }) { return ( <div><h1>Vos commandes</h1> <ul> {commandes.map((commande) => ( <li key={commande.id}> <p>Commande #{commande.id} : {commande.item} </p></li> ))}</ul> </div> ) ; } // Récupérer les données de la page avec getServerSideProps export async function getServerSideProps() { const res = await fetch("https ://api.example.com/orders") ; const orders = await res.json() ; return { props : { orders, }, } ; }
Dans l'App Router, vous pouvez récupérer les données relatives aux commandes directement dans le composant serveur OrdersPage
et les rendre côté serveur :
// src/app/orders/page.tsx (App Router, Server Component) export default async function OrdersPage() { // Récupérer les données directement dans le composant serveur const res = await fetch("https://api.example.com/orders") ; const orders = await res.json() ; return ( <div> <h1>Vos commandes</h1> <ul> {orders.map((order) => ( <li key={order.id}> <p>Commande #{commande.id} : {commande.item} </p> </li> ))} </ul> </div> ) ; }
De même, la fonction getStaticPaths
du routeur Pages est utilisée pour générer une liste de chemins pour une route dynamique rendue statique. Dans l'App Router, vous utilisez la fonction generateStaticParams
pour générer un tableau d'objets params pour les routes dynamiques, comme suit :
import PostLayout from "./components/post-layout" ; export async function generateStaticParams() { return [{ id : "1" }, { id : "2" }] ; } async function getPost(params) { const res = await fetch(`https://api.example.com/posts/${params.id}`) ; const post = await res.json() ; return post ; } export default async function Post({ params }) { const post = await getPost(params) ; return <PostLayout post={post} /> ; }
Bien que l'utilisation de composants client dans des composants serveur offre d'importantes possibilités de composition, elle peut aussi parfois brouiller les frontières entre le code client et le code serveur. Ce flou crée un risque d'exposition involontaire de code serveur sensible ou de secrets au client en les incluant par erreur dans le composant client.
Pour éviter que cela ne se produise, utilisez toujours la directive use server
en tête des déclarations des fonctions d'action du serveur. Mieux encore, créez un fichier actions.ts
distinct pour contenir toutes les actions du serveur et utilisez la directive use server
au niveau supérieur ou au niveau du module.
Vous pouvez également utiliser le paquetage npm server-only
pour déclarer explicitement un module réservé au serveur. Par exemple, si vous avez un module de récupération de données appelé src/data.ts
, vous pouvez installer le module server-only
et l'utiliser comme suit pour exclure le code du bundle côté client même s'il est importé par erreur :
import "server-only" ; export async function getUsers() { const res = await fetch("https://jsonplaceholder.typicode.com/users") ; return res.json() ; }
Dans l'App Router, vous pouvez créer des routes API directement en créant un nouveau fichier route.ts
au chemin que vous souhaitez. À partir de ce fichier, vous pouvez exporter différentes méthodes HTTP, telles que GET
, POST
, PUT
, etc.
Pour la méthode GET
, vous pouvez configurer la mise en cache en exportant l'option dynamic
route config. Par exemple, la route suivante sera mise en cache et revalidée toutes les soixante secondes :
export const dynamic = "force-static" ; export const revalidate = 60 ; export async function GET() { const res = await fetch("https://jsonplaceholder.typicode.com/users") ; const data = await res.json() ; return Response.json({ data }) ; }
Une mise en garde s'impose lorsque l'on utilise force-static
: les fonctions dynamiques, telles que les en-têtes
ou les cookies
, renverront des valeurs vides, de sorte que vous ne pourrez pas les utiliser.
La plupart des routes de l'API nécessitent un ou plusieurs paramètres dynamiques pour identifier la ressource demandée. Vous pouvez utiliser le paramètre slug
pour gérer cela. Vous pouvez définir les slugs de la manière suivante :
app/users/[slug]/route.ts
. Ce format correspondra à /users/1
, /users/2
, etc.app/users/[...slug].ts
. Ce format permet de capturer tous les segments dynamiques, ce qui signifie que cette route correspondra à /users/1
, /users/1/orders
, /users/1/orders/1
, et ainsi de suite.app/users/[[...slug]].ts
. Ceci est similaire à un fourre-tout mais correspondra également à /users
car les segments dynamiques sont optionnels.Vous pouvez accéder à la balise slug
à partir de la propriété params
dans les layouts, les pages et les routes. Par exemple, la pagesrc/app/blog/[slug]/page.tsx
peut accéder au slug
à partir de la propriété params
comme suit :
// src/app/blog/[slug]/page.tsx export default function Page({ params } : { params : { slug : string } }) { return <div>My Post : {params.slug}</div> ; }
De la même manière, vous pouvez accéder au slug
à partir de l'option params
dans un gestionnaire de route GET
, comme suit :
export async function GET( request : Request, { params } : { params : Promise<{ slug : string }> } ) { const slug = (await params).slug ; }
Dans cet article, nous avons couvert certains des pièges les plus courants lors de l'utilisation de Next.js App Router et discuté des moyens de les résoudre et d'améliorer l'expérience globale de l'utilisateur.
Si vous développez des applications Next.js, vous pouvez les déployer sur Upsun, qui offre des fonctionnalités telles que la mise à l'échelle verticale et horizontale, la mise en cache des bords et l'observabilité dès le départ.