Aller au contenu principal
Retour au blog

Next.js : un canonical dans le layout et Google désindexe tout votre site

8 min
Partager :
Next.js : un canonical dans le layout et Google désindexe tout votre site

Deux clics en trois mois

J'ouvre Google Search Console un samedi matin, sans inquiétude particulière. Le site tourne, les tests passent, Lighthouse affiche 100 en performance et 100 en SEO.

Le graphique dit : 2 clics en trois mois.

Je descends. Section Indexation :

1 page indexée
6 pages non indexées

Sept pages au sitemap. Une seule dans l'index. Cinq articles de blog invisibles, la page blog invisible, les mentions légales invisibles. Depuis le premier jour.

Et rien, absolument rien, ne signalait le problème.

Tout ce qui semblait normal

C'est ce qui rend ce bug méchant. Voici ce que j'avais vérifié, et qui était vrai :

  • Chaque page répond HTTP 200
  • Aucune balise noindex nulle part
  • robots.txt autorise tout sauf /api/
  • Le sitemap liste bien les neuf URLs
  • Aucune URL du sitemap ne redirige
  • Ma suite de tests end-to-end vérifiait déjà les balises canonical, et elle était verte

Un audit technique classique ne trouve rien. Lighthouse ne trouve rien — il ne compare pas votre canonical à l'URL de la page. Le build ne trouve rien. TypeScript ne trouve rien.

Le seul endroit où le problème est visible, c'est Search Console. Et encore : il faut aller chercher.

Le diagnostic

Search Console, section Indexation, « Rapport complet », puis « Pourquoi des pages ne sont pas indexées ». Le tableau ventile les motifs. Le mien :

MotifPages
Page avec redirection2
Autre page avec balise canonique correcte1
Explorée, actuellement non indexée3

La deuxième ligne est un euphémisme remarquable. Traduction : cette page nous dit elle-même qu'elle est un doublon d'une autre, donc on indexe l'autre.

Pour vérifier sans attendre le prochain passage de Google, une boucle sur le sitemap suffit :

for U in $(curl -sS https://votre-site.fr/sitemap.xml \
           | grep -oE '<loc>[^<]+' | sed 's/<loc>//'); do
  C=$(curl -sS "$U" \
      | grep -oE '<link rel="canonical" href="[^"]+' \
      | head -1 | sed 's/.*href="//')
  printf "%-45s -> %s\n" "$U" "$C"
done

Résultat :

https://votre-site.fr            -> https://votre-site.fr
https://votre-site.fr/blog       -> https://votre-site.fr        ← ici
https://votre-site.fr/mentions   -> https://votre-site.fr        ← ici
https://votre-site.fr/blog/article-1 -> https://votre-site.fr/blog/article-1

Trois pages déclaraient la page d'accueil comme leur version canonique.

Notez au passage un détail instructif : trois pages portaient un canonical erroné, mais Search Console n'en attribuait qu'une seule à ce motif. Les catégories de Search Console ne correspondent pas une pour une à vos bugs. Ne vous fiez pas au compteur pour mesurer l'ampleur — allez lire les balises.

La cause : l'héritage des métadonnées

Voici le layout racine. Le coupable tient en trois lignes :

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL(siteUrl),
  title: { default: "…", template: "%s | …" },
  openGraph: { /* … */ },
  alternates: {
    canonical: siteUrl,   // ← le bug
  },
};

Dans l'App Router, les métadonnées d'un layout sont héritées par toutes les pages enfants, sauf si une page redéfinit la clé. C'est une bonne chose pour openGraph.siteName ou le template de titre : on l'écrit une fois, tout le site en profite.

Pour le canonical, c'est un désastre silencieux. Une seule déclaration au niveau du layout se transforme en affirmation valable pour toutes les URLs du site : je suis la page d'accueil.

Dans mon cas, un seul fichier redéfinissait alternates :

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
  return {
    // …
    alternates: {
      canonical: `${siteUrl}/blog/${slug}`,
    },
  };
}

Mes articles de blog s'en sortaient. Tout le reste héritait de l'accueil.

Pourquoi personne ne le voit

Trois raisons se cumulent.

Le code paraît juste. canonical: siteUrl dans un layout se lit comme « le canonical de mon site », pas comme « le canonical de chacune de mes pages ». La sémantique de l'héritage n'est visible nulle part à l'endroit où on écrit la ligne.

Le symptôme est différé et déplacé. Il n'apparaît ni au build, ni au déploiement, ni dans le navigateur, mais des semaines plus tard, dans un outil tiers, sous un libellé qui ne mentionne pas Next.js.

Il n'y a pas d'erreur. Un canonical qui pointe ailleurs est un usage parfaitement légitime de la balise — c'est précisément à ça qu'elle sert. Vous donnez une instruction valide à Google. Il l'exécute.

Le correctif

La règle est simple : ce qui identifie une page ne se déclare jamais dans un layout.

On retire le canonical du layout :

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL(siteUrl),
  // … le reste
  // Pas de canonical ici : Next hérite les métadonnées du layout
  // vers chaque page enfant.
};

Et chaque page déclare le sien :

// app/page.tsx
export const metadata: Metadata = {
  alternates: { canonical: siteUrl },
};

