Un e-commerce européen de 12 000 pages produit, décliné en 5 langues, perd 40 % de son trafic organique sur les versions espagnole et italienne en trois mois. La cause : des annotations hreflang non réciproques introduites silencieusement lors d'une refonte du système de templates. Aucune alerte. Le problème est découvert par hasard dans Search Console, trois cycles de crawl trop tard.
Hreflang est l'une des implémentations les plus fragiles du SEO technique. La spécification est simple en surface — mais les modes de défaillance sont nombreux, silencieux, et souvent combinés.
Le modèle mental correct : hreflang est un graphe bidirectionnel
Avant de parler d'erreurs, posons le cadre. Chaque annotation hreflang crée une arête dans un graphe orienté entre deux URLs. Google ne valide une relation que si elle est réciproque : la page A pointe vers B, et B pointe vers A. Si une seule direction manque, Google ignore l'annotation — sans message d'erreur explicite dans Search Console.
Ce modèle a une conséquence directe : le nombre d'annotations croît de manière combinatoire. Un site en 5 langues/régions génère 5 annotations par page (4 alternatives + x-default). Pour 12 000 pages produit, cela représente 60 000 annotations hreflang qui doivent toutes être cohérentes.
La moindre rupture dans ce graphe — une URL en 404, un slash manquant, une locale mal formatée — casse silencieusement le signal pour la paire concernée.
Pourquoi Google ne vous dit (presque) rien
Le rapport "Ciblage international" de Search Console ne remonte qu'un sous-ensemble des erreurs hreflang. Il signale les tags avec des valeurs de langue invalides ou les URLs qui retournent une erreur, mais il ne détecte pas les cas les plus courants : annotations non réciproques sur des pages qui répondent en 200, URLs avec des paramètres de tracking ajoutés côté CMS, ou incompatibilité entre canonical et hreflang.
C'est pour cette raison qu'un audit hreflang ne peut pas reposer uniquement sur Search Console. Il nécessite un crawl complet et une validation programmatique.
Les 6 erreurs hreflang qui reviennent systématiquement
1. Annotations non réciproques
C'est l'erreur la plus fréquente et la plus destructrice. La page française pointe vers la page allemande, mais la page allemande ne pointe pas vers la française.
Cas typique : l'équipe qui gère le site .de utilise un template différent, ou a oublié d'ajouter la nouvelle locale après un lancement marché.
<!-- Sur https://shop.exemple.com/fr/chaussures-running -->
<link rel="alternate" hreflang="fr" href="https://shop.exemple.com/fr/chaussures-running" />
<link rel="alternate" hreflang="de" href="https://shop.exemple.com/de/laufschuhe" />
<link rel="alternate" hreflang="x-default" href="https://shop.exemple.com/fr/chaussures-running" />
<!-- Sur https://shop.exemple.com/de/laufschuhe -->
<link rel="alternate" hreflang="de" href="https://shop.exemple.com/de/laufschuhe" />
<!-- ERREUR : la page DE ne pointe pas vers la page FR -->
<!-- Google ignore TOUTES les annotations hreflang de cette paire -->
La correction est évidente — mais le problème est de détecter l'absence. Sur 12 000 pages × 5 langues, une vérification manuelle est impossible. Screaming Frog permet de crawler toutes les versions et d'exporter un rapport de réciprocité via l'onglet "Hreflang". Mais cela reste un audit ponctuel. Sans monitoring continu, la prochaine mise à jour de template réintroduira le problème.
2. Conflit entre canonical et hreflang
Quand une page déclare un rel="canonical" qui pointe vers une URL différente de celle utilisée dans l'annotation hreflang, Google reçoit un signal contradictoire : "cette page est un duplicata de X" ET "cette page est la version locale de Y".
<!-- Conflit : le canonical pointe vers /fr/ mais hreflang déclare /fr-be/ -->
<link rel="canonical" href="https://shop.exemple.com/fr/chaussures-running" />
<link rel="alternate" hreflang="fr-BE" href="https://shop.exemple.com/fr-be/chaussures-running" />
La règle : l'URL dans hreflang doit être identique à l'URL canonical de la page cible. Si la page /fr-be/chaussures-running a un canonical vers /fr/chaussures-running, alors hreflang doit pointer vers /fr/chaussures-running, pas vers /fr-be/.
Mieux encore : si /fr-be/ et /fr/ ont le même contenu et que vous canonicalisez vers /fr/, vous n'avez probablement pas besoin d'une annotation fr-BE distincte. Simplifiez le graphe.
Pour une compréhension complète de l'interaction entre canonical et les autres meta tags, consultez le guide complet des meta tags SEO.
3. Codes de langue/région invalides
Hreflang utilise le format ISO 639-1 pour la langue et, optionnellement, ISO 3166-1 Alpha 2 pour la région. Les erreurs courantes :
en-UKau lieu deen-GB(le code ISO de la Grande-Bretagne est GB, pas UK)zh-Hansau lieu dezh-Hansvia le script tag (Google accepte les subtags de script pour le chinois, mais pas pour les autres langues de cette manière)fr-FRquand une simple annotationfrsuffirait (surspécification qui réduit le matching)
Google documente explicitement les formats acceptés dans sa documentation hreflang. Point important : Google ne supporte pas les codes de langue à 3 lettres (ISO 639-2). fra pour le français sera ignoré silencieusement.
4. x-default absent ou mal utilisé
Le x-default indique à Google la page à servir quand aucune autre annotation hreflang ne correspond à la langue/région de l'utilisateur. Son absence n'est pas une erreur fatale — mais elle prive Google d'un signal de fallback.
L'erreur la plus fréquente : pointer x-default vers une page qui n'existe pas, ou vers la homepage quand le contexte est une page produit.
<!-- CORRECT : x-default pointe vers la version anglaise internationale -->
<link rel="alternate" hreflang="x-default" href="https://shop.exemple.com/en/running-shoes" />
<link rel="alternate" hreflang="en-US" href="https://shop.exemple.com/en-us/running-shoes" />
<link rel="alternate" hreflang="fr" href="https://shop.exemple.com/fr/chaussures-running" />
<link rel="alternate" hreflang="de" href="https://shop.exemple.com/de/laufschuhe" />
Deux stratégies valables pour x-default : pointer vers la version anglaise internationale (si elle existe), ou pointer vers une page de sélection de langue. Les deux fonctionnent. L'essentiel est la cohérence sur l'ensemble du site.
5. URLs avec trailing slash inconsistant
https://shop.exemple.com/fr/chaussures-running et https://shop.exemple.com/fr/chaussures-running/ sont deux URLs distinctes pour Google. Si le hreflang de la page DE pointe vers la version sans slash, mais que le canonical de la page FR est avec slash, la réciprocité est cassée.
Ce problème est d'autant plus vicieux qu'il est souvent invisible dans le code source : le CMS normalise d'un côté, le reverse proxy de l'autre, et le template hreflang utilise une troisième convention.
6. Hreflang sur des pages en soft 404 ou redirigées
Si une URL cible d'un hreflang retourne une redirection 301/302 ou est identifiée comme soft 404, Google ignore l'annotation. L'URL dans hreflang doit retourner un 200 avec le contenu attendu.
Scénario classique : un produit est supprimé du catalogue allemand mais reste disponible en France. La page /de/laufschuhe-modele-x redirige vers la catégorie, tandis que /fr/chaussures-running-modele-x garde son hreflang vers l'URL allemande. L'annotation est morte, mais personne ne le sait.
Trois méthodes d'implémentation : laquelle choisir
Google accepte trois méthodes pour déclarer les annotations hreflang. Chacune a des trade-offs spécifiques selon la taille du site et l'architecture technique.
Balises <link> dans le <head> HTML
C'est la méthode la plus courante. Chaque page inclut un jeu de balises <link rel="alternate" hreflang="..."> dans son <head>.
Avantage : simple à implémenter, facile à debugger (View Source dans le navigateur).
Inconvénient : sur un site en 15 langues, chaque page embarque 15+ balises supplémentaires dans le <head>, ce qui augmente la taille du HTML. Pour un site de 30 000 pages avec 15 locales, cela représente une charge non négligeable sur le crawl budget.
Pour les sites rendus côté client (SPA/CSR), assurez-vous que ces balises sont présentes dans le HTML initial servi au crawler, pas injectées par JavaScript après hydratation. C'est un piège classique du rendu client qui s'applique directement aux annotations hreflang.
HTTP headers Link
Pour les ressources non-HTML (PDFs, fichiers media) ou quand vous ne contrôlez pas le <head>, les headers HTTP sont une alternative :
Link: <https://shop.exemple.com/fr/chaussures-running>; rel="alternate"; hreflang="fr",
<https://shop.exemple.com/de/laufschuhe>; rel="alternate"; hreflang="de",
<https://shop.exemple.com/en/running-shoes>; rel="alternate"; hreflang="x-default"
Configuration Nginx pour injecter ces headers dynamiquement :
# /etc/nginx/conf.d/hreflang.conf
# Exemple pour un site bilingue FR/DE avec structure /fr/ et /de/
map $uri $hreflang_fr {
~^/fr/(.*)$ https://shop.exemple.com/fr/$1;
~^/de/(.*)$ https://shop.exemple.com/fr/$1;
}
map $uri $hreflang_de {
~^/fr/(.*)$ https://shop.exemple.com/de/$1;
~^/de/(.*)$ https://shop.exemple.com/de/$1;
}
server {
listen 443 ssl;
server_name shop.exemple.com;
location ~ ^/(fr|de)/ {
add_header Link "<$hreflang_fr>; rel=\"alternate\"; hreflang=\"fr\", <$hreflang_de>; rel=\"alternate\"; hreflang=\"de\", <$hreflang_fr>; rel=\"alternate\"; hreflang=\"x-default\"" always;
# ... proxy_pass ou racine statique
}
}
Attention : cette approche présuppose une symétrie parfaite des URLs entre les langues (/fr/chaussures-running ↔ /de/chaussures-running). Dès que les slugs sont traduits (/de/laufschuhe), le mapping Nginx devient un cauchemar à maintenir. Préférez alors la méthode sitemap.
Sitemap XML
Pour les sites à grande échelle, c'est l'approche la plus robuste. Les annotations hreflang sont déclarées dans le sitemap, pas dans le HTML :
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://shop.exemple.com/fr/chaussures-running</loc>
<xhtml:link rel="alternate" hreflang="fr"
href="https://shop.exemple.com/fr/chaussures-running"/>
<xhtml:link rel="alternate" hreflang="de"
href="https://shop.exemple.com/de/laufschuhe"/>
<xhtml:link rel="alternate" hreflang="es"
href="https://shop.exemple.com/es/zapatillas-running"/>
<xhtml:link rel="alternate" hreflang="it"
href="https://shop.exemple.com/it/scarpe-running"/>
<xhtml:link rel="alternate" hreflang="en"
href="https://shop.exemple.com/en/running-shoes"/>
<xhtml:link rel="alternate" hreflang="x-default"
href="https://shop.exemple.com/en/running-shoes"/>
</url>
<url>
<loc>https://shop.exemple.com/de/laufschuhe</loc>
<xhtml:link rel="alternate" hreflang="fr"
href="https://shop.exemple.com/fr/chaussures-running"/>
<xhtml:link rel="alternate" hreflang="de"
href="https://shop.exemple.com/de/laufschuhe"/>
<!-- ... toutes les alternates, réciprocité assurée -->
</url>
</urlset>
Avantages : pas d'impact sur la taille du HTML, source unique de vérité, plus facile à générer programmatiquement depuis une base de données. Combinée avec un sitemap segmenté par langue, cette approche offre une visibilité fine sur l'indexation de chaque version linguistique.
Inconvénient : le crawl du sitemap n'est pas instantané. Google peut mettre plusieurs jours à traiter un sitemap volumineux. Et si vous utilisez à la fois des balises HTML et le sitemap, les signaux contradictoires seront sources de confusion.
Recommandation : choisissez UNE méthode et tenez-vous-y. La cohabitation HTML + sitemap n'est pas interdite, mais elle multiplie les points de défaillance.
Génération programmatique : le script qui sauve
Sur un site de taille significative, la génération manuelle des annotations hreflang est une garantie de bugs. Voici un script TypeScript qui génère les annotations à partir d'une table de correspondance et valide la réciprocité :
// hreflang-generator.ts
// Génère et valide les annotations hreflang depuis un mapping CSV/JSON
interface LocaleMapping {
pageId: string;
locale: string;
url: string;
canonical: string;
httpStatus: number;
}
interface HreflangAnnotation {
sourceUrl: string;
alternates: { hreflang: string; href: string }[];
}
function generateHreflang(
mappings: LocaleMapping[],
xDefaultLocale: string = "en"
): { annotations: HreflangAnnotation[]; errors: string[] } {
const errors: string[] = [];
const annotations: HreflangAnnotation[] = [];
// Grouper par pageId
const groups = new Map<string, LocaleMapping[]>();
for (const m of mappings) {
if (!groups.has(m.pageId)) groups.set(m.pageId, []);
groups.get(m.pageId)!.push(m);
}
for (const [pageId, locales] of groups) {
// Validation : toutes les URLs doivent retourner 200
const non200 = locales.filter((l) => l.httpStatus !== 200);
for (const broken of non200) {
errors.push(
`[${pageId}] ${broken.locale}: ${broken.url} retourne ${broken.httpStatus}`
);
}
// Validation : l'URL hreflang doit correspondre au canonical
for (const locale of locales) {
if (locale.url !== locale.canonical) {
errors.push(
`[${pageId}] ${locale.locale}: URL hreflang (${locale.url}) ≠ canonical (${locale.canonical})`
);
}
}
// Validation : locale format ISO 639-1(-ISO 3166-1)?
const localeRegex = /^[a-z]{2}(-[A-Z]{2})?$/;
for (const locale of locales) {
if (locale.locale !== "x-default" && !localeRegex.test(locale.locale)) {
errors.push(
`[${pageId}] Format de locale invalide: "${locale.locale}"`
);
}
}
// Générer les alternates pour chaque URL du groupe
const activeLocales = locales.filter((l) => l.httpStatus === 200);
for (const source of activeLocales) {
const alternates = activeLocales.map((target) => ({
hreflang: target.locale,
href: target.url,
}));
// Ajouter x-default
const xDefault = activeLocales.find(
(l) => l.locale === xDefaultLocale
);
if (xDefault) {
alternates.push({
hreflang: "x-default",
href: xDefault.url,
});
} else {
errors.push(`[${pageId}] Aucune locale "${xDefaultLocale}" pour x-default`);
}
annotations.push({ sourceUrl: source.url, alternates });
}
}
return { annotations, errors };
}
// Usage : vérification de réciprocité post-crawl
function validateReciprocity(
annotations: HreflangAnnotation[]
): string[] {
const errors: string[] = [];
const index = new Map<string, Set<string>>();
// Construire l'index : URL → ensemble des URLs qu'elle référence
for (const ann of annotations) {
const targets = new Set(ann.alternates.map((a) => a.href));
index.set(ann.sourceUrl, targets);
}
// Vérifier la réciprocité
for (const ann of annotations) {
for (const alt of ann.alternates) {
if (alt.hreflang === "x-default") continue;
const targetAnnotations = index.get(alt.href);
if (!targetAnnotations) {
errors.push(
`${ann.sourceUrl} → ${alt.href} (${alt.hreflang}): page cible non trouvée dans le dataset`
);
} else if (!targetAnnotations.has(ann.sourceUrl)) {
errors.push(
`${ann.sourceUrl} → ${alt.href} (${alt.hreflang}): réciprocité manquante`
);
}
}
}
return errors;
}
Ce script couvre les trois validations critiques : codes HTTP, cohérence canonical/hreflang, et réciprocité. Intégrez-le dans votre pipeline CI/CD pour détecter les régressions avant le déploiement.
Scénario réel : migration multilingue d'un e-commerce
Prenons un cas concret. Un retailer mode basé en France opère un site e-commerce avec :
- 15 000 pages produit actives
- 5 versions linguistiques : fr, de, es, it, en-GB
- Total théorique d'annotations hreflang : 15 000 × 6 (5 alternates + x-default) = 90 000 annotations
- Architecture : Next.js avec SSR, base de données PIM centralisée, sitemap généré dynamiquement
Lors d'une migration de Next.js 13 vers Next.js 14 (passage aux App Router), l'équipe constate les problèmes suivants sur une période de 6 semaines :
Semaine 1-2 : le nouveau système de routing modifie la génération des URLs. Les trailing slashes sont supprimés sur les versions DE et IT, mais conservés sur FR, ES et EN-GB. Résultat : 6 000 paires hreflang cassées par inconsistance d'URL (la page FR pointe vers /de/laufschuhe/ avec slash, mais le canonical DE est désormais /de/laufschuhe sans slash).
Semaine 3 : un développeur corrige le trailing slash, mais introduit un bug dans le composant <Head> : les annotations hreflang ne sont plus incluses dans le HTML SSR initial — elles sont injectées côté client après hydratation. Googlebot reçoit le HTML sans hreflang. Le problème est identique à un mismatch d'hydratation : le HTML serveur et le HTML client divergent.
Semaine 4-6 : le trafic organique sur les versions ES et IT chute de 35 %. Google commence à afficher la version FR dans les SERPs espagnols. Le rapport "Ciblage international" de Search Console ne montre que 200 erreurs sur les 6 000 réelles.
Résolution : l'équipe réalise un crawl complet avec Screaming Frog (configuration : Spider > Crawl > Hreflang, en crawlant les 5 sous-répertoires linguistiques). Le rapport "Hreflang" de Screaming Frog identifie 5 847 URLs avec des erreurs de réciprocité. Temps de crawl des 75 000 pages (15 000 × 5) : environ 4 heures à 5 URLs/seconde.
La correction nécessite deux actions :
- Forcer l'injection des annotations hreflang dans le HTML SSR initial (pas dans un
useEffect) - Normaliser les trailing slashes via le middleware Next.js
// middleware.ts (Next.js App Router)
import { NextResponse, NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Normaliser : toujours SANS trailing slash (sauf la racine)
if (pathname !== "/" && pathname.endsWith("/")) {
const url = request.nextUrl.clone();
url.pathname = pathname.slice(0, -1);
return NextResponse.redirect(url, 308); // 308 = redirect permanent qui préserve la méthode
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Temps de récupération après correction : 3 à 4 semaines. Google doit recrawler les 75 000 pages et reconstruire le graphe hreflang.
La leçon de ce scénario : les régressions hreflang sont lentes à apparaître et lentes à corriger. Le delta entre l'introduction du bug et la chute de trafic visible est de 2-3 semaines — le temps que Google recrawle suffisamment de pages pour invalider le graphe. Un outil de monitoring comme Seogard, qui vérifie les meta tags à chaque crawl et alerte sur les disparitions, aurait détecté le problème en quelques heures au lieu de plusieurs semaines.
Audit et validation : la checklist opérationnelle
Avec Screaming Frog
- Configurez le crawl pour suivre les hreflang : Configuration > Spider > Crawl > Crawl Linked Hreflang
- Après le crawl, onglet Hreflang : vérifiez les colonnes "Missing Return Links", "Inconsistent Language", "Non-200 Hreflang URLs"
- Exportez le rapport : Reports > Hreflang > All Hreflang
- Filtrez sur les erreurs de réciprocité — ce sont les plus critiques
Avec Search Console
- Ancienne version (si encore accessible) : Trafic de recherche > Ciblage international > onglet Langue
- Nouvelle version : le rapport dédié hreflang a été supprimé. Vous pouvez vérifier l'indexation par langue en filtrant par sous-répertoire (/fr/, /de/, etc.) dans le rapport de couverture
- Vérifiez l'URL Inspection pour des pages spécifiques : le champ "Declared hreflang" montre ce que Google a effectivement détecté
Validation en ligne de commande
Pour un audit rapide sur une page spécifique :
# Extraire les annotations hreflang d'une page
curl -s https://shop.exemple.com/fr/chaussures-running | \
grep -oP '<link[^>]*hreflang="[^"]*"[^>]*href="[^"]*"[^>]*/>' | \
sed 's/.*hreflang="\([^"]*\)".*href="\([^"]*\)".*/\1 → \2/'
# Vérifier la réciprocité pour chaque URL trouvée
curl -s https://shop.exemple.com/fr/chaussures-running | \
grep -oP 'href="\K[^"]*(?="[^>]*hreflang)' | \
while read url; do
echo "--- Checking $url ---"
curl -s "$url" | grep -c "shop.exemple.com/fr/chaussures-running"
done
Ce n'est pas un substitut à un crawl complet, mais c'est suffisant pour valider un déploiement sur quelques URLs de référence avant de lancer un crawl Screaming Frog sur l'ensemble du site.
Edge cases et trade-offs que personne ne mentionne
Régions sans contenu traduit
Votre catalogue allemand ne couvre que 80 % des produits du catalogue français. Pour les 20 % restants, deux options :
- Ne pas inclure de hreflang pour les paires incomplètes : la page FR n'a pas d'annotation vers DE pour ces produits. Clean, mais Google pourrait quand même afficher la page FR aux utilisateurs allemands.
- Pointer vers la page catégorie DE : techniquement valide, mais mauvaise expérience utilisateur et signal de pertinence faible.
La meilleure approche : maintenir une page DE même pour les produits non disponibles, avec un message clair ("Cet article n'est pas disponible dans votre région") et un statut HTTP 200. Cela préserve le graphe hreflang. Le retirer du sitemap DE et du maillage interne DE suffit à le dés-indexer sans casser les annotations.
Hreflang et sites en mode SPA/CSR
Si votre site est une SPA avec du client-side rendering, les annotations hreflang injectées par JavaScript peuvent ne pas être vues par Googlebot lors de la première passe de crawl. Google utilise un processus en deux phases (crawl HTML → rendering JavaScript), et les annotations hreflang sont lues lors de la première phase.
Solutions : passer en SSR/SSG pour les pages qui nécessitent hreflang, ou utiliser la méthode sitemap XML qui ne dépend pas du rendu de la page. Le prerendering est aussi une option pour servir un HTML complet au crawler.
Hreflang et pagination
Pour les pages catégories paginées, chaque page paginée doit avoir son annotation hreflang vers la page paginée équivalente dans les autres langues. /fr/chaussures?page=3 pointe vers /de/schuhe?page=3, pas vers /de/schuhe. Si les paginations ne sont pas synchronisées entre les langues (la page 3 FR ne contient pas les mêmes produits que la page 3 DE), ne mettez pas de hreflang sur les pages paginées — limitez les annotations à la page 1.
Domaines séparés vs sous-répertoires
L'implémentation hreflang est identique quelle que soit l'architecture (sous-domaines, sous-répertoires, ccTLDs). Mais les domaines séparés ajoutent une contrainte : chaque domaine doit être vérifié dans Search Console, et les sitemaps de chaque domaine doivent référencer les URLs des autres domaines. C'est une source d'erreur supplémentaire, surtout quand les domaines sont gérés par des équipes différentes.
Performances et crawl budget
Sur un site de 50 000 pages en 10 langues, l'inclusion de 10 balises <link> hreflang par page ajoute environ 1 KB au HTML de chaque page. Sur 50 000 pages, cela représente 50 MB de données supplémentaires que Googlebot doit télécharger et parser. Ce n'est pas négligeable pour les sites qui ont des contraintes de crawl budget.
La méthode sitemap déplace cette charge vers un fichier dédié, crawlé moins fréquemment mais qui n'impacte pas la taille des pages individuelles. Pour les sites au-delà de 10 000 pages × 5 langues, c'est généralement le choix le plus efficace.
Monitoring continu : la seule solution viable
Un audit hreflang ponctuel corrige les erreurs du jour. Il ne protège pas contre les régressions de demain. Chaque déploiement, chaque modification de template, chaque produit ajouté ou supprimé est une occasion de casser le