Next.js 16, la fin du cache que personne ne comprenait

Next.js 16 inverse le cache : avec cacheComponents activé, rien n'est mis en cache par défaut. Composants serveur, use cache, Turbopack, proxy et migration.

L'équipe CodeWorlds15 min de lecture ·

Pratique Next.js dans des leçons interactives avec un éditeur de code.

Crée un compte gratuit→
Dans cet article11

La plus grande source de frustration avec les versions précédentes de ce framework n'était ni les composants serveur ni les server actions, mais le cache. Il s'appliquait par défaut sur plusieurs couches à la fois, et répondre à « pourquoi je vois des données obsolètes » demandait de connaître quatre mécanismes différents.

La version 16 inverse ce modèle. Une fois une option activée dans la configuration, rien n'est mis en cache par défaut, et ce qui doit l'être, tu le marques explicitement. Le changement paraît mineur et c'est pourtant le plus important de ce framework depuis l'arrivée de l'app router.

Le cache explicite

Avant que quoi que ce soit ne fonctionne, le modèle doit être activé dans la configuration, et c'est la raison la plus fréquente pour laquelle les exemples trouvés en ligne finissent par une erreur de build. Tout le mécanisme se cache derrière une seule option.

TypeScript
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true
}

export default nextConfig

Sans elle, la directive de cache, la durée de vie et le tag sont indisponibles, et le projet se comporte comme dans la version précédente, où les réglages du segment de route décidaient du cache. Cette même option active aussi le rendu préalable partiel décrit plus bas, puisque le flag séparé qui s'en chargeait auparavant a été supprimé. Le modèle exige également le runtime Node, donc les routes marquées pour l'edge doivent d'abord être déplacées.

Une directive placée dans une fonction ou un composant indique que son résultat doit être mémorisé.

TypeScript
async function getArticles(category: string) {
  'use cache'
  cacheLife('hours')
  cacheTag(`articles-${category}`)

  const data = await db.articles.findMany({ where: { category } })
  return data
}

Trois choses se passent ici explicitement. La première, c'est la mise en cache elle-même. La deuxième, c'est la durée de vie de l'entrée, donnée sous la forme d'un profil nommé plutôt que d'un nombre de secondes éparpillé dans le code. La troisième, c'est un tag qui permet d'invalider cette entrée précise après un changement de données.

TypeScript
'use server'

export async function saveArticle(data: FormData) {
  await db.articles.create({ data: process(data) })
  revalidateTag(`articles-${data.get('category')}`)
}

Cette construction résout le problème le plus fréquent des versions précédentes : des données modifiées par un utilisateur qui n'apparaissent jamais dans une liste, parce que la couche de cache ne sait rien du changement. L'invalidation se trouve maintenant visiblement dans le code, juste à côté de l'écriture.

Le prix de cette explicité se résume à une chose : une page qui était rapide par accident devient lente jusqu'à ce que tu la marques explicitement. Pendant une migration, c'est la surprise la plus fréquente.

Le rendu préalable partiel

Le second mécanisme travaille avec le premier et mérite d'être compris, car c'est de lui que vient la majeure partie du gain.

Une page se compose habituellement d'une partie immuable et d'une partie dépendante de l'utilisateur. L'en-tête, le pied de page, la description d'un produit et le corps d'un article sont identiques pour tout le monde. Un panier, une salutation par prénom et une liste de produits récemment consultés sont individuels.

L'approche classique force un choix : soit la page entière est statique et n'a aucune partie individuelle, soit elle est entièrement dynamique et se régénère à chaque requête. Le rendu préalable partiel permet d'avoir les deux : une coquille statique atteint l'utilisateur immédiatement, tandis que les fragments dépendants de la requête suivent juste après.

TypeScript
export default function ProductPage({ params }) {
  return (
    <>
      <ProductDescription id={params.id} />
      <Suspense fallback={<BasketSkeleton />}>
        <UserBasket />
      </Suspense>
    </>
  )
}

La limite est marquée par la frontière de suspense. Tout ce qui est en dehors doit pouvoir être généré à l'avance, donc accéder aux en-têtes de requête ou aux cookies au-delà de cette limite est une erreur que le framework signale au moment du build.

Turbopack par défaut

Le nouveau bundler n'est plus une option activée par un flag et devient le choix par défaut, en développement comme pour les builds de production.

