Accueil / Articles / Next.js moderne détaillé : ce que remplace chaque fonctionnalité et quand l’utiliser

Next.js moderne détaillé : ce que remplace chaque fonctionnalité et quand l’utiliser

Une visite guidée de huit fonctionnalités de Next.js, allant des composants serveur à l’API Metadata, montrant quel ancien modèle chacune remplace et où cela peut poser des problèmes.

3113 mots

Next.js gère désormais le routage, la récupération de données, le cache, la stratégie de rendu ainsi que une grande partie des fonctionnalités API. Pourtant, les équipes l’adoptent souvent tout en conservant de vieilles habitudes : récupération de données dans useEffect, création manuelle de routes API pour chaque formulaire, règles de cache que personne ne parvient à expliquer. Ce guide couvre huit fonctionnalités, le modèle ancien qu’elles remplacent, ainsi que les problèmes qui surviennent dans des projets réels, afin de vous permettre de décider, fonction par fonction, ce qui doit figurer dans votre codebase.

Pourquoi le framework couvre désormais toute la stack

Walmart, Nike, TikTok, OpenAI et Airbnb utilisent Next.js pour leurs applications web, et l’avantage réside dans la consolidation : le routage, le regroupement des fichiers, les modes de rendu, l’accès aux données et les points d’entrée serveur partagent un même répertoire et un ensemble de conventions communes. Le choix d’un routage ou la configuration d’un outil de regroupement des fichiers ne font plus partie des étapes initiales d’un projet, ce qui permet de consacrer plus de temps au produit lui-même.

1. Les composants serveur font du serveur l’endroit par défaut pour le rendu

Les composants serveur React (RSC) représentent le plus grand changement architectural de cette liste. Un composant serveur s’exécute uniquement sur le serveur et envoie le résultat rendu au navigateur, de sorte que son code ne fait jamais partie du bundle JavaScript client.

Qu’est-ce qui disparaît d’une page pilotée par des données

Dans le React classique rendu côté client, même un composant qui se contente de lire des données et d’afficher une liste ajoute son code, sa logique de récupération des données ainsi que ses dépendances au bundle, avant d’être hydraté dans le navigateur. Avec les RSC, le composant lit les données sur le serveur et n’envoie que le résultat. Dans l’App Router, chaque composant est un composant serveur sauf indication contraire, c’est pourquoi le fichier ci-dessous n’a besoin d’aucune directive :

// app/products/page.tsx
// This component runs ONLY on the server. No "use client" directive needed.

La page est une fonction async qui interroge directement la base de données et renvoie du markup. Il n’y a ni route API intermédiaire ni requête côté client :

import { db } from "@/lib/db";export default async function ProductsPage() {
  // Direct database access. No API route. No fetch boilerplate.
  const products = await db.product.findMany({ take: 20 });  return (
    <main>
      <h1>Our Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            <h2>{product.name}</h2>
            <p>${product.price}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

Aucun useState, aucun useEffect, pas de drapeau de chargement, pas de fetch vers son propre backend. Moins de code s’exécute dans le navigateur, l’HTML arrive complet (ce qui est avantageux pour les moteurs de recherche), et il y a moins à maintenir. Comme ce code interagit directement avec la base de données, ne l’importez jamais dans un fichier client ; un module de données réservé au serveur rend cette frontière explicite.

Où les composants clients ont encore leur place

Vous avez toujours besoin de composants clients pour tout ce qui est interactif : gestionnaires d’événements, état, effets, ainsi que des API du navigateur comme localStorage. Vous identifiez un tel fichier en plaçant la directive "use client" en haut :

// components/AddToCartButton.tsx
"use client";

Le bouton ci-dessous conserve un état local et réagit aux clics, ce qui correspond exactement au type d’opération qui doit avoir lieu dans le navigateur :

import { useState } from "react";export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);  return (
    <button onClick={() => setAdded(true)}>
      {added ? "Added!" : "Add to Cart"}
    </button>
  );
}

Commencez par les composants serveur et n’ajoutez des éléments interactifs que là où c’est nécessaire, en plaçant "use client" le plus bas possible dans la hiérarchie : une page serveur contenant un petit bouton client génère bien moins de JavaScript qu’une page qui est entièrement un composant client depuis le début. Pour en savoir plus sur le fonctionnement interne du modèle de rendu, consultez l’architecture derrière le rendu sans bundle.

