Hreflang vers domaines supprimés : −38% de trafic DE en 6 semaines

Hreflang générés vers des domaines supprimés : autopsie d'un nettoyage oublié

Mardi 14h. Le Head of SEO d'une marketplace mode européenne ouvre son rapport Search Console hebdomadaire. Le marché français — 12 000 pages produit, 480K clics mensuels — affiche une courbe descendante depuis six semaines. Pas un effondrement brutal. Une érosion lente, −3% par semaine, presque invisible dans le bruit quotidien. Sauf qu'en cumulé, c'est −38% de trafic organique sur le cluster allemand, et −11% sur le .fr. Le site tourne sur Next.js 14 avec App Router, un CMS headless Contentful, et un système de gestion i18n maison. Le marché allemand (shop-marque.de) a été fermé quatre mois plus tôt. Le domaine n'a pas été renouvelé.

Mercredi 9h12 — Le signal faible devient alarme

L'équipe SEO creuse d'abord les suspects habituels. Core Update ? Rien d'annoncé par Google sur la période. Changement de contenu ? Le dernier déploiement majeur remonte à trois semaines — une refonte des filtres de navigation, sans impact sur les templates produit. Problème d'indexation ? Le rapport "Pages" de Search Console montre un chiffre stable de pages indexées pour le .fr.

Le premier vrai indice arrive à 10h35. Un dev frontend, en inspectant une page produit dans Chrome DevTools pour un tout autre sujet, remarque quelque chose dans le <head> :

<link rel="alternate" hreflang="fr" href="https://www.shop-marque.fr/produit/robe-ete-lin" />
<link rel="alternate" hreflang="de" href="https://www.shop-marque.de/produkt/sommerkleid-leinen" />
<link rel="alternate" hreflang="en" href="https://www.shop-marque.com/product/summer-linen-dress" />
<link rel="alternate" hreflang="x-default" href="https://www.shop-marque.com/product/summer-linen-dress" />

La ligne hreflang="de" pointe vers shop-marque.de. Ce domaine n'existe plus. Le DNS ne résout plus. Le certificat TLS a expiré il y a trois mois. Quiconque — ou quoi que ce soit — qui suit ce lien tombe sur une erreur de connexion.

Le Head of SEO ouvre Screaming Frog, lance un crawl de 500 pages du .fr. Résultat : 100% des pages produit, catégorie et contenu éditorial contiennent un hreflang vers le .de mort. Sur 12 347 pages crawlées, 12 347 pointent vers un domaine fantôme.

À 11h20, l'équipe vérifie l'outil d'inspection d'URL de Search Console. Google voit les hreflang. Google suit les liens. Google tombe sur un domaine mort. Et Google fait exactement ce que la documentation prévoit dans ce cas : il ignore l'ensemble du cluster hreflang de la page. Pas seulement la ligne de. Toutes les lignes.

Le CTO rejoint le call à 11h45. La question est simple : comment 12 000 pages génèrent-elles encore un hreflang vers un domaine fermé il y a quatre mois ?

La réponse est dans le code. Et dans un fichier de configuration que personne n'a touché depuis la fermeture.

Le bug : un tableau de locales jamais nettoyé

L'architecture i18n du site repose sur un système classique pour Next.js 14 App Router. Un fichier de configuration centralise les locales disponibles :

// lib/i18n/config.ts
export const locales = [
  {
    code: 'fr',
    domain: 'www.shop-marque.fr',
    defaultLocale: false,
    contentfulLocale: 'fr-FR',
  },
  {
    code: 'de',
    domain: 'www.shop-marque.de',
    defaultLocale: false,
    contentfulLocale: 'de-DE',
  },
  {
    code: 'en',
    domain: 'www.shop-marque.com',
    defaultLocale: true,
    contentfulLocale: 'en-US',
  },
] as const;

export type LocaleCode = (typeof locales)[number]['code'];

Ce fichier alimente tout : le routing, les requêtes Contentful, les sitemaps, et — c'est le point critique — la génération des balises hreflang.