La différence se ressent sur les gros projets. Le rafraîchissement après un changement de code se compte en dizaines de millisecondes plutôt qu'en secondes, et les builds de production raccourcissent plusieurs fois. Sur un projet de vingt composants, tu ne le remarqueras pas ; sur un projet de deux cents, la différence change le rythme de travail.

Un cache sur disque est arrivé aussi, donc un démarrage ultérieur après un redémarrage ne repart pas de zéro. Ça compte pour le travail avec des branches, où basculer entre elles signifiait auparavant un rebuild complet.

Vérifie ta propre configuration lors du passage. Les projets utilisant des réglages personnalisés pour l'ancien bundler doivent les transposer, et certains plugins n'ont pas d'équivalent. Sur un projet classique sans configuration personnalisée, le passage est imperceptible, puisqu'il fonctionne par défaut.

Le nom trompe dans les monorepos. Turbopack empaquette le code d'une seule application, tandis que Turborepo répartit les tâches entre les paquets et mémorise leurs résultats, donc un paquet que personne n'a touché n'est pas reconstruit une seconde fois. Le gain n'apparaît qu'à partir du moment où plusieurs applications ou bibliothèques partagent le dépôt ; avec une seule, ce n'est que de la configuration.

Changements cassants

Trois choses demandent de l'attention pendant la migration et méritent d'être connues avant de commencer.

La première, c'est la couche intermédiaire. Le fichier qui traite les requêtes avant qu'elles n'atteignent une route a changé de nom, pour qu'il soit clair qu'il s'exécute à la frontière réseau plutôt qu'à l'intérieur de l'application. C'est un renommage de fichier, plus une relecture de ce qu'il contient, car une partie de ce code n'y a jamais vraiment eu sa place.

La deuxième, c'est la version requise de la bibliothèque d'interface. Le framework a besoin d'une version contenant les mécanismes sur lesquels repose le rendu préalable partiel, donc monter l'un entraîne l'autre.

La troisième, c'est le cache, déjà évoqué plus haut. Monter la version seule ne le change pas encore, car le nouveau modèle s'active par une option séparée. Une fois celle-ci activée, le code qui s'appuie sur l'ancien comportement fonctionne toujours, juste plus lentement, puisqu'il ne met plus rien en cache. Ce n'est pas une erreur que tu vois dans une console, mais une hausse du temps de réponse et de la facture des fonctions, remarquée généralement après une semaine ou à la première facture. Vérifie au passage la version du runtime, car la version 16 exige Node 20.9 ou plus récent.

L'ordre pratique de migration : monte les versions, lance le projet, relis les temps de réponse, puis ajoute des marqueurs de cache là où tu observes une dégradation. L'ordre inverse, c'est-à-dire tout marquer d'avance, conduit à mettre en cache des choses qui ne devraient pas l'être.

Les composants serveur en pratique

La séparation entre composants serveur et composants client est avec nous depuis plusieurs versions et continue de causer des malentendus.

Par défaut, un composant est côté serveur. Il s'exécute sur le serveur, a accès à la base de données et aux secrets, et le navigateur reçoit le résultat plutôt que le code. La directive marquant un composant comme côté client n'est nécessaire que lorsque tu as besoin d'état, d'effets ou d'événements du navigateur.

L'erreur la plus fréquente consiste à marquer des composants comme côté client trop haut dans l'arbre. La directive s'applique à tout le sous-arbre, donc marquer un layout fait basculer tout ce qui est dessous vers le navigateur, y compris du code qui aurait pu rester sur le serveur.

La bonne approche consiste à faire descendre cette frontière le plus bas possible. Un bouton interactif est un composant client, tandis que la liste qui le contient reste côté serveur. Avec des bibliothèques de composants, Mantine par exemple, ça veut dire généralement les envelopper dans tes propres composants client plutôt que de marquer des pages entières.

Server actions

Un mécanisme qui permet d'appeler une fonction serveur directement depuis un composant, sans écrire de route d'interface de programmation. Pratique, et ça mérite d'être compris, car c'est parfois utilisé sans précaution.

TypeScript
'use server'

import { auth } from '@/lib/auth'

export async function deleteComment(id: string) {
  const session = await auth()
  if (!session) throw new Error('Not authenticated')

  const comment = await db.comments.findUnique({ where: { id } })
  if (comment.authorId !== session.userId) throw new Error('Not permitted')

  await db.comments.delete({ where: { id } })
  revalidateTag('comments')
}