2. Les actions serveur remplacent les routes API pour les mutations

Les actions serveur vous permettent d’écrire une fonction qui s’exécute sur le serveur et de l’appeler directement depuis un composant. Next.js génère l’endpoint HTTP, serialize les arguments et renvoie le résultat, vous évitant ainsi de devoir gérer une route distincte uniquement pour traiter la soumission d’un formulaire.

Le modèle à deux fichiers qu’il remplace

Auparavant, toute mutation nécessitait un gestionnaire dans le dossier api du Pages Router, qui devait lire le corps de la requête, écrire dans la base de données et répondre en JSON :

// You needed an API route
// pages/api/create-post.ts
export default async function handler(req, res) {
  const { title, content } = req.body;
  await db.post.create({ data: { title, content } });
  res.status(200).json({ success: true });
}

Le composant devait alors appeler manuellement cet endpoint, en serialisant lui-même le payload :

// Then in your component:
const response = await fetch("/api/create-post", {
  method: "POST",
  body: JSON.stringify({ title, content }),
});

Une action validée placée à côté de son formulaire

Avec les actions serveur, la page importe ce dont elle a besoin, y compris Zod pour la validation des entrées :

// app/posts/new/page.tsx
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { z } from "zod";

L’action est déclarée à côté du formulaire. La directive "use server" à l’intérieur du corps de la fonction la marque comme du code réservé au serveur ; le formulaire la transmet à sa propriété action. L’action valide FormData à l’aide de safeParse, renvoie des erreurs de champ en cas d’échec de la validation, et sinon écrit les données envoyées avant de rediriger :

const schema = z.object({
  title: z.string().min(3, "Title must be at least 3 characters"),
  content: z.string().min(10, "Content is too short"),
});async function createPost(formData: FormData) {
  "use server";  const parsed = schema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }  await db.post.create({ data: parsed.data });
  redirect("/posts");
}export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Write something..." required />
      <button type="submit">Publish</button>
    </form>
  );
}

La validation est essentielle car une action serveur constitue un point d’accès public que n’importe qui peut appeler avec des données arbitraires. Pour la même raison, les vérifications d’autorisation doivent se trouver à l’intérieur de l’action elle-même ; pourquoi les actions serveur nécessitent une autorisation à l’intérieur de chaque corps de fonction explique cela en détail. Notez également qu’aucun élément ici ne lit l’objet d’erreur retourné, de sorte qu’une validation échouée ne se manifeste pas. Le modèle suivant corrige ce problème.

Affichage des erreurs et de l’état en attente avec useActionState

Lorsque le formulaire doit afficher des messages de validation ou désactiver le bouton pendant qu’une demande est en cours, placez-le dans un composant client et enveloppez l’action avec le hook useActionState de React :

"use client";

Le hook renvoie l’état le plus récent généré par l’action, un formAction enveloppé à transmettre au formulaire, ainsi qu’un indicateur isPending. Le composant affiche la première erreur par champ et change l’étiquette du bouton pendant que la soumission est en cours :

import { useActionState } from "react";
import { createPost } from "./actions";export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);  return (
    <form action={formAction}>
      <input name="title" placeholder="Post title" />
      {state?.error?.title && (
        <p className="text-red-500">{state.error.title[0]}</p>
      )}
      <textarea name="content" placeholder="Write something..." />
      {state?.error?.content && (
        <p className="text-red-500">{state.error.content[0]}</p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? "Publishing..." : "Publish"}
      </button>
    </form>
  );
}

Détail souvent source de confusion : lorsque l’on utilise une action via useActionState, React la lance en passant l’état précédent comme premier argument et FormData comme deuxième. La fonction createPost présentée précédemment ne prend que formData ; par conséquent, une version exportée depuis ./actions pour cet hook nécessite la signature (prevState, formData). De plus, cette action doit se trouver dans un fichier distinct ayant "use server" en haut, car un composant client ne peut pas définir de fonctions serveur directement.

3. Turbopack raccourcit le cycle de retour d’information en développement

Pendant longtemps, webpack a dicté les règles du développement local. Fast Refresh était pratique une fois le serveur en marche, mais les démarrages initiaux dans les applications volumineuses pouvaient prendre de 30 à 60 secondes. Turbopack est un outil de bundling basé sur Rust, développé par Vercel, conçu pour éliminer ce goulot d’étranglement.

