Tu migres une app Next.js, tu déploies, tout a l’air OK. Les pages s’affichent, les routes répondent, Lighthouse te dit merci. Et puis quelques jours après, Search Console te met une gifle. Les clics s’effondrent, les impressions suivent, et tu passes une semaine à douter de tout… alors que la cause est parfois ridiculement simple : tes balises SEO ne sont plus dans le HTML de réponse.
Le piège est vicieux parce qu’en tant que dev, tu regardes le DOM après hydratation. Donc tu “vois” bien un <title>, une meta description, un canonical. Sauf que Google (et surtout ses systèmes d’indexation à grande échelle) ne voit pas forcément ce que toi tu vois, au même moment, avec les mêmes garanties. Avec App Router, les Server Components, et deux-trois composants qui basculent en client, tu peux te retrouver avec du SEO en CSR sans t’en rendre compte.
Le scénario classique : ta page est belle, mais le HTML est vide (SEO en CSR)
Le post-mortem qu’on a vu passer côté communauté Next.js est brutal : une migration Pages Router → App Router, et une chute massive de trafic organique en quelques semaines. Pas parce que « Next.js c’est nul », pas parce que Google punit App Router, mais parce qu’un détail a glissé pendant la refacto : des éléments SEO critiques (title, description, OG, etc.) se retrouvent générés côté client.
Pourquoi c’est si dangereux ? Parce que ce qui compte pour l’indexation, ce n’est pas ce que ton navigateur finit par afficher après exécution JS. C’est d’abord le document HTML initial, celui que reçoit un crawler quand il tape l’URL. Oui, Google peut rendre du JavaScript. Mais ce rendu a un coût, arrive souvent en seconde vague, et n’est pas une garantie uniforme pour tous les signaux (et tous les produits, genre Discover). Si ton HTML initial n’a pas les bons signaux, tu joues au casino.
Et là-dessus, App Router peut être impitoyable : tu peux tout faire “dans les règles React”, mais si tu mets la génération des metas dans un Client Component (ou via un head manager côté client), tu crées un site qui est “SEO-friendly” pour toi, pas pour un bot.
Sponsorisé par Le Scribouillard
Besoin de contenu optimisé SEO ?
Utilisez la meilleure plateforme française de création de contenu assistée par IA ! Et générez des articles pour moins de 1€ !
Ce que je vérifie en premier : le HTML de réponse, pas le DOM
Le réflexe qui sauve du temps : ne commence pas par des audits SEO ou des outils “magiques”. Commence par une vérification bête, au niveau HTTP. Tu veux savoir ce que ton serveur renvoie réellement pour une page donnée.
Concrètement, j’ouvre le « View Source » (la source HTML, pas l’inspecteur), et je fais aussi un curl pour être sûr de ne pas me faire tromper par le navigateur.
curl -sL https://ton-site.fr/une-page | sed -n '1,200p'Tu cherches tout de suite les choses qui ont un impact direct sur l’indexation et le CTR : <title>, <meta name="description">, <link rel="canonical">, <meta name="robots">, puis les OG/Twitter si ton acquisition dépend du partage ou si tu veux un rendu propre dans les previews.
Et surtout : tu ne te contentes pas de “ah oui je vois un title”. Tu vérifies qu’il est le bon. Sur une migration, le piège numéro 1 c’est « tout a le même title » ou « la description est vide sur 30% des routes » parce qu’un layout a écrasé la meta, ou parce qu’un paramètre de route n’est plus pris en compte dans la génération.
Pourquoi App Router te met facilement dans ce piège
Avec Pages Router, on avait tendance à faire du SEO via next/head dans chaque page, parfois en s’appuyant sur getServerSideProps ou getStaticProps. C’était imparfait, mais assez “visible” : si tu oubliais de mettre un <Head>, tu le voyais vite.
Avec App Router, le centre de gravité bouge : tu as des layouts imbriqués, des Server Components par défaut, et une API de metadata pensée pour produire des balises dans le head sans bidouille. Sauf qu’au moindre glissement vers un Client Component, tu peux te retrouver à reconstruire un vieux pattern « React SPA » : un composant qui fait un useEffect pour changer le title, un “SEO component” qui vit côté client parce qu’il utilise un hook, un provider, un state global, ou pire, un layout marqué use client qui entraîne tout le reste.
Je vais être direct : si ton SEO dépend d’un composant qui doit être “client” pour fonctionner, tu es déjà dans une zone à risque. Ça peut marcher. Mais ça ne devrait pas être ta stratégie de base.
Le pattern propre : metadata en serveur, et pas au doigt mouillé
En App Router, je privilégie systématiquement les mécanismes serveur pour le head. Typiquement, un export const metadata (quand c’est statique) ou une fonction generateMetadata (quand c’est dépendant de la route, du slug, de la locale, ou d’un fetch). L’objectif est simple : les balises sortent dans le HTML initial, point.
Ce qui fait gagner du temps, c’est d’être cohérent : une page “marketing” et une page “contenu” ne devraient pas avoir deux systèmes concurrents de génération de title/canonical. La moitié des incidents viennent de là : une partie du site utilise metadata App Router, l’autre garde une couche SEO “historique” en client, et à la fin personne ne sait quel système gagne.
Attention aussi à un détail qui fait mal en prod : si tu fais du metadata dynamique, tu veux être sûr que tes données nécessaires sont accessibles côté serveur, et que tu maîtrises le caching/rendu (sinon tu te retrouves à servir un title en retard, ou un canonical incohérent entre variantes). Ça dépasse le “SEO”, c’est juste de l’architecture web.
Les balises qui cassent le plus souvent en migration (et que Google aime bien)
Le <title> et la meta description, tout le monde y pense. Mais sur les migrations Next.js, j’ai vu des pertes bêtes à cause du canonical et des robots. Le canonical est le genre de truc que tu ne regardes pas tant que ça va. Puis un jour tu te rends compte qu’il pointe vers la mauvaise URL (http vs https, www vs non-www, trailing slash qui change, params oubliés) et tu as créé toi-même ton propre duplicate content.
Le robots, pareil : une meta globale « noindex » laissée en place sur un layout, une condition d’environnement inversée, ou une route qui tombe sur une page d’erreur mais renvoie quand même 200. Visuellement tu as une page, Google a un signal de non-indexation, et tu t’étonnes de perdre des pages dans l’index.
Et si tu fais du multi-langue ou des alternates, les hreflang et alternate sont souvent les premiers dommages collatéraux d’une migration de routing. Une locale qui change de structure d’URL, des alternates incomplets, ou des liens générés côté client parce que “c’était plus simple”. Ça ne se voit pas, mais ça se paye.
Le test qui évite l’incident : un smoke test qui parse le HTML
Le meilleur garde-fou que j’ai mis en place sur ce genre de migration, c’est un test automatique ultra simple : une CI (ou un job de pré-prod) qui fetch quelques URLs critiques et vérifie que les balises existent dans le HTML renvoyé. Pas dans le DOM rendu par Playwright après 5 secondes. Dans le HTML brut.
Ce n’est pas un audit SEO, c’est un test de non-régression. Et c’est exactement ce que tu veux avant un déploiement : détecter « on a basculé en CSR » ou « le canonical est vide » avant que Google ne le découvre.
// node >= 18
import assert from "node:assert/strict";
const urls = [
"https://ton-site.fr/",
"https://ton-site.fr/produit/truc",
"https://ton-site.fr/blog/un-article",
];
for (const url of urls) {
const res = await fetch(url, {
headers: {
// évite les variantes “bizarres” côté CDN si tu en as
"user-agent": "devmy-smoke-seo/1.0",
"accept": "text/html",
},
redirect: "follow",
});
assert.equal(res.status, 200, `${url} devrait répondre 200`);
const html = await res.text();
assert.match(html, /<title>[^<]+<\/title>/i, `${url} sans <title> SSR`);
assert.match(html, /<meta\s+name=["']description["']/i, `${url} sans meta description SSR`);
assert.match(html, /<link\s+rel=["']canonical["']/i, `${url} sans canonical SSR`);
}
console.log("SEO SSR smoke test OK");Tu peux raffiner ensuite (vérifier le contenu exact, s’assurer que le canonical correspond à l’URL attendue, vérifier que tu n’as pas un title générique). Mais même en version simple, ça attrape une grosse partie des régressions.
Les erreurs fréquentes que je vois en App Router (et que tu peux éviter)
La plus fréquente, c’est le fameux composant “SEO” qui devient client parce qu’il dépend d’un hook. Ça part souvent d’une bonne intention : centraliser le SEO, éviter la duplication. Sauf que le jour où tu lui mets un usePathname() ou un state de navigation, tu le bascules en client, et tu viens de rendre tes metas dépendantes de l’hydratation. Tu ne le vois pas, tu ne le ressens pas, mais le HTML initial se vide.
Autre classique : le layout global qui passe en use client “juste” pour un provider, un thème, un composant analytics, une lib UI. À partir de là, tu changes la nature de ton rendu, et tu peux embarquer des trucs qui n’avaient rien demandé. Tu ne veux pas faire du dogme « tout server », mais tu veux savoir ce que tu entraînes comme conséquences sur le head.
Enfin, l’erreur qui fait perdre un temps fou : ne tester qu’en local ou en preview, puis découvrir en prod un comportement différent à cause du caching, d’un CDN, d’une config headers, ou d’un build “optimisé” qui ne ressemble pas à ton run dev. Si tu veux de la confiance, teste ton HTML sur l’environnement le plus proche de la prod, avec la même config de déploiement.
Mon avis : une migration Next.js, ce n’est pas « juste du front »
App Router est puissant, et oui, ça vaut le coup. Mais si ton site vit de SEO, une migration Next.js n’est pas une refacto “design system”. C’est une opération qui touche à l’indexation, donc au business. Ton job, ce n’est pas de “faire marcher la navigation”, c’est de garantir que le serveur envoie des documents HTML cohérents, stables, et lisibles par des machines.
La bonne nouvelle, c’est que ce genre d’incident est évitable avec un protocole simple : vérifier le HTML de réponse, arrêter de valider dans le DOM hydraté, et automatiser deux-trois assertions sur tes pages clés. Tu n’empêcheras pas tous les bugs. Mais tu éviteras au moins celui qui te fait perdre 85% de trafic pour un détail “invisible”.