Le composant responsable de l'injection des hreflang dans le <head> vit dans le layout racine :

// app/layout.tsx (extrait simplifié)
import { locales } from '@/lib/i18n/config';

function HreflangTags({ slug, currentLocale }: { slug: string; currentLocale: string }) {
  return (
    <>
      {locales.map((locale) => (
        <link
          key={locale.code}
          rel="alternate"
          hrefLang={locale.code}
          href={`https://${locale.domain}/${slug}`}
        />
      ))}
      <link
        rel="alternate"
        hrefLang="x-default"
        href={`https://${locales.find(l => l.defaultLocale)!.domain}/${slug}`}
      />
    </>
  );
}

Le code itère sur toutes les locales du tableau. Aucun filtre. Aucune vérification que le domaine est actif. Aucun flag enabled: true/false. Le tableau locales est la source de vérité unique, et personne ne l'a modifié quand le marché allemand a été décommissionné.

Pourquoi personne n'a rien vu

Quatre facteurs ont conspiré :

1. La fermeture du .de a été traitée comme un projet infrastructure, pas SEO. Le ticket JIRA "Fermer shop-marque.de" listait : couper les paiements, désactiver les comptes clients, arrêter les flux logistiques, ne pas renouveler le domaine. Aucune sous-tâche ne mentionnait les hreflang du .fr ou du .com.

2. Le composant HreflangTags n'avait aucun test. Pas un seul test unitaire, pas un test e2e vérifiant le contenu du <head>. Le composant avait été écrit une fois, deux ans plus tôt, et jamais retouché. L'équipe testait les pages dans un navigateur — et les hreflang ne sont pas visibles pour un utilisateur humain.

3. Contentful avait bien été nettoyé. Les entrées de contenu en de-DE avaient été archivées. Les requêtes GraphQL pour la locale de retournaient des résultats vides. Mais le composant HreflangTags ne consommait pas Contentful — il lisait le tableau statique locales. Deux sources de vérité, désynchronisées.

4. L'impact SEO a été graduel. Google n'invalide pas immédiatement un cluster hreflang cassé. Il recrawle les pages à son rythme, constate que le lien de est mort, et commence à ignorer les signaux hreflang de la page. Sur un site de 12 000 pages avec un crawl budget correct, ce processus prend des semaines. Le temps que l'érosion soit visible dans les métriques, quatre mois se sont écoulés.

Ce que Googlebot voit vs ce que le développeur voit

Le développeur ouvre la page produit dans Chrome. Il voit une fiche produit qui fonctionne. Les hreflang sont dans le code source, mais ils ne sont pas rendus visuellement. Même en inspectant le <head>, il faut savoir qu'il faut chercher un domaine mort parmi les <link>.

Googlebot, lui, fait exactement ceci :

  1. Crawle https://www.shop-marque.fr/produit/robe-ete-lin.
  2. Parse le <head>, extrait les hreflang.
  3. Tente de valider le cluster : pour chaque URL hreflang, Google vérifie que la page cible contient un hreflang retour (reciprocal link).
  4. https://www.shop-marque.de/produkt/sommerkleid-leinen → DNS failure. Pas de réponse HTTP. Pas de lien retour possible.
  5. Le cluster hreflang est considéré comme invalide. Google ignore toutes les annotations hreflang de la page — y compris le lien fren qui, lui, fonctionne parfaitement.

C'est documenté dans la documentation officielle Google sur les hreflang : "If the annotations are inconsistent, we may ignore them entirely."

Le résultat concret : Google ne sait plus que shop-marque.fr et shop-marque.com sont des versions localisées du même contenu. Les pages .fr commencent à concurrencer les pages .com dans les SERPs françaises. Le trafic organique se fragmente.

Vérification à grande échelle avec Screaming Frog

Pour quantifier l'étendue du dégât, l'équipe lance un crawl complet avec Screaming Frog, en activant l'extraction custom :

Configuration > Custom Extraction
Extraction: XPath
XPath: //link[@rel='alternate' and @hreflang='de']/@href