// app/blog/page.tsx
export const metadata: Metadata = {
  title: "Blog",
  alternates: { canonical: `${siteUrl}/blog` },
};

// app/mentions-legales/page.tsx
export const metadata: Metadata = {
  title: "Mentions légales",
  alternates: { canonical: `${siteUrl}/mentions-legales` },
};

C'est plus verbeux qu'une déclaration unique. C'est le prix : le canonical est une propriété de la page, pas du site, et le code doit le dire.

Une remarque sur metadataBase : il reste dans le layout, et c'est correct. Il ne déclare pas une identité, il fournit une origine pour résoudre les URLs relatives. On peut d'ailleurs écrire canonical: "/blog" et laisser metadataBase compléter — je préfère l'URL absolue explicite, pour que la lecture du fichier suffise à savoir ce qui sera émis.

Le test qui manquait

J'avais un test end-to-end sur les canonicals. Il était vert pendant toute la durée du bug. Le voici :

test("sitemap, robots et canonical s'accordent sur une seule origine", async ({ request, page }) => {
  const sitemap = await (await request.get("/sitemap.xml")).text();
  const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);

  const origins = new Set(urls.map((u) => new URL(u).origin));
  expect(origins).toHaveProperty("size", 1);

  await page.goto("/");
  const canonical = await page.locator('link[rel="canonical"]').getAttribute("href");
  expect(new URL(canonical!).origin).toBe([...origins][0]);
});

Il avait été écrit contre un autre problème : une incohérence www / non-www, où le canonical, le sitemap et robots.txt codaient chacun leur propre domaine. Il vérifie donc l'origine.

Or l'origine était juste. C'était la page qui était fausse. Bon domaine, mauvaise cible — un angle mort parfait.

Le test correct parcourt le sitemap et exige que chaque URL se désigne elle-même :

test("chaque URL du sitemap se déclare canonique, pas une autre page", async ({ request, page }) => {
  const sitemap = await (await request.get("/sitemap.xml")).text();
  const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
  expect(urls.length).toBeGreaterThan(1);

  for (const url of urls) {
    // On visite le chemin sur le serveur testé, pas l'URL absolue du
    // sitemap : sinon le test interroge la production et réussit ou
    // échoue selon le déploiement de quelqu'un d'autre.
    const path = new URL(url).pathname;
    await page.goto(path);

    const canonical = await page.locator('link[rel="canonical"]').getAttribute("href");
    expect(canonical, `${path} ne déclare aucun canonical`).toBeTruthy();

    const strip = (p: string) => p.replace(/\/$/, "");
    expect(
      strip(new URL(canonical!).pathname),
      `${path} pointe son canonical vers une autre page`
    ).toBe(strip(path));
  }
});

Deux détails valent d'être soulignés.

Comparer les chemins, pas les URLs complètes. Le sitemap contient des URLs de production absolues ; le test tourne contre localhost. En comparant les chemins, le test valide le serveur qu'il a réellement démarré. J'ai écrit la première version avec page.goto(url) — elle allait interroger le site en ligne et échouait sur du code qui n'était pas celui en cours de test.

Vérifier que le test échoue. J'ai retiré le canonical de /blog, relancé, et confirmé l'échec avec le bon message avant de restaurer. Un test de non-régression qu'on n'a jamais vu rougir n'est pas un test, c'est une décoration — celui qu'il remplace en est la démonstration.

Après le correctif

Une fois le déploiement en ligne, vérifiez d'abord en production avec la boucle curl du début. Chaque URL doit se désigner elle-même. Ensuite seulement, Search Console :

  1. Indexation → Rapport complet → Valider la correction sur le motif « Autre page avec balise canonique correcte ».
  2. Inspection de l'URL pour chaque page concernée, puis Demander une indexation. Ça force un passage au lieu d'attendre le cycle naturel.

Comptez quelques jours à deux semaines.

Et une mise en garde, parce que je me suis fait la réflexion trop vite : ce correctif ne règle que les pages effectivement bloquées par le canonical. Chez moi, il restait trois pages en « Explorée, actuellement non indexée » — Google est venu, a lu, et a décidé que ça n'en valait pas la peine. Ça, ce n'est pas un bug. C'est un jugement sur le contenu, et aucune balise ne le renversera.

Ce que j'en retiens

Deux règles, dont une qui dépasse largement le canonical.

Ce qui identifie une page ne se déclare jamais dans un layout. Le canonical, la description, l'URL Open Graph : tout ce qui répond à « quelle page suis-je ? » appartient à la page. Le layout porte ce qui répond à « quel site suis-je ? ».

Un test qui vérifie une propriété voisine de celle qui compte donne une fausse assurance. Mon test contrôlait l'origine parce que mon bug précédent portait sur l'origine. Il a survécu, inchangé, à un bug qui touchait exactement le même attribut HTML. Quand vous écrivez une garde de non-régression, demandez-vous ce qu'elle laisserait passer — pas seulement ce qu'elle attrape.

Le site répondait 200, sans noindex, sitemap valide, tests verts, Lighthouse à 100. Et six pages sur sept étaient invisibles sur Google. La qualité mesurable n'est pas la qualité.

A

Amor GABTNI

Développeur Full Stack & Mobile

Articles similaires