Turbopack a atteint un taux de réussite de 100 % sur les 8 298 tests d’intégration de Next.js, et les équipes rapportent que les démarrages initiaux en développement ne durent plus que moins de 3 secondes, alors qu’auparavant cela prenait plus d’une minute. Son étiquette de maturité évolue rapidement entre les versions, il est donc nécessaire de consulter la documentation actuelle pour connaître son statut dans votre version, en particulier pour les builds de production.

L’activer

Pour l’utiliser en développement, il suffit d’activer un paramètre dans le script de développement :

// package.json
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build"
  }
}

Aucune configuration supplémentaire n’est nécessaire. Turbopack effectue des calculs incrémentaux, ne reconstruisant que ce qui a changé, ce qui profite le plus aux projets de grande taille. Si vous utilisez des chargeurs ou des plugins personnalisés de webpack, vérifiez d’abord s’il existe des équivalents dans Turbopack. Notre comparaison des outils de bundling examine ces compromis.

4. Le pré-rendering partiel combine un noyau statique avec des données en flux

Le pré-rendering partiel (PPR) sert immédiatement un noyau HTML statique préconstruit et y ajoute progressivement les parties dynamiques de la même page, tout cela au sein d’une seule réponse.

Sur une page de produit, le layout, la navigation et la description restent identiques pour tous ; en revanche, le prix personnalisé, le panier d’achats et le nombre de stocks varient. PPR envoie d’abord le noyau et complète progressivement les éléments spécifiques à chaque demande au fur et à mesure qu’ils sont prêts.

La page importe un composant statique et deux composants dynamiques :

// app/product/[id]/page.tsx
import { Suspense } from "react";
import { ProductDetails } from "./ProductDetails"; // static
import { PersonalizedPrice } from "./PersonalizedPrice"; // dynamic
import { StockStatus } from "./StockStatus"; // dynamic

La frontière entre le statique et le dynamique est définie avec Suspense. Tout ce qui se trouve en dehors de cette frontière peut être prérendu ; chaque solution de repli Suspense devient un placeholder dans le shell qui est remplacé une fois que son enfant a terminé sa rendu sur le serveur :

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* This renders statically - instant */}
      <ProductDetails id={params.id} />      {/* These stream in dynamically */}
      <Suspense fallback={<div>Loading price...</div>}>
        <PersonalizedPrice productId={params.id} />
      </Suspense>      <Suspense fallback={<div>Checking stock...</div>}>
        <StockStatus productId={params.id} />
      </Suspense>
    </div>
  );
}

Notez que params est ici défini comme un objet simple. Dans les versions récentes de Next.js, params est transmis sous forme de Promise et doit être attendu, il convient donc d’adapter cette signature en fonction de la version ciblée.

PPR a été introduit via un paramètre expérimental dans la configuration de Next.js, à partir de l’import des types :

// next.config.ts
import type { NextConfig } from "next";

puis via le paramètre lui-même :

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
};export default nextConfig;

Lors de la rédaction de ce texte, ce paramètre a été réorganisé dans les versions plus récentes (le comportement PPR dépend désormais de la configuration Cache Components dans Next.js 16) ; par conséquent, considérez ce fragment comme illustratif et vérifiez le nom actuel de l’option. Quoi qu’il en soit, la page semble statique car son shell est mémorisé en cache, tandis que ses données restent à jour. Nous abordons plus en détail ces mécanismes dans partial pre-rendering and concurrent rendering explained.

5. L’utilisation de la directive cache rend le stockage en cache explicite

Next.js 13 et 14 utilisaient par défaut un stockage en cache agressif, ce qui a conduit de nombreuses équipes à découvrir des pages obsolètes en production sans cause apparente. Next.js 16 adopte une approche basée sur l’activation volontaire du stockage en cache grâce à Cache Components et à la directive use cache : vous indiquez ce qui doit être mémorisé en cache au lieu de deviner ce qui l’est déjà.

En plaçant la directive en haut d’un composant asynchrone, son contenu rendu est mémorisé :