Export CSV. 12 347 lignes. Toutes contiennent https://www.shop-marque.de/.... Un curl rapide confirme que le domaine est mort :

curl -sI https://www.shop-marque.de/produkt/sommerkleid-leinen
# curl: (6) Could not resolve host: www.shop-marque.de

DNS NXDOMAIN. Le domaine a expiré et n'a pas été racheté. Il est disponible à l'achat chez n'importe quel registrar. Ce qui ouvre un autre risque : si un tiers rachète le domaine et y place du contenu, les hreflang du .fr et du .com pointeront vers un site tiers. Un scénario de détournement classique, rarement anticipé.

Le fix : supprimer, redéployer, revalider

Étape 1 — Nettoyer la source de vérité

Le correctif minimal consiste à retirer la locale de du tableau. Mais l'équipe prend une décision plus robuste : ajouter un flag active pour gérer proprement les futures fermetures de marché.

// lib/i18n/config.ts — version corrigée
export const locales = [
  {
    code: 'fr',
    domain: 'www.shop-marque.fr',
    defaultLocale: false,
    contentfulLocale: 'fr-FR',
    active: true,
  },
  {
    code: 'de',
    domain: 'www.shop-marque.de',
    defaultLocale: false,
    contentfulLocale: 'de-DE',
    active: false, // Marché fermé 2026-02
  },
  {
    code: 'en',
    domain: 'www.shop-marque.com',
    defaultLocale: true,
    contentfulLocale: 'en-US',
    active: true,
  },
] as const;

export const activeLocales = locales.filter((l) => l.active);

Étape 2 — Filtrer les hreflang

Le composant HreflangTags utilise désormais activeLocales :

// app/layout.tsx — composant corrigé
import { activeLocales } from '@/lib/i18n/config';

function HreflangTags({ slug, currentLocale }: { slug: string; currentLocale: string }) {
  return (
    <>
      {activeLocales.map((locale) => (
        <link
          key={locale.code}
          rel="alternate"
          hrefLang={locale.code}
          href={`https://${locale.domain}/${slug}`}
        />
      ))}
      <link
        rel="alternate"
        hrefLang="x-default"
        href={`https://${activeLocales.find(l => l.defaultLocale)!.domain}/${slug}`}
      />
    </>
  );
}

Le HTML rendu après correctif :

<link rel="alternate" hreflang="fr" href="https://www.shop-marque.fr/produit/robe-ete-lin" />
<link rel="alternate" hreflang="en" href="https://www.shop-marque.com/product/summer-linen-dress" />
<link rel="alternate" hreflang="x-default" href="https://www.shop-marque.com/product/summer-linen-dress" />

Plus de ligne de. Le cluster hreflang ne contient plus que des domaines actifs avec des liens réciproques fonctionnels.

Étape 3 — Vérifier les sitemaps

Le sitemap index référençait encore un sitemap-de.xml. Même logique : le générateur de sitemap lisait le même tableau locales. Après le fix, le sitemap index ne liste plus que les sitemaps fr et en. L'ancien sitemap-de.xml retourne désormais un 404, ce qui est le comportement attendu — Google cessera de le requêter après quelques tentatives. Un problème similaire de sitemap pointant vers un mauvais domaine avait été documenté dans un incident Astro avec un domaine inexistant.

Étape 4 — Invalider les caches et forcer le recrawl

Le site utilise un CDN avec cache edge de 24h. Un simple redéploiement ne suffit pas — les anciennes pages avec hreflang cassés resteraient servies pendant 24h.

# Purge du cache CDN (Cloudflare)
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

Ensuite, soumission du sitemap mis à jour dans Search Console, et demande d'inspection d'URL sur 10 pages stratégiques pour vérifier que Google voit bien le nouveau <head>.

Étape 5 — Ajouter des tests

L'équipe ajoute un test d'intégration dans la CI qui vérifie que chaque URL hreflang dans le <head> pointe vers un domaine résolvable :

// __tests__/hreflang.test.ts
import { activeLocales } from '@/lib/i18n/config';
import dns from 'dns/promises';

