TechniqueCarnet — Tharsan.me

Next.js écrase votre en-tête Vary : diagnostic et contournement

Sur Next.js 16, un Vary posé par le proxy ou par headers() n'apparaît pas sur les réponses de pages. Diagnostic, ce qui le conserve, et comment vivre avec.

Vishnukumar Tharsan· 5 min de lecture
  • next-js
  • http
  • cache
Croquis à l'encre : une enveloppe dont le tampon est barré d'une croix rouge, à côté d'un second tampon intact.

Vous posez Vary: Accept sur vos pages, vous relancez, vous inspectez la réponse : l'en-tête n'y est pas. À la place, Next renvoie son propre Vary avec les jetons de son routeur. Ce n'est ni votre configuration ni votre proxy qui sont en cause — c'est le comportement de Next.js 16 sur les réponses de pages de l'App Router. Les route handlers, eux, conservent parfaitement le Vary qu'on leur donne.

Voici comment je l'ai diagnostiqué, et ce que j'en ai fait.

Le symptôme

Le contexte : je sers une représentation Markdown de chaque page du site par négociation de contenu. Un client qui envoie Accept: text/markdown reçoit du Markdown à la même URL ; un navigateur reçoit du HTML. C'est une négociation classique, et elle impose une chose : la réponse doit porter Vary: Accept, faute de quoi un cache intermédiaire servira la mauvaise variante au client suivant.

J'ai donc posé l'en-tête. D'abord depuis le proxy :

const response = NextResponse.next()
response.headers.set("Vary", "Accept")
return response

Puis, sans succès, depuis la configuration :

async headers() {
  return [{ source: "/about", headers: [{ key: "Vary", value: "Accept" }] }]
}

Dans les deux cas, la réponse observée est la même :

Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch

Aucune trace d'Accept. Pas de message d'avertissement, pas d'erreur, rien dans les journaux.

Le diagnostic : isoler ce qui passe et ce qui ne passe pas

La première question utile n'est pas « pourquoi Next fait ça » mais « est-ce que mes autres en-têtes passent ? ». Si tout disparaît, c'est mon proxy qui ne s'exécute pas. Si seul Vary disparaît, c'est un traitement spécifique.

Mon proxy pose aussi un en-tête Link qui annonce la variante Markdown. Test :

curl -sI https://www.tharsan.me/about | grep -iE '^(vary|link):'

Le Link est là. Le Vary est remplacé. La cause est donc bien un traitement particulier de cet en-tête, et pas un proxy qui ne tourne pas.

Deuxième question : est-ce que c'est vrai partout ? J'ai comparé quatre types de réponses :

Type de réponseVary personnalisé conservé
Page App Router (proxy ou headers())Non — écrasé par les jetons du routeur
Réponse renvoyée directement par le proxy (404, 406)Oui
Route handler (app/**/route.ts)Oui
Réponse après réécriture vers un route handlerOui

Le motif est net : dès que la réponse est produite par le pipeline de rendu de page, le Vary est reconstruit à partir des besoins du routeur, et ce qu'on avait posé est perdu. Dès que la réponse sort d'un route handler ou du proxy lui-même, elle est intacte.

Troisième question, la plus importante : est-ce que c'est un artefact de développement ? Beaucoup de comportements d'en-têtes diffèrent entre next dev et un vrai build. J'ai vérifié en next dev, en next start sur un build de production, et enfin sur le déploiement réel. Même résultat aux trois niveaux. Ce n'est pas un artefact.

Cette dernière vérification est celle qu'on est tenté de sauter, et c'est celle qui évite de construire un contournement pour un problème qui n'existe qu'en local.

Pourquoi ce comportement existe

Il n'est pas absurde. L'App Router sert deux choses différentes à la même URL : un document HTML pour un chargement classique, et une charge utile React Server Components pour une navigation côté client. Cette variation dépend d'en-têtes internes — rsc, next-router-state-tree, next-router-prefetch.

Un cache qui ignorerait cette variation servirait une charge RSC à un navigateur demandant un document, ou l'inverse. Le framework a donc une raison légitime de contrôler cet en-tête : c'est une garantie de correction de son propre cache. La contrepartie est qu'il ne fusionne pas votre valeur avec la sienne.

Le contournement : déplacer la variante, pas l'en-tête

Le réflexe est de s'acharner à faire passer Accept dans le Vary du HTML. Mieux vaut se demander à quoi sert réellement cet en-tête ici. Il sert à empêcher un cache de servir la mauvaise variante. Si les deux variantes n'ont pas la même clé de cache, le problème disparaît de lui-même.

C'est ce que fait déjà mon architecture, sans que je l'aie conçue pour ça. Quand un client demande du Markdown, le proxy ne rend pas la page : il réécrit vers un route handler dédié qui sert le Markdown. Cette réponse-là est produite par un route handler, donc elle porte bien son Vary: Accept, plus un Content-Location qui nomme sa propre URL canonique. Et côté cache, la réécriture change la ressource servie.

Résultat : la variante Markdown est correctement identifiée et correctement mise en cache. Ce qui reste non conforme, c'est uniquement le Vary de la réponse HTML — le cas où un client réclame du HTML et reçoit du HTML. Le risque pratique est faible.

Trois options si votre situation est plus exigeante :

  1. Assumer et documenter. Les réponses Markdown sont conformes, la réponse HTML ne l'est pas. Un commentaire dans le code évite qu'un futur lecteur rejoue l'enquête. C'est mon choix.
  2. Poser l'en-tête à la couche d'hébergement. Beaucoup de plateformes permettent de définir des en-têtes hors du framework — c'est le seul endroit qui reste après le rendu de Next. À tester avec un déploiement par essai, ce qui rend la boucle lente.
  3. Séparer les URL pour de bon. Si la négociation est critique, servez le Markdown uniquement sur une URL distincte (/about.md) et abandonnez la négociation sur l'URL principale. Vous perdez l'élégance du « une ressource, deux représentations », vous gagnez un comportement de cache trivial à raisonner.

Ce que je retiens

La leçon générale dépasse cet en-tête : un framework qui gère son propre cache se réserve les en-têtes qui gouvernent ce cache. Vary est le cas le plus visible, mais le raisonnement vaut pour Cache-Control ou ETag. Quand un en-tête disparaît sans erreur, cherchez d'abord qui d'autre a une raison légitime de le contrôler.

Et la méthode de diagnostic, elle, est réutilisable telle quelle : vérifier que les autres en-têtes passent, comparer plusieurs types de réponses pour situer la frontière, et confirmer sur un vrai build avant de construire quoi que ce soit.

Cette négociation Markdown fait partie d'un chantier plus large pour rendre le site lisible par les agents IA — je le détaille ici — mené juste après la migration de ce site depuis Remix. L'état courant de la couche technique est documenté sur la page développeurs. Si vous vous êtes cassé les dents sur le même sujet, dites-le-moi : je suis curieux de savoir si la couche d'hébergement s'en sort mieux.

À lire ensuite

02 / SUITE
← Tous les articles