React 17 → React 18 : quand Suspense SSR fait disparaître les meta tags de next/head
Jeudi 14h30. L'équipe frontend d'une marketplace française de mobilier — 1 200 fiches produit, 380K visites organiques par mois — merge la PR « Upgrade React 18 + Streaming SSR ». Les Lighthouse passent au vert. Le QA valide le parcours d'achat. Vendredi soir, le déploiement part en production. Lundi matin, Search Console affiche 847 pages avec l'alerte « Title tag absent ». Le trafic organique a déjà commencé à glisser.
Lundi 9h12 — Le tableau de bord vire au rouge
Le Lead SEO ouvre Search Console comme chaque lundi. L'onglet « Pages » affiche un nouveau cluster d'erreurs : 847 URL remontées avec le statut « Explorée, actuellement non indexée ». Le rapport « Améliorations » signale des balises <title> manquantes sur l'ensemble des fiches produit. Première réaction : un problème de déploiement partiel. Le DevOps confirme que le rollout est complet depuis vendredi 21h.
L'équipe ouvre une fiche produit dans Chrome. Le <title> est bien visible dans l'onglet du navigateur. Les meta description, canonical, og:title — tout est là dans l'inspecteur. Le Lead SEO lance un curl -A Googlebot sur la même URL. Le HTML retourné contient un <head> quasi vide : pas de <title>, pas de <meta name="description">, pas de <link rel="canonical">. Juste le charset et le viewport.
Hypothèse #1 : un problème de cloaking accidentel lié au user-agent. L'équipe vérifie le middleware Next.js — aucune condition sur le user-agent. Hypothèse écartée en 15 minutes.
Hypothèse #2 : un cache CDN qui servirait une version stale. L'équipe purge Cloudflare, reteste. Même résultat. Le curl sans user-agent Googlebot renvoie aussi un <head> vide. Le problème ne vient pas du CDN.
Hypothèse #3 : un bug de next/head après la mise à jour de dépendances. L'équipe regarde le package.json — Next.js 13.5.6 avec React 18.2.0, monté depuis React 17.0.2. Le diff du lock file montre que react-dom a changé de mode de rendu. C'est là que le soupçon se forme.
Le Lead SEO lance un crawl Screaming Frog en mode « JavaScript rendering » sur 200 URL. Résultat : les meta tags apparaissent. Puis un crawl en mode « HTML brut » : les meta tags sont absents sur 194 des 200 pages. L'écart est net. Le problème est dans le HTML initial envoyé par le serveur, pas dans le rendu client.
À ce stade, les chiffres Google Analytics 4 ne montrent pas encore de chute — le week-end masque le signal. Mais le Lead SEO sait que Googlebot a crawlé 310 pages depuis vendredi soir. 310 pages crawlées avec un <head> vide. L'horloge tourne.
Le bug : le streaming SSR de React 18 réordonne les chunks et abandonne next/head
Pour comprendre ce qui s'est passé, il faut remonter au changement fondamental entre React 17 et React 18 côté serveur.
React 17 : renderToString, synchrone et prévisible
Avec React 17, react-dom/server expose renderToString. La méthode est synchrone. Elle produit une chaîne HTML complète en une seule passe. next/head collecte tous les appels à <Head> dans l'arbre de composants, puis injecte les balises dans le <head> du document avant d'envoyer la réponse. L'ordre est garanti : le <head> est complet au moment où le premier octet part vers le client.
Le HTML renvoyé ressemblait à ceci :
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Canapé 3 places velours bleu — MaisonDeco</title>
<meta name="description" content="Canapé 3 places en velours bleu, livraison gratuite. Dimensions 220x95x85cm." />
<link rel="canonical" href="https://www.maisondeco.fr/canape-3-places-velours-bleu" />
<meta property="og:title" content="Canapé 3 places velours bleu" />
</head>
<body>
<div id="__next"><!-- contenu complet --></div>
</body>
</html>
Googlebot reçoit ce HTML, parse le <head>, trouve tout ce qu'il faut. Indexation propre.
React 18 : renderToPipeableStream, asynchrone et fragmenté
React 18 introduit renderToPipeableStream. Le rendu est streamé : le serveur envoie le shell HTML immédiatement, puis injecte les contenus des <Suspense> boundaries au fur et à mesure qu'ils se résolvent. C'est un gain de performance réel — le TTFB chute, le LCP s'améliore.
Mais next/head n'a pas été conçu pour ce modèle. Le composant <Head> de next/head fonctionne par side-effect : pendant le rendu, chaque instance de <Head> pousse ses balises dans un contexte global. Avec renderToString, ce contexte est lu une fois le rendu terminé, et les balises sont injectées dans le <head> du document.
Avec renderToPipeableStream, le shell HTML — y compris le <head> — est envoyé avant que les composants wrappés dans <Suspense> aient fini leur rendu. Si un composant produit appelle <Head> et que ce composant est enfant d'un <Suspense>, ses meta tags ne sont pas encore collectés quand le <head> est flushed.
Le HTML streamé ressemblait à ceci côté serveur :
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- next/head : rien ici, les composants produit n'ont pas encore rendu -->
</head>
<body>
<div id="__next">
<header><!-- shell statique --></header>
<!--$?--><template id="B:0"></template><!--/$-->
</div>
<!-- plus tard, injecté par streaming : -->
<script>
// React injecte le contenu résolu du Suspense boundary
// mais les balises <Head> ne remontent PAS dans le <head> du document
</script>
</body>
</html>
Les meta tags finissent par arriver côté client via JavaScript. Le navigateur les insère dans le DOM. L'utilisateur ne voit rien d'anormal. Mais Googlebot, même s'il exécute JavaScript, reçoit d'abord le HTML initial. Et dans de nombreux cas, l'indexation se base sur ce HTML initial, surtout pour les balises du <head>.
Reproduction step-by-step
L'équipe a isolé le bug avec cette commande :
curl -s https://www.maisondeco.fr/canape-3-places-velours-bleu \
| head -50 \
| grep -E '<title>|<meta name="description"|<link rel="canonical"'
Résultat : aucune ligne. La même commande sur une page statique (CGU, mentions légales) qui n'utilise pas <Suspense> retourne les trois balises.
Pour confirmer, l'équipe a utilisé l'outil « Inspection d'URL » de Search Console, puis cliqué sur « Tester l'URL en direct ». Le HTML rendu par Google montrait un <head> sans meta tags sur les pages produit.
La comparaison dans Chrome DevTools était trompeuse. Le panneau « Elements » affiche le DOM live — après hydratation client, après que React a patché le <head>. Le panneau « Sources » → « View Page Source » montrait le HTML brut streamé. C'est là que le <head> était vide.
Pourquoi les tests n'ont rien vu
La CI de l'équipe utilisait @testing-library/react avec render() — du rendu client. Les tests e2e Playwright vérifiaient page.title() — qui lit le DOM après hydratation. Aucun test ne vérifiait le HTML brut de la réponse HTTP.
Le pipeline Lighthouse tournait en mode « navigation » avec un Chrome headless qui exécute le JS. Les scores étaient au vert. Les meta tags étaient présents — dans le DOM post-hydratation.
Personne ne testait ce que Googlebot voit en premier : le HTML brut, avant JavaScript.
C'est un angle mort classique des migrations de framework. La même régression silencieuse a frappé des équipes sur la migration Next.js Pages Router vers App Router, où les metadata exports étaient ignorés sur les composants marqués "use client".
La structure du composant fautif
Voici le pattern qui causait le problème. Le composant ProductPage était wrappé dans un <Suspense> dans _app.tsx :
// pages/product/[slug].tsx
import Head from 'next/head';
import { Suspense } from 'react';
import ProductDetails from '@/components/ProductDetails';
import ProductReviews from '@/components/ProductReviews';
export default function ProductPage({ product }) {
return (
<>
<Head>
<title>{product.name} — MaisonDeco</title>
<meta name="description" content={product.shortDescription} />
<link rel="canonical" href={`https://www.maisondeco.fr/${product.slug}`} />
<meta property="og:title" content={product.name} />
<meta property="og:image" content={product.image} />
</Head>
<ProductDetails product={product} />
<Suspense fallback={<div>Chargement avis...</div>}>
<ProductReviews productId={product.id} />
</Suspense>
</>
);
}
Le composant ProductPage lui-même n'était pas dans un Suspense. Mais la configuration de _app.tsx avait été modifiée pendant la migration pour wrapper le contenu principal dans un Suspense boundary global :
// pages/_app.tsx
import { Suspense } from 'react';
import Layout from '@/components/Layout';
export default function App({ Component, pageProps }) {
return (
<Layout>
<Suspense fallback={<div className="loading-shell" />}>
<Component {...pageProps} />
</Suspense>
</Layout>
);
}
Ce <Suspense> dans _app.tsx est le coupable. Il dit à React 18 : « tu peux streamer le shell (<Layout>) immédiatement, et résoudre <Component> plus tard ». Le <Head> à l'intérieur de <Component> n'est donc pas collecté au moment du flush initial du <head>.
Le fix — Sortir next/head du Suspense boundary
Patch immédiat : remonter les meta tags hors du Suspense
La solution la plus propre : ne plus wrapper le composant page entier dans un <Suspense>. Le Suspense doit être granulaire — uniquement autour des composants qui ont réellement besoin de lazy loading.
// pages/_app.tsx — CORRIGÉ
import Layout from '@/components/Layout';
export default function App({ Component, pageProps }) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
Le <Suspense> est déplacé à l'intérieur des pages, uniquement autour des sections non critiques pour le SEO :
// pages/product/[slug].tsx — CORRIGÉ
import Head from 'next/head';
import { Suspense } from 'react';
import ProductDetails from '@/components/ProductDetails';
import ProductReviews from '@/components/ProductReviews';
export default function ProductPage({ product }) {
return (
<>
<Head>
<title>{product.name} — MaisonDeco</title>
<meta name="description" content={product.shortDescription} />
<link rel="canonical" href={`https://www.maisondeco.fr/${product.slug}`} />
<meta property="og:title" content={product.name} />
<meta property="og:image" content={product.image} />
</Head>
<ProductDetails product={product} />
<Suspense fallback={<div>Chargement avis...</div>}>
<ProductReviews productId={product.id} />
</Suspense>
</>
);
}
Vérification post-déploiement
Après le merge et le déploiement, l'équipe vérifie avec le même curl :
curl -s https://www.maisondeco.fr/canape-3-places-velours-bleu \
| head -50 \
| grep -E '<title>|<meta name="description"|<link rel="canonical"'
Résultat :
<title>Canapé 3 places velours bleu — MaisonDeco</title>
<meta name="description" content="Canapé 3 places en velours bleu, livraison gratuite. Dimensions 220x95x85cm." />
<link rel="canonical" href="https://www.maisondeco.fr/canape-3-places-velours-bleu" />
Les trois lignes sont de retour dans le HTML initial.
Test CI ajouté
L'équipe ajoute un test d'intégration qui vérifie le HTML brut retourné par le serveur Next.js :
// __tests__/seo-ssr.test.ts
import { createServer } from 'http';
import next from 'next';
describe('SSR meta tags', () => {
let server: ReturnType<typeof createServer>;
let baseUrl: string;
beforeAll(async () => {
const app = next({ dev: false, dir: '.' });
await app.prepare();
const handle = app.getRequestHandler();
server = createServer(handle);
await new Promise<void>((resolve) => {
server.listen(0, () => {
const addr = server.address();
baseUrl = `http://localhost:${(addr as any).port}`;
resolve();
});
});
});
afterAll(() => server.close());
it('should include title and canonical in raw HTML for product pages', async () => {
const res = await fetch(`${baseUrl}/product/canape-3-places-velours-bleu`);
const html = await res.text();
expect(html).toContain('<title>');
expect(html).toContain('<link rel="canonical"');
expect(html).toContain('<meta name="description"');
});
});
Ce test échoue sur le HTML brut, pas sur le DOM hydraté. Il aurait détecté la régression avant le déploiement.
Invalidation et récupération
L'équipe force un recrawl via Search Console (« Demander l'indexation ») sur les 50 pages à plus fort trafic. Pour le reste, elle soumet un sitemap mis à jour avec des <lastmod> au jour du fix pour accélérer le passage de Googlebot.
Le cache Cloudflare est purgé intégralement. L'équipe vérifie que le header Cache-Control des pages produit inclut s-maxage=3600, stale-while-revalidate=86400 — suffisamment court pour que la version corrigée soit servie rapidement.
Chronologie de récupération
- J+0 (mardi) : fix déployé, recrawl forcé sur top 50 pages.
- J+3 : Search Console montre 340 pages re-crawlées avec
<title>détecté. - J+7 : les alertes « Title tag absent » passent de 847 à 210.
- J+14 : toutes les pages sont re-crawlées. Les alertes tombent à 0.
- J+21 : le trafic organique revient au niveau pré-incident. La perte cumulée est estimée à ~45K clics sur la période, soit environ 12% du trafic mensuel.
Les pages qui avaient perdu leur position 1 sur des requêtes longue traîne (« canapé velours bleu 3 places livraison gratuite ») ont mis jusqu'à 18 jours pour retrouver leur rang. Les pages à fort Domain Authority (catégories principales) ont récupéré en 4-5 jours.
L'impact aurait été pire sur un site plus récent ou avec moins d'autorité. Et il aurait été détecté plus tard sans la vérification systématique du lundi matin dans Search Console. D'autres migrations silencieuses — comme celle documentée sur Nuxt 2 vers Nuxt 3 avec 200 pages en fallback layout — ont mis six semaines avant d'être repérées.
Ce qu'on en retient
La migration React 17 → 18 ne casse rien visuellement. Le navigateur affiche le bon titre, les bonnes metas. C'est ce qui rend le bug invisible aux yeux de toute l'équipe — devs, QA, product.
Le danger est dans l'écart entre le DOM hydraté et le HTML initial. Cet écart n'existe pas en React 17. Il apparaît dès qu'un <Suspense> boundary englobe un composant qui appelle next/head. Et aucun outil de test front standard ne le détecte par défaut.
Trois règles à appliquer après une migration React 18 avec streaming :
- Ne jamais wrapper un composant contenant
<Head>dans un<Suspense>au niveau_app. - Tester le HTML brut en CI, pas seulement le DOM post-hydratation.
- Monitorer la divergence SSR/CSR en continu. Un outil comme Seogard compare automatiquement le HTML initial et le DOM rendu pour chaque page crawlée — ce type de régression est détecté en minutes, pas en semaines.
Le streaming SSR est un vrai gain de performance. Mais les meta tags SEO doivent être dans le shell, jamais dans un chunk différé. C'est la règle non écrite de React 18 que la documentation officielle ne mentionne qu'en passant.