describe('hreflang domains', () => {
  it.each(activeLocales)('$code domain $domain resolves', async (locale) => {
    const result = await dns.lookup(locale.domain).catch(() => null);
    expect(result).not.toBeNull();
  });

  it('no inactive locale appears in activeLocales', () => {
    const inactive = activeLocales.filter((l) => !l.active);
    expect(inactive).toHaveLength(0);
  });
});

Ce test échouera en CI si quelqu'un ajoute une locale avec un domaine mort, ou si un domaine actif expire sans que l'équipe s'en aperçoive.

Temps de récupération

Le fix a été déployé un jeudi. Les premiers signes de récupération dans Search Console sont apparus au bout de 8 jours — le temps que Google recrawle une fraction significative des 12 000 pages et revalide les clusters hreflang. La récupération complète du trafic organique a pris 23 jours. Le trafic .fr est revenu à son niveau d'avant l'érosion. Le trafic .com sur les requêtes françaises a cessé de cannibaliser le .fr.

Le domaine shop-marque.de a été racheté par l'équipe pour éviter tout détournement futur, même sans intention de le réutiliser. Coût : 9€/an. Assurance contre un risque de réputation potentiellement catastrophique.

L'incident a aussi révélé que les signaux de langue cassés ne se limitent pas aux attributs lang — les hreflang sont un vecteur de régression tout aussi silencieux. Et comme pour les métadonnées SEO qui se désynchronisent entre CMS et framework, la cause racine est toujours la même : deux sources de vérité qui divergent sans que personne ne monitore la divergence.

Ce qu'on en retient

Fermer un marché, c'est un projet business. Nettoyer les artefacts techniques de ce marché — hreflang, sitemaps, redirections, structured data —, c'est un projet technique qui n'apparaît dans aucun ticket JIRA si personne ne le demande explicitement.

Trois règles à graver :

  • Chaque locale doit avoir un flag active. Supprimer une entrée d'un tableau, c'est perdre la trace de son existence. La désactiver, c'est documenter la décision.
  • Les hreflang doivent être testés en CI. Un test DNS sur chaque domaine cible prend 200ms et évite des semaines de régression silencieuse.
  • Les domaines fermés doivent être conservés. 9€/an, contre le risque qu'un tiers récupère le domaine et hérite de signaux hreflang pointant depuis un site à forte autorité.

Un outil de monitoring continu comme Seogard détecte ce type de hreflang cassé dès le premier crawl post-déploiement — pas quatre mois plus tard, quand la courbe de trafic a déjà décroché.

Articles connexes

i18n18 juin 2026

Crowdin lang=\"auto\" : signal de langue cassé, −34 % trafic

Crowdin auto-translate injecte lang=\"auto\" sur tout le site. Google confond le marché cible. Récit, diagnostic et fix complet.

Actualités SEO18 juin 2026

Change of Address : déclarer toutes les variantes de domaine

Google clarifie sa doc sur les site moves : chaque variante de domaine doit être déclarée dans le Change of Address tool. Guide technique complet.

Actualités SEO17 juin 2026

Bing AI Citation Share : ce que ça change pour le SEO technique

Analyse technique du nouveau Citation Share dans Bing Webmaster Tools. Métriques AI, impact sur le trafic, et stratégies d'optimisation concrètes.

Headless17 juin 2026

Storyblok : redirections custom perdues après changement de plan

Un site e-commerce perd 1 200 redirections stockées en custom field Storyblok lors d'un upgrade de plan. Récit, diagnostic et fix complet.

Headless16 juin 2026

Sanity preview mode en prod : drafts indexés par Google

Un site Next.js + Sanity garde la preview API key en production. Googlebot indexe 340 drafts non publiés. Récit, diagnostic et fix complet.

Headless16 juin 2026

Strapi public role 403 : SSR vide, Googlebot indexe du blanc

Un seed Strapi écrase le rôle Public. L'API renvoie 403, le SSR sert du vide. Récit complet : diagnostic, fix, récupération SEO en 19 jours.