// A component that caches its output for 1 hour
async function PopularArticles() {
  "use cache";

Le reste du composant effectue des requêtes et affiche le contenu comme d’habitude :

  const articles = await fetch("https://api.example.com/popular-articles").then(
    (r) => r.json()
  );  return (
    <ul>
      {articles.map((article: { id: string; title: string }) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  );
}

Le commentaire mentionne une heure, mais la directive seule ne définit pas de durée ; cette durée provient d’un profil de cache défini avec cacheLife, sinon un profil par défaut est utilisé. Les profils personnalisés sont déclarés dans la configuration :

// next.config.ts
const nextConfig = {
  experimental: {
    cacheLife: {
      "stale-for-a-day": {
        stale: 60 * 60, // 1 hour
        revalidate: 60 * 60 * 24, // 1 day
        expire: 60 * 60 * 24 * 7, // 1 week
      },
    },
  },
};

Ces trois valeurs répondent à des questions différentes. stale indique la durée pendant laquelle un client peut utiliser sa copie sans vérifier le serveur, revalidate précise la fréquence à laquelle le serveur met à jour en arrière-plan l’entrée correspondante, et expire désigne le moment après lequel cette entrée est supprimée et pour laquelle la prochaine requête doit attendre des données fraîches. Le fait que cacheLife soit classé dans la catégorie experimental dépend de votre version. Pour l’invalidation basée sur des balises, nécessaire lorsque les données changent lors d’une écriture, consultez notre guide sur l’utilisation du cache et de la révalidation basée sur des balises.

6. Fonctionnalités de streaming IA avec l’AI SDK

L’AI SDK de Vercel s’intègre aux Route Handlers et aux hooks React, ce qui permet de rendre le streaming de chat, la recherche assistée par l’IA et les interfaces utilisateur générées des éléments ordinaires du code d’une application.

Sur le serveur, un gestionnaire de route importe streamText ainsi qu’un fournisseur de modèle :

// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";

Le gestionnaire POST lit la conversation depuis le corps de la requête, démarre une transmission en flux continu et la renvoie sous forme de réponse streamée (la parenthèse fermante de la fonction est coupée dans l’extrait) :

export async function POST(req: Request) {
  const { messages } = await req.json();  const result = streamText({
    model: openai("gpt-4o"),
    messages,
  });  return result.toDataStreamResponse();

Sur le client, la page de chat est un composant client car elle gère l’état des entrées :

// app/chat/page.tsx
"use client";

Le hook useChat gère la liste des messages, la valeur d’entrée et leur envoi, ainsi que le réaffichage à mesure que de nouveaux tokens arrivent :

import { useChat } from "ai/react";export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit } = useChat();  return (
    <div>
      <div>
        {messages.map((m) => (
          <div key={m.id}>
            <strong>{m.role}:</strong> {m.content}
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} placeholder="Ask anything..." />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

Cela correspond à environ 30 lignes pour un chat en streaming, avec un gestionnaire de route au backend et useChat au frontend. Gardez à l’esprit que l’API du SDK d’IA évolue rapidement : dans les versions majeures plus récentes, le hook est importé depuis @ai-sdk/react, l’état des entrées doit être géré manuellement, et l’outil de réponse porte un nom différent. Vérifiez les versions que vous utilisez et consultez la documentation du SDK avant de copier ce code. Pour des interfaces de type agent avec des outils et plusieurs étapes, consultez comment créer des interfaces d’agent IA à plusieurs étapes avec Next.js et le SDK d’IA.

7. Modèles App Router pour des layouts complexes

L’App Router, introduit dans Next.js 13, est désormais la méthode standard pour développer des applications Next.js. Deux de ses fonctionnalités remplacent ce qui nécessitait auparavant une gestion personnalisée de l’état.

Itinéraires parallèles pour des panneaux de tableau de bord indépendants

Les itinéraires parallèles affichent plusieurs pages au sein d’un même layout en même temps. Chaque dossier préfixé de @ définit une case nommée :

app/
  dashboard/
    @analytics/
      page.tsx
    @recent/
      page.tsx
    layout.tsx
    page.tsx

Le layout reçoit chaque case en tant que propriété supplémentaire à children et les place dans la grille :

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  recent,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  recent: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="col-span-2">{children}</div>
      <aside>
        {analytics}
        {recent}
      </aside>
    </div>
  );
}

Puisque chaque case constitue un segment d’itinéraire distinct, elle charge ses propres données et peut disposer de ses propres états de chargement et d’erreur. Une requête d’analyse lente n’affecte pas le panneau des activités récentes. Un piège : lorsqu’on navigue vers un sous-path que la case ne définit pas, Next.js a besoin d’un default.tsx dans cette case pour savoir quoi afficher lors d’un rechargement forcé.

Interception des itinéraires pour des modaux avec des URL réelles

Un modèle d’interface courant ouvre un élément dans une fenêtre modale tandis que l’URL est mise à jour pour correspondre à cet élément, afin de permettre son partage. Les interceptions des routes gèrent cela grâce à des conventions de dossiers :

app/
  photos/
    [id]/
      page.tsx      // Full page view at /photos/123
    (..)[id]/
      page.tsx      // Intercepted modal view
  page.tsx

Lorsqu’un utilisateur clique depuis le tableau, le dossier d’interception capture la navigation côté client et affiche la version modale. Lorsque quelqu’un ouvre directement l’URL ou la met à jour, c’est plutôt toute la page ordinaire qui s’affiche, sans aucune manipulation manuelle de l’historique. Les marqueurs (.), (..) et (...) sont relatifs aux segments de route, et non aux dossiers du système de fichiers. La fenêtre modale est généralement affichée via un slot @modal parallèle ; il convient donc de consulter la documentation sur le routage pour connaître exactement le layout requis par votre structure.

8. Primitifs SEO : métadonnées et images

L’API Metadata transforme le SEO en code ordinaire qui se trouve à côté de la page qu’il décrit. Après avoir importé le type Metadata :

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";

une page exporte la fonction generateMetadata, qui charge l’article et renvoie le titre, la description, les données Open Graph ainsi que la carte Twitter :

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPost(params.slug);  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage }],
      type: "article",
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

Les aperçus sociaux, les URLs canoniques et les données structurées proviennent des mêmes données que celles affichées par la page. Si la fonction getPost est également appelée par le composant de page, il faut éliminer les doublons de requête (par exemple à l’aide du cache de React) afin que la base de données ne soit pas consultée deux fois.

Pour les images, next/image doit être votre solution par défaut :

import Image from "next/image";

Il s’charge de manière différée par défaut, fournit des variantes de taille appropriée et se convertit en WebP ou AVIF lorsque le navigateur les prend en charge. Définir explicitement des valeurs pour width et height permet également d’allouer de l’espace et d’éviter les décalages de mise en page :

export function ProductCard({ product }: { product: Product }) {
  return (
    <div>
      <Image
        src={product.imageUrl}
        alt={product.name}
        width={400}
        height={300}
        priority={false} // set true for above-the-fold images
      />
      <h2>{product.name}</h2>
    </div>
  );
}

Ne mettez priority à true que pour l’image visible en haut de la page, généralement l’image principale ou celle du produit phare, afin que le navigateur la télécharge tôt.

Un ordre d’apprentissage logique

Chaque étape s’appuie sur la précédente :

  1. Conventions de nommage des fichiers dans App Router : layout.tsx, page.tsx, loading.tsx et error.tsx.
  2. Les composants serveur et l’emplacement de la frontière client.
  3. Les actions serveur avec validation Zod pour chaque modification.
  4. Suspense et streaming pour gérer les états de chargement de manière déclarative.
  • Rendu préalable partiel pour le modèle statique-plus-dynamique.
  • Turbopack en développement pour des itérations plus rapides.
  • Points clés

    • Considérez le serveur comme environnement d’exécution par défaut et appliquez "use client" jusqu’aux éléments interactifs les plus simples.
    • Les actions serveur sont des points d’entrée : validez les entrées et vérifiez les permissions à l’intérieur de chacune d’elles.
    • Définissez vos limites statiques et dynamiques avec Suspense ; le PPR ainsi que le cache s’appuient tous deux sur celles-ci.
    • Préférez un cache explicite avec use cache et des profils cacheLife nommés plutôt que de compter sur les paramètres par défaut.
    • Beaucoup de ces API ont changé entre les versions récentes, il est donc nécessaire de vérifier les flags et les signatures en fonction de la version que vous utilisez réellement.

    Que vous commenciez à zéro ou que vous migriez une application importante vers App Router, adopter ces éléments un par un représente une approche à faible risque.

    Lectures complémentaires