Un sélecteur de devise réécrit le title côté client — Google indexe USD partout
Jeudi 14h. L'équipe front d'une marketplace mode européenne (Nuxt 3, ~12 000 fiches produit, 14 devises) pousse un nouveau dropdown de devise. Le composant est fluide, animé, testé sur trois navigateurs. Vendredi soir, personne ne remarque rien. Lundi matin, l'équipe SEO ouvre la Search Console pour préparer un reporting trimestriel. Sur le rapport Performance, filtre pays = Allemagne : les impressions ont chuté de 31 % en quatre jours. Filtre pays = Suède : −44 %. Le seul marché stable, c'est les États-Unis.
Lundi 9h12 — « C'est un problème de Core Update ? »
Le premier réflexe du Lead SEO : chercher une annonce d'update Google. Rien sur X, rien sur Search Engine Journal. Hypothèse écartée.
Deuxième réflexe : vérifier les erreurs d'indexation dans la Search Console. Aucune alerte, aucun pic de pages non indexées. Les pages sont indexées. Le problème est ailleurs.
À 9h35, le Lead SEO tape site:example.com "Robe longue" dans Google. Le snippet affiché pour la fiche produit phare en France indique :
Robe Longue Lin — $129.00 | BrandName
Dollar. Pas euro. Le titre affiché dans les SERP porte le prix en USD — pour le marché français.
Il teste depuis un VPN allemand. Même résultat : $129.00. Suédois : $129.00. Partout, le title indexé contient le prix en dollars américains.
À 10h02, l'équipe front est convoquée. Le développeur principal ouvre la page dans Chrome, sélectionne EUR dans le dropdown. Le <title> se met à jour instantanément :
Robe Longue Lin — 119,00 € | BrandName
« Ça marche chez moi. » La phrase classique.
Le Lead SEO ouvre alors l'outil d'inspection d'URL de la Search Console. Le HTML capturé par Googlebot apparaît. Le <title> dans le <head> :
Robe Longue Lin — $129.00 | BrandName
Dollar. Le prix par défaut. Celui qui est rendu côté serveur avant toute interaction JS.
Les chiffres tombent vite. Sur les 21 derniers jours :
- Trafic organique hors-US : −18 700 clics (de 61 200 à 42 500).
- Impressions marchés EUR : −27 %.
- CTR moyen sur les fiches produit DE, FR, SE, DK : passé de 3.1 % à 1.9 %.
Les titres en dollars sur des SERP en euros détruisent le CTR. Un utilisateur allemand qui voit $129.00 au lieu de 119,00 € ne clique pas — ou clique chez le concurrent qui affiche le bon prix dans sa devise.
Le problème n'est pas un bug d'indexation. C'est un bug de rendu.
Le bug : document.title réécrit au runtime, invisible au crawl
Ce que voit le développeur
Le composant CurrencyDropdown.vue fonctionne comme suit. Au montage, il lit la devise depuis un cookie preferred_currency ou depuis le localStorage. S'il en trouve une, il met à jour le state Pinia, ce qui déclenche un watcher qui réécrit le document.title dynamiquement.
Voici le composant simplifié :
<script setup lang="ts">
import { useCurrencyStore } from '~/stores/currency'
import { watch } from 'vue'
const currencyStore = useCurrencyStore()
// Lecture cookie / localStorage au mount (client-side uniquement)
onMounted(() => {
const saved = localStorage.getItem('preferred_currency')
if (saved) {
currencyStore.setCurrency(saved)
}
})
// Watcher qui réécrit le title quand la devise change
watch(
() => currencyStore.currentCurrency,
(newCurrency) => {
const product = useProductStore().current
if (product) {
const price = product.prices[newCurrency]
const symbol = getCurrencySymbol(newCurrency)
document.title = `${product.name} — ${symbol}${price} | BrandName`
}
}
)
</script>
<template>
<select @change="currencyStore.setCurrency($event.target.value)">
<option v-for="c in currencyStore.availableCurrencies" :key="c" :value="c">
{{ c }}
</option>
</select>
</template>
Dans le navigateur, le flux est :
- Le SSR rend la page avec la devise par défaut (
USD). - L'hydratation Vue s'exécute.
onMountedlit le cookie/localStorage.- Le watcher réécrit
document.titleavec la bonne devise.
L'utilisateur humain ne voit jamais le titre en USD (sauf un flash de quelques millisecondes). Tout semble fonctionner.
Ce que voit Googlebot
Googlebot effectue un fetch HTTP brut. Il reçoit le HTML SSR. Le <title> contient la devise par défaut :
<!DOCTYPE html>
<html lang="fr">
<head>
<title>Robe Longue Lin — $129.00 | BrandName</title>
<meta name="description" content="Robe longue en lin, $129.00. Livraison offerte." />
<!-- ... -->
</head>
Même si Googlebot exécute le JavaScript dans un second passage (le fameux "two-wave indexing"), il ne dispose ni de localStorage ni de cookie preferred_currency. Le onMounted ne trouve rien. Le watcher ne se déclenche pas (la devise reste USD). Le title reste inchangé.
Résultat : 100 % des pages produit sont indexées avec le prix en USD, quelle que soit la langue ou le pays cible.
Vérification avec curl et Screaming Frog
L'équipe confirme le diagnostic avec un fetch brut :
curl -s https://www.example.com/fr/robe-longue-lin \
-H "User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
| grep -oP '<title>.*?</title>'
Sortie :
<title>Robe Longue Lin — $129.00 | BrandName</title>
Un crawl Screaming Frog en mode "JavaScript rendering off" sur les 12 000 fiches produit confirme : 100 % des titles contiennent $. Un second crawl en mode "JavaScript rendering on" (avec Chrome headless intégré) montre le même résultat — parce que Screaming Frog ne porte pas le cookie preferred_currency non plus.
Le diagnostic est clair. Le <title> SSR contient la devise par défaut. La réécriture est purement CSR, conditionnée à un état client (cookie/localStorage) que ni Googlebot ni aucun crawler ne possède.
Pourquoi les tests n'ont rien détecté
L'équipe avait des tests Cypress end-to-end. Mais chaque test démarrait en injectant un cookie preferred_currency=EUR dans le beforeEach. Le scénario "aucun cookie, première visite" n'était pas couvert. Le title SSR n'a jamais été vérifié en isolation.
Le pipeline CI n'avait aucun test sur le HTML statique rendu. Pas de snapshot du <head>. Pas de validation du <title> dans la réponse HTTP brute. Ce type de régression — un contenu qui diverge entre le SSR et le CSR — est un classique documenté dans d'autres frameworks aussi. Le pattern est toujours le même : le navigateur compense, le crawler ne compense pas.
L'aggravation par la meta description
Le même mécanisme touchait la meta description. Le composant réécrivait aussi document.querySelector('meta[name="description"]').content au runtime. Google affichait donc dans les snippets :
Robe longue en lin, $129.00. Livraison offerte.
Sur un marché français. La méta-description en dollars sur une SERP française fait chuter le CTR autant que le title. L'impact est double.
Ce type de divergence SSR/CSR sur les metas n'est pas limité aux devises. On observe le même schéma quand un layout parent se fait override silencieusement ou quand une meta async qui throw fait servir le fallback.
Le fix — en deux temps
Étape 1 : le title SSR doit refléter le marché, pas le client
La devise affichée dans le <title> ne doit pas dépendre d'un état client. Elle doit être déterminée côté serveur, à partir de la locale ou du sous-dossier de l'URL.
L'équipe refactorise le useHead dans le composable produit pour calculer le title côté serveur :
// composables/useProductSeo.ts
export function useProductSeo(product: Product) {
const { locale } = useI18n()
// Mapping locale → devise par défaut pour le marché
const localeCurrencyMap: Record<string, string> = {
'fr': 'EUR',
'de': 'EUR',
'se': 'SEK',
'dk': 'DKK',
'en': 'USD',
'en-gb': 'GBP',
}
const marketCurrency = localeCurrencyMap[locale.value] || 'USD'
const price = product.prices[marketCurrency]
const symbol = getCurrencySymbol(marketCurrency)
useHead({
title: `${product.name} — ${symbol}${formatPrice(price, marketCurrency)} | BrandName`,
meta: [
{
name: 'description',
content: `${product.shortDescription}, ${symbol}${formatPrice(price, marketCurrency)}. ${getShippingLabel(locale.value)}`,
},
],
})
}
Ce composable s'exécute au moment du SSR. Le useHead de Nuxt 3 injecte le <title> et la <meta name="description"> directement dans le HTML rendu. Googlebot reçoit le bon prix dans la bonne devise, sans exécuter une seule ligne de JavaScript côté client.
Le dropdown de devise continue de fonctionner côté client. Mais il ne touche plus au document.title. Il met à jour uniquement les prix affichés dans le <body> — ce qui est légitime et n'impacte pas l'indexation.
Étape 2 : validation et déploiement
Avant de déployer, l'équipe ajoute un test de non-régression :
// tests/seo/product-title.test.ts
import { describe, it, expect } from 'vitest'
import { $fetch } from '@nuxt/test-utils'
describe('Product page SSR title', () => {
it('renders EUR price for /fr/ locale', async () => {
const html = await $fetch('/fr/robe-longue-lin')
expect(html).toContain('<title>Robe Longue Lin — 119,00\u00a0€ | BrandName</title>')
expect(html).not.toContain('$')
})
it('renders SEK price for /se/ locale', async () => {
const html = await $fetch('/se/robe-longue-lin')
expect(html).toContain('kr')
expect(html).not.toContain('$')
})
it('renders USD price for /en/ locale', async () => {
const html = await $fetch('/en/robe-longue-lin')
expect(html).toContain('$129.00')
})
})
Le déploiement est poussé un mardi à 11h. L'équipe invalide le cache CDN (Cloudflare) sur les routes /fr/*, /de/*, /se/*, /dk/* et /en-gb/* :
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"purge_everything": true}'
Purge complète. Les pages servies depuis le cache portaient encore les titles en USD.
Récupération
L'équipe force une réindexation via l'API Indexing de Search Console pour les 200 pages produit à plus fort trafic. Le reste est laissé au crawl naturel.
Les résultats observés :
- J+3 : les titles EUR apparaissent dans les SERP pour 40 % des fiches FR/DE.
- J+7 : 85 % des titles corrigés visibles dans Search Console.
- J+14 : le CTR moyen sur les marchés EUR remonte de 1.9 % à 2.8 % (pas encore le 3.1 % d'avant — le ranking met plus de temps à se stabiliser).
- J+21 : les impressions hors-US reviennent à 95 % du niveau pré-incident. Le trafic clics est remonté à 57 800 (vs 61 200 avant, vs 42 500 au creux).
La récupération totale prend environ quatre semaines. Le CTR atteint 3.0 % à J+28.
Leçons opérationnelles
L'équipe met en place trois garde-fous :
- Snapshot HTML du
<head>dans le CI : chaque merge request déclenche un rendu SSR des pages critiques et compare le<title>à un pattern attendu (regex par locale). - Alerte Screaming Frog automatisée : un crawl hebdomadaire en mode JS-off vérifie qu'aucun title ne contient
$sur les locales non-USD. - Règle ESLint custom : interdiction de
document.title =dans les fichiers composants. Le title ne passe que paruseHead.
L'équipe a aussi documenté un principe dans leur wiki interne : « Tout ce qui apparaît dans le <head> doit être déterministe au SSR. Si ça dépend d'un cookie, d'un localStorage ou d'un state client, ça ne doit pas toucher le <head>. »
Ce principe rejoint ce que d'autres équipes découvrent dans des contextes différents — quand un CMS comme Contentful ne synchronise pas le champ SEO title ou quand des hreflang pointent vers des domaines supprimés parce que la source de vérité n'est pas la bonne.
Ce qu'on en retient
Le <title> est le premier signal que Google lit et le premier texte que l'utilisateur voit dans les SERP. Le réécrire côté client, c'est parier que le crawler exécutera le même code que le navigateur, dans le même contexte. Ce pari est perdu d'avance quand le contenu dépend d'un état utilisateur (cookie, localStorage, géolocalisation IP côté client).
La règle est simple : le <head> est le territoire du SSR. Le dropdown de devise peut transformer le DOM visible — pas les metas.
Un monitoring de rendu type Seogard détecte cette divergence SSR/CSR en comparant le HTML brut et le rendu post-JS sur chaque crawl. L'alerte arrive en minutes, pas en trois semaines.
La devise dans le title, c'est un détail. Perdre 18 700 clics en 21 jours parce qu'un document.title = a glissé dans un composant Vue, c'est un incident.