Crowdin auto-translate injecte lang="auto" : signal de langue cassé sur tout le site
Mercredi 14h30. L'équipe contenu d'une marketplace mode européenne lance la traduction automatique de 4 200 fiches produit via Crowdin. Le workflow est rodé : machine translation d'abord, relecture humaine ensuite. Dans le navigateur, les pages espagnoles s'affichent en espagnol. Les pages allemandes en allemand. Tout semble normal. Dix-huit jours plus tard, le Head of SEO ouvre Search Console et découvre que le trafic organique espagnol a fondu de 34 %. Les pages allemandes remontent sur des requêtes en néerlandais. Personne n'a touché au code. Le problème est dans une balise de quatre caractères.
T+0 — Mercredi 14h30 : le push qui ne fait pas de bruit
Le site tourne sur Next.js 14 (App Router) avec Strapi 4 comme CMS headless. Les traductions passent par Crowdin, connecté à Strapi via l'intégration officielle. Le workflow est simple : un content manager clique sur "Pre-translate" dans Crowdin, les chaînes passent en machine translation (DeepL via Crowdin), puis atterrissent dans Strapi en statut "draft" le temps de la relecture.
Sauf que l'intégration Crowdin-Strapi ne se contente pas de traduire les champs texte. Elle synchronise aussi les métadonnées du document — y compris le champ locale et, dans la configuration de ce site, un champ custom htmlLang utilisé par le layout Next.js pour peupler l'attribut lang de la balise <html>.
Personne ne regarde ce champ. Pourquoi le regarderait-on ? Il a été configuré une fois, il ne bouge jamais.
Sauf quand Crowdin le touche.
T+18 jours — Lundi 9h12 : l'alerte
Le Head of SEO ouvre le rapport de performance Search Console filtré sur l'Espagne. Les clics sont passés de 8 400/jour à 5 500/jour sur les 18 derniers jours. Pas de chute brutale — une érosion lente, régulière, jour après jour. Le genre de courbe qu'on attribue d'abord à la saisonnalité.
Il vérifie l'Allemagne. Même pattern : −22 % de clics. Mais les impressions allemandes ont migré vers des requêtes en néerlandais. Des fiches produit censées ranker sur "Sommerjacke Damen" apparaissent sur "zomerjack dames". Google a changé le marché cible.
9h35. Le Head of SEO appelle le lead dev. Première hypothèse : un problème de hreflang. L'équipe ouvre Screaming Frog, lance un crawl des 200 premières URLs espagnoles. Les balises hreflang sont intactes. Les <link rel="alternate" hreflang="es"> pointent vers les bonnes URLs. Les x-default aussi.
9h48. Deuxième hypothèse : un problème de contenu. Peut-être que les traductions machine ont dégradé la qualité et que Google a déclassé les pages. Le lead dev ouvre une fiche produit espagnole dans Chrome, View Source. Le contenu est en espagnol. Les meta title et description aussi. Tout est cohérent.
10h02. Le lead dev regarde de plus près le source HTML. Il tombe sur la ligne 1 :
<!DOCTYPE html>
<html lang="auto" dir="ltr">
lang="auto".
Pas lang="es". Pas lang="de". lang="auto" — une valeur qui n'existe dans aucune spécification. Ni HTML5, ni BCP 47, ni IANA Language Subtag Registry.
Il ouvre une page allemande. lang="auto". Une page française. lang="auto". Le site anglais, celui qui n'est pas passé par Crowdin : lang="en".
Le problème est identifié. Il touche 4 200 pages dans 4 langues.
Le bug : Crowdin écrase le champ htmlLang avec "auto" en attente de validation
Pour comprendre le mécanisme, il faut regarder trois couches : Crowdin, Strapi, et Next.js.
Couche 1 : Crowdin Pre-translate
Quand l'option "Pre-translate" est utilisée avec une engine de machine translation, Crowdin crée des traductions en statut "MT" (Machine Translated). Ces traductions ne sont pas "approuvées" tant qu'un réviseur humain ne les valide pas.
Le problème vient du mapping de champs dans l'intégration Crowdin-Strapi. Le fichier de configuration de l'intégration ressemblait à ceci :
{
"strapi": {
"content_types": {
"api::product.product": {
"fields": {
"title": { "translatable": true },
"description": { "translatable": true },
"seoTitle": { "translatable": true },
"seoDescription": { "translatable": true },
"htmlLang": { "translatable": true }
}
}
}
}
}
Le champ htmlLang a été marqué comme translatable. Logique en apparence : chaque locale a sa propre valeur de htmlLang. Sauf que Crowdin ne sait pas que ce champ est un code de langue — il le traite comme n'importe quelle chaîne de texte. Et quand la machine translation n'a pas encore de traduction approuvée pour un champ, le connecteur Strapi écrit une valeur temporaire.
Cette valeur temporaire : "auto".
Ce n'est pas documenté explicitement dans la documentation Crowdin. Le comportement vient du fait que le connecteur utilise le statut de la traduction pour décider quoi écrire. Pour les chaînes en statut "MT" non approuvées, certains champs reçoivent la valeur source. Mais pour les champs courts (moins de 5 caractères), le connecteur applique une logique différente : il écrit "auto" comme placeholder, en attendant la validation humaine.
Couche 2 : Strapi
Côté Strapi, le champ htmlLang est un simple champ texte, sans validation de format :
// schema.json du content-type Product
{
"htmlLang": {
"type": "string",
"default": "en",
"required": false
}
}
Pas de regex. Pas d'enum. Pas de validation BCP 47. Strapi accepte "auto" sans broncher. Le champ passe de "es" à "auto" dans la base de données. Aucune erreur, aucun log, aucun warning.
Pour vérifier l'étendue du problème, le lead dev a lancé une requête directe sur la base PostgreSQL de Strapi :
SELECT locale, html_lang, COUNT(*)
FROM products
GROUP BY locale, html_lang
ORDER BY locale;
Résultat :
| locale | html_lang | count |
|---|---|---|
| de | auto | 1 050 |
| en | en | 4 200 |
| es | auto | 1 050 |
| fr | auto | 1 050 |
| it | auto | 1 050 |
Les 4 200 pages non-anglaises portent toutes htmlLang = "auto".
Couche 3 : Next.js
Le layout racine de l'application Next.js utilisait ce champ pour construire la balise <html> :
// app/[locale]/layout.tsx
import { getProductMeta } from '@/lib/strapi';
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const meta = await getProductMeta(params.locale);
return (
<html lang={meta.htmlLang || params.locale} dir="ltr">
<body>{children}</body>
</html>
);
}
Le fallback params.locale aurait dû rattraper le coup. Mais meta.htmlLang n'était pas null ni undefined — il valait "auto", une chaîne non vide. Le || ne déclenche pas le fallback. "auto" est truthy.
Le HTML servi par le serveur Next.js à chaque requête :
<!DOCTYPE html>
<html lang="auto" dir="ltr">
<head>
<link rel="alternate" hreflang="es" href="https://example.com/es/producto/..." />
<link rel="alternate" hreflang="de" href="https://example.com/de/produkt/..." />
<!-- ... -->
</head>
Un paradoxe : les balises hreflang disent "cette page est en espagnol", mais la balise <html> dit "la langue est auto". Pour un navigateur, lang="auto" n'a aucune signification — il l'ignore. Pour un lecteur d'écran, c'est un problème d'accessibilité. Pour Googlebot, c'est un signal de langue invalide.
Ce que voit Googlebot vs ce que voit le développeur
Le développeur ouvre la page dans Chrome. Le contenu est en espagnol. Le navigateur détecte automatiquement la langue via son algorithme de détection. L'expérience utilisateur est normale.
Googlebot ne fait pas de détection automatique au même niveau. Il lit le signal lang de la balise <html> comme un signal fort pour déterminer la langue et le marché cible de la page. Quand ce signal est invalide, Googlebot se rabat sur d'autres signaux : contenu textuel, hreflang, domaine, et — c'est le point crucial — les patterns de liens entrants.
Pour les pages allemandes, beaucoup de liens venaient de sites néerlandais (la marketplace avait un partenariat avec un comparateur de prix basé aux Pays-Bas). Sans signal lang valide, Googlebot a pondéré ces liens entrants plus fortement et a commencé à associer les pages allemandes au marché néerlandais.
Résultat dans Search Console : les pages /de/produkt/sommerjacke-damen-1234 apparaissaient dans les résultats néerlandais pour des requêtes néerlandaises. La documentation Google sur la langue est claire : l'attribut lang de la balise <html> est un signal que Googlebot utilise pour comprendre la langue d'une page.
Pourquoi les tests n'ont rien détecté
L'équipe avait des tests E2E avec Playwright. Mais les tests vérifiaient le contenu visible : titre affiché, prix, images. Aucun test ne validait l'attribut lang de <html>. Le pipeline CI/CD ne faisait aucune assertion sur les balises SEO du document root.
Screaming Frog n'était pas configuré pour alerter sur des valeurs lang non standard — le crawler par défaut collecte la valeur mais ne la valide pas contre la liste BCP 47.
Et comme le contenu textuel était correctement traduit, rien n'était visuellement cassé. Le bug était invisible à l'œil humain. Seul un audit HTML brut ou un monitoring des divergences SSR l'aurait détecté.
Le fix : trois couches, trois correctifs
Correctif 1 — Strapi : validation du champ htmlLang
Ajout d'une validation sur le content-type Product pour rejeter toute valeur non conforme BCP 47 :
// src/api/product/content-types/product/lifecycles.js
const bcp47 = /^[a-z]{2,3}(-[A-Z]{2})?$/;
module.exports = {
beforeUpdate(event) {
const { data } = event.params;
if (data.htmlLang && !bcp47.test(data.htmlLang)) {
throw new Error(
`Invalid htmlLang value: "${data.htmlLang}". Must be a valid BCP 47 tag.`
);
}
},
beforeCreate(event) {
const { data } = event.params;
if (data.htmlLang && !bcp47.test(data.htmlLang)) {
throw new Error(
`Invalid htmlLang value: "${data.htmlLang}". Must be a valid BCP 47 tag.`
);
}
},
};
Correctif 2 — Next.js : fallback robuste
Le layout a été modifié pour ne jamais faire confiance aveuglément au champ CMS :
// app/[locale]/layout.tsx
const VALID_LANGS = ['en', 'es', 'de', 'fr', 'it'] as const;
type ValidLang = typeof VALID_LANGS[number];
function resolveHtmlLang(cmsValue: string | null, locale: string): ValidLang {
if (cmsValue && VALID_LANGS.includes(cmsValue as ValidLang)) {
return cmsValue as ValidLang;
}
if (VALID_LANGS.includes(locale as ValidLang)) {
return locale as ValidLang;
}
return 'en';
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const meta = await getProductMeta(params.locale);
const htmlLang = resolveHtmlLang(meta.htmlLang, params.locale);
return (
<html lang={htmlLang} dir="ltr">
<body>{children}</body>
</html>
);
}
Le || a été remplacé par une fonction qui valide explicitement la valeur contre une liste blanche. Plus de truthy/falsy implicite.
Correctif 3 — Crowdin : exclure le champ htmlLang du mapping
Le fichier de configuration de l'intégration a été modifié pour ne plus marquer htmlLang comme traduisible :
{
"strapi": {
"content_types": {
"api::product.product": {
"fields": {
"title": { "translatable": true },
"description": { "translatable": true },
"seoTitle": { "translatable": true },
"seoDescription": { "translatable": true },
"htmlLang": { "translatable": false }
}
}
}
}
}
Le champ htmlLang est désormais géré manuellement par l'équipe contenu lors de la création de la locale dans Strapi. Il ne passe plus par le pipeline de traduction.
Correction des données existantes
Un script de migration a été exécuté pour corriger les 4 200 entrées :
UPDATE products SET html_lang = 'es' WHERE locale = 'es' AND html_lang = 'auto';
UPDATE products SET html_lang = 'de' WHERE locale = 'de' AND html_lang = 'auto';
UPDATE products SET html_lang = 'fr' WHERE locale = 'fr' AND html_lang = 'auto';
UPDATE products SET html_lang = 'it' WHERE locale = 'it' AND html_lang = 'auto';
Cache et re-crawl
Après le déploiement du fix, l'équipe a :
- Purgé le cache ISR de Next.js (
next/cacherevalidation via API route). - Soumis les 4 sitemaps localisés dans Search Console pour demander un re-crawl.
- Utilisé l'outil d'inspection d'URL sur 20 pages échantillons pour vérifier que Googlebot recevait bien
lang="es",lang="de", etc.
Temps de récupération
Les premiers signes de rétablissement sont apparus 6 jours après le fix. Le trafic espagnol a retrouvé son niveau d'avant l'incident en 14 jours. Le trafic allemand a mis 21 jours — les associations néerlandaises parasites ayant pris plus de temps à se dissiper dans l'index.
Au total, l'estimation de perte : environ 52 000 clics organiques sur les marchés non-anglophones, sur une période de 32 jours (18 jours de bug + 14 jours de récupération).
Mesures préventives
L'équipe a ajouté un test Playwright dans le pipeline CI :
// tests/seo/html-lang.spec.ts
import { test, expect } from '@playwright/test';
const locales = ['en', 'es', 'de', 'fr', 'it'];
for (const locale of locales) {
test(`HTML lang attribute is "${locale}" for /${locale}/ pages`, async ({ page }) => {
await page.goto(`/${locale}/producto/test-product`);
const lang = await page.getAttribute('html', 'lang');
expect(lang).toBe(locale);
});
}
Ce test bloque le déploiement si l'attribut lang ne correspond pas à la locale de la route. Le genre de test qui prend 5 minutes à écrire et qui aurait évité 32 jours de dégâts.
Un monitoring Screaming Frog a été configuré pour crawler les 5 versions localisées chaque semaine et alerter si une valeur lang non standard apparaît. L'équipe a aussi documenté une règle interne : tout champ qui alimente une balise HTML structurelle (lang, dir, hreflang, meta robots) ne doit jamais être marqué comme translatable dans un connecteur de traduction.
Ce qu'on en retient
Un connecteur de traduction n'est pas qu'un outil de contenu. Il touche aux métadonnées, aux champs techniques, aux signaux que les moteurs de recherche utilisent pour classer un site. Quand un champ comme htmlLang est marqué comme traduisible par erreur, le pipeline de machine translation peut injecter des valeurs temporaires que personne ne vérifie — parce que le contenu visible, lui, est correct.
Le fossé entre ce que voit un humain dans un navigateur et ce que lit Googlebot dans le HTML brut est le terreau de ces régressions silencieuses. Un monitoring continu type Seogard détecte ce type de divergence entre signal déclaratif et contenu réel en quelques minutes, pas en 18 jours.
Les champs techniques d'un CMS ne sont pas du contenu. Ils ne doivent jamais transiter par un pipeline de traduction automatique.