Les deux vérifications au début ne sont pas superflues. Une server action est un point d'accès joignable depuis l'extérieur, donc n'importe qui peut l'appeler avec n'importe quel identifiant. Cacher un bouton dans l'interface ne protège rien.

La deuxième chose, c'est la validation des entrées. Les arguments arrivent depuis le navigateur, donc un type dans la signature de la fonction est une déclaration, pas une garantie. Un schéma qui vérifie les entrées est obligatoire ici, exactement comme sur une route ordinaire.

La troisième, c'est la gestion d'erreur visible pour l'utilisateur. Une exception levée dans une action atteint le composant, et sans traitement, elle finit en message générique, donc il vaut mieux renvoyer un résultat décrivant ce qui s'est mal passé.

Images et polices

Deux mécanismes intégrés qui ont le plus d'effet sur les mesures de vitesse, et pourtant souvent négligés.

Le composant d'image choisit la taille selon l'appareil, convertit le format et réserve l'espace pour que la mise en page ne saute pas après le chargement. Ce dernier point compte le plus, car le déplacement du contenu pendant le chargement est l'une des métriques mesurées.

TypeScript
import Image from 'next/image'

<Image src="/product.jpg" alt="Product view" width={800} height={600} priority />

Le marqueur de priorité s'applique aux images visibles sans défilement. Sans lui, le navigateur les charge avec tout le reste, donc le plus grand élément de la page apparaît plus tard qu'il ne le pourrait, et ça, c'est mesuré et ça pèse sur le score de vitesse.

Le mécanisme des polices les récupère au moment du build et les sert depuis ton propre domaine, plutôt que d'interroger un fournisseur externe à chaque visite. Ça supprime une connexion réseau et le scintillement du texte quand une police de remplacement bascule vers celle prévue, et au passage, un endroit où les données des visiteurs sortaient vers l'extérieur.

Next.js face aux alternatives

FrameworkPoint fortFaiblesseQuand le choisir
Next.jsComposants serveur, écosystème, déploiement chez son créateurComplexité, changements de modèle fréquentsUne application avec une interface et un back-end
AstroRésultat le plus léger, contenu au premier planMoins pratique avec beaucoup d'interactivitéSites de contenu, blogs, documentation
RemixModèle de données plus simple, plus proche des standards du webÉcosystème plus restreintUne application avec des formulaires et des données
Hono avec un front séparéSéparation des couches, légèretéDeux déploiements plutôt qu'unInterface de programmation et front-end séparés

Le choix entre les deux premières lignes dépend du rapport entre contenu et interactivité. Un blog ou une documentation n'a besoin ni d'une couche serveur ni de composants client sur chaque page, et un résultat plus léger se traduit directement par un temps de chargement plus court.

Envisage la dernière ligne une fois que le back-end grandit en produit autonome. Garder une interface de programmation dans le même projet que le front-end est pratique au début et devient une contrainte dès qu'autre chose commence à utiliser cette interface.

Il est juste d'ajouter que ce framework change son modèle de travail plus souvent que la concurrence. Le passage à l'app router, puis aux composants serveur, maintenant au cache explicite : chacun de ces changements a demandé de repenser un projet. C'est le prix à payer pour rester proche de ce qui se passe dans la bibliothèque d'interface, et ça mérite d'être compté sur un projet censé vivre plusieurs années sans changement majeur.

À l'inverse, aucun de ces changements n'a invalidé la version précédente du jour au lendemain. Le pages router fonctionne encore, et les applications des générations précédentes peuvent continuer d'évoluer sans migration. Le coût réside plutôt dans le fait que les contenus et exemples en ligne décrivent des versions différentes, donc trouver une réponse demande de vérifier à laquelle elle se rapporte.

Structure du projet

Un router basé sur des répertoires veut dire que la structure de fichiers est aussi la structure des adresses, et ça demande quelques décisions.

Les fichiers aux noms réservés portent un sens particulier : page, layout, état de chargement, gestion d'erreur, ressource absente. Les autres fichiers d'un répertoire de route sont du code ordinaire, donc des composants utilisés par une seule page peuvent se trouver à côté d'elle plutôt que dans un répertoire commun.

Les groupes de routes, écrits comme un nom entre parenthèses, permettent de diviser un projet sans influencer les adresses. L'usage typique sépare la partie publique d'un panneau derrière une connexion, où chacun a son propre layout tandis que les adresses restent plates.

app/
  (public)/
    layout.tsx
    page.tsx
  (panel)/
    layout.tsx
    settings/page.tsx

Décide dès le départ où vit le code qui n'est pas une route. Garder tout dans le répertoire des routes fonctionne à dix pages et devient illisible à cinquante, car distinguer un fichier qui gère une adresse d'un fichier auxiliaire devient difficile.

La gestion d'erreur est une affaire à part. Le fichier qui gère les erreurs capture les exceptions de son sous-arbre, donc son emplacement décide si un utilisateur voit un message à la place de la page entière ou seulement à la place d'une section.

Si le produit a besoin d'une documentation, tu n'as pas à installer un générateur séparé à côté ni à payer pour une plateforme. Fumadocs est une bibliothèque qui s'installe dans une application Next.js existante, donc la documentation partage les composants, le déploiement et le dépôt avec le produit, et elle peut aussi générer des pages à partir d'une définition OpenAPI. Deux choses valent d'être connues avant de décider : le rythme des changements est élevé, avec huit versions majeures sorties en moyenne tous les quatre-vingts jours, et le registre contient une version 17.0.0 publiée par erreur et marquée comme obsolète, donc une plage * ou le fait de viser le numéro le plus élevé mène dans une impasse.

Erreurs courantes

La première, c'est une directive client dans un layout. Elle fait basculer tout le sous-arbre vers le navigateur, donc les composants serveur cessent d'être côté serveur.

La deuxième, c'est accéder aux en-têtes de requête dans une partie censée être générée à l'avance. Le framework le signale au moment du build, mais la cause est parfois difficile à voir, car l'appel se trouve plusieurs niveaux plus bas.

La troisième, c'est migrer sans vérifier les temps de réponse. Le code fonctionne et a simplement arrêté de mettre en cache, tandis que la facture des fonctions serverless grimpe en silence.

La quatrième, c'est invalider des chemins entiers plutôt que des tags. Rafraîchir tout à chaque changement annule le gain du cache et charge le back-end.

La cinquième, ce sont des server actions sans vérification des permissions. Ce sont des points d'accès ordinaires joignables depuis l'extérieur, donc vérifier qui appelle doit se trouver à l'intérieur plutôt que dans l'interface qui les affiche.

La sixième, c'est récupérer des données dans un composant client là où un composant serveur le ferait mieux. Le résultat, c'est un aller-retour supplémentaire vers le serveur et un scintillement de l'état de chargement qui n'aurait pas besoin d'exister.

FAQ

Qu'est-ce qui a changé dans la version 16 ?

Le cache est devenu explicite : avec l'option cacheComponents activée dans la configuration, rien n'est mis en cache par défaut, et ce qui doit l'être se marque avec une directive portant une durée de vie et un tag. Cette même option a remplacé l'ancien flag de rendu préalable partiel. Le nouveau bundler est devenu le défaut en développement et en production, et le fichier de la couche intermédiaire a changé de nom.

Migrer depuis la version 15 est-il difficile ?

Généralement non, car le code qui s'appuie sur l'ancien comportement fonctionne toujours et arrête simplement de mettre en cache. Le travail principal consiste à relire les temps de réponse et à ajouter des marqueurs là où une dégradation apparaît, en plus de renommer le fichier de la couche intermédiaire.

Quand un composant doit-il être côté client ?

Quand il utilise de l'état, des effets, des événements du navigateur ou des interfaces disponibles seulement côté client. Dans tous les autres cas, laisse-le côté serveur, car le code n'atteint alors jamais le navigateur et accède directement à la base de données.

Next.js convient-il aux sites de contenu ?

Oui, bien que pour un blog ou une documentation, des frameworks plus légers donnent un résultat plus petit et un modèle plus simple. Ce framework l'emporte dès qu'une connexion, un panneau utilisateur ou un back-end rejoint le contenu, car il garde tout ça dans un seul projet.

Faut-il déployer chez le créateur du framework ?

Non, même si l'intégration y est la plus poussée et qu'une partie des fonctionnalités marche sans configuration. Les alternatives incluent Netlify, Cloudflare et un déploiement en conteneur sur ton propre serveur, et ça vaut le coup de vérifier la prise en charge du rendu préalable partiel chez le fournisseur choisi.

La documentation se trouve sur le site du projet, et les changements de la version 16 figurent dans l'annonce de sortie.

À lire aussi