Migration Vue 2 vers Vue 3 : 47 pages produit en panne SEO pendant 21 jours
Jeudi 14h. Une équipe de quatre développeurs pousse en production la dernière brique d'une migration Vue 2 vers Vue 3. Le composant ProductPage.vue a été réécrit en Composition API. Les tests Cypress passent. Le design est pixel-perfect. Le PO valide. Trois semaines plus tard, la Search Console révèle que 47 pages produit n'ont plus de meta title ni de meta description. Le trafic organique de la catégorie a fondu de 34 %. Personne n'a reçu d'alerte.
Lundi, T+18 jours — "Pourquoi on a perdu des clics sur les fiches produit ?"
Le lundi matin, la responsable SEO ouvre son rapport hebdomadaire Search Console. Elle repère une chute progressive sur le segment "fiches produit" du site — une plateforme e-commerce française spécialisée dans le matériel de sport outdoor, environ 1 200 pages indexées.
Les chiffres sont nets. Sur les 21 derniers jours, le segment produit affiche :
- Impressions : −41 % (de 112K à 66K)
- Clics : −34 % (de 8 200 à 5 400)
- Position moyenne : passée de 14.2 à 22.7
Premier réflexe : vérifier si Google a lancé une mise à jour. Le core update de mai 2026 est en cours de déploiement. L'hypothèse tient cinq minutes. Mais la chute ne touche que les fiches produit, pas les pages catégories, pas le blog. Un core update ne cible pas chirurgicalement 47 URLs d'un même template.
Deuxième hypothèse : un problème d'indexation. La responsable SEO ouvre l'outil d'inspection d'URL sur une fiche produit en baisse. Le rendu HTML renvoyé par Google montre un <title> vide. Pas absent du DOM — vide. La balise est là, mais son contenu est une chaîne nulle.
Elle inspecte la page dans Chrome. Le title s'affiche correctement dans l'onglet du navigateur. Elle fait un View Source : le title est bien dans le HTML initial. Elle ouvre les DevTools, regarde le <head> dans le DOM live : le title est là, avec le bon texte.
Alors elle fait ce que tout le monde oublie de faire : elle désactive JavaScript dans Chrome et recharge la page.
Le <title> est vide.
Elle envoie un message Slack à 9h47 : "Les meta titles des fiches produit sont vides côté SSR. Quelqu'un a touché au composant ProductPage récemment ?"
Le lead dev répond à 10h12 : "On a migré ce composant en Composition API il y a trois semaines. Mais les tests passent."
La conversation bascule. Ce n'est pas un bug mineur. C'est une régression silencieuse qui dure depuis le déploiement du 27 avril.
Le bug : useHead en Options API ne survit pas à la migration Composition API
L'équipe remonte au commit du 27 avril. Le diff est clair. Avant la migration, ProductPage.vue utilisait l'Options API avec le plugin vue-meta (version 2.x, compatible Vue 2). Après la migration, le composant a été réécrit avec <script setup> et @unhead/vue — le successeur recommandé pour Vue 3.
Le problème est dans la façon dont useHead a été porté.
L'ancien code (Vue 2 + vue-meta)
// ProductPage.vue — Vue 2 / Options API
export default {
name: 'ProductPage',
metaInfo() {
return {
title: this.product.name + ' | OutdoorShop',
meta: [
{
vmid: 'description',
name: 'description',
content: this.product.shortDescription
},
{
property: 'og:title',
content: this.product.name
}
]
}
},
data() {
return {
product: {}
}
},
async created() {
const res = await fetch(`/api/products/${this.$route.params.slug}`)
this.product = await res.json()
}
}
Avec vue-meta côté SSR, metaInfo() était résolue après le hook created. Le cycle de vie SSR de Vue 2 garantissait que this.product était populé avant que vue-meta ne collecte les balises meta pour le rendu serveur. Le title contenait le nom du produit. Tout fonctionnait.
Le nouveau code (Vue 3 + @unhead/vue)
<!-- ProductPage.vue — Vue 3 / Composition API -->
<script setup>
import { ref } from 'vue'
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const product = ref({})
useHead({
title: product.value.name
? product.value.name + ' | OutdoorShop'
: '',
meta: [
{
name: 'description',
content: product.value.shortDescription || ''
},
{
property: 'og:title',
content: product.value.name || ''
}
]
})
const fetchProduct = async () => {
const res = await fetch(`/api/products/${route.params.slug}`)
product.value = await res.json()
}
fetchProduct()
</script>
Le bug saute aux yeux une fois qu'on connaît le cycle de vie.
useHead() est appelé de manière synchrone dans <script setup>. Au moment de l'exécution, fetchProduct() n'a pas encore résolu. product.value est un objet vide. product.value.name est undefined. L'expression ternaire retombe sur la chaîne vide ''.
Le title injecté côté SSR est donc : "".
La meta description : "".
L'og:title : "".
Ce que voit le développeur vs ce que voit Googlebot
Voici le HTML renvoyé par le serveur — ce que Googlebot reçoit :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title></title>
<meta name="description" content="">
<meta property="og:title" content="">
<!-- ... -->
</head>
<body>
<div id="app">
<!-- contenu SSR avec product data vide ou placeholder -->
</div>
<script type="module" src="/assets/app.a3f8b2c1.js"></script>
</body>
</html>
Le title est une balise vide. La description est vide. Googlebot indexe la page avec un title vide, ou — dans le meilleur des cas — fabrique un title à partir du contenu visible de la page, souvent mal choisi.
Côté navigateur, le scénario est différent. Après hydratation, Vue 3 monte le composant côté client. fetchProduct() résout le fetch. product.value se remplit. Mais useHead avec des valeurs statiques ne se met pas à jour. Les meta restent vides dans le DOM.
Sauf que le développeur ne le voit pas. Le navigateur affiche le title de la page dans l'onglet via le document.title initial du SSR pendant le chargement, puis l'hydratation remplace le contenu visible du <body>. L'onglet du navigateur peut afficher le title SSR initial (vide) ou le mettre à jour si un autre script le change. Mais dans le cas présent, personne ne modifie document.title après coup.
Le développeur teste autrement : il navigue sur le site en SPA, via le router. En navigation client-side, le composant se monte, le fetch résout, et si un watcher était en place, le title se mettrait à jour. Mais ici, il n'y a pas de réactivité sur useHead — les valeurs sont passées en brut, pas en computed.
Résultat : en navigation SPA (le mode de test naturel du dev), le title peut sembler correct dans certains cas. En accès direct ou en SSR (le mode de Googlebot), le title est vide.
Pourquoi les tests n'ont rien détecté
L'équipe avait des tests Cypress end-to-end. Mais les tests Cypress s'exécutent après hydratation, dans un navigateur headless complet. Ils vérifient le DOM final, pas le HTML SSR. Le test cy.title().should('contain', 'Chaussure Trail') passait — parce qu'en environnement de test, le composant chargeait les données assez vite pour que le title soit mis à jour côté client avant que Cypress ne l'évalue.
Aucun test ne vérifiait le HTML brut retourné par le serveur avant exécution JavaScript.
Pas de crawl Screaming Frog post-déploiement avec le mode "JavaScript rendering OFF". Pas de curl automatisé dans la CI. Pas de diff sur les balises meta entre deux déploiements.
Le bug est passé en production sans résistance.
Vérification manuelle du problème
Pour confirmer, l'équipe lance un curl depuis le terminal :
curl -s https://www.outdoorshop.example/produit/chaussure-trail-x500 \
| grep -oP '<title>\K[^<]*'
Résultat : une ligne vide. Aucun contenu dans la balise title.
Puis la même commande sur une page catégorie (non migrée, encore en Options API) :
curl -s https://www.outdoorshop.example/categorie/chaussures-trail \
| grep -oP '<title>\K[^<]*'
Résultat : Chaussures Trail Homme & Femme | OutdoorShop. Le title est bien présent.
Le diagnostic est confirmé. Le problème touche exclusivement les 47 fiches produit dont le composant a été migré vers la Composition API le 27 avril.
Le fix : useHead réactif et vérification SSR dans la CI
Patch correctif
La correction est simple une fois le problème compris. useHead de @unhead/vue accepte des valeurs réactives — ref, computed, ou une fonction retournant un objet. Le code original passait des valeurs statiques évaluées au moment de l'appel. Il faut passer des computed.
<!-- ProductPage.vue — Vue 3 / Composition API — CORRIGÉ -->
<script setup>
import { ref, computed } from 'vue'
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const product = ref({})
useHead({
title: computed(
() => product.value.name
? `${product.value.name} | OutdoorShop`
: 'OutdoorShop'
),
meta: [
{
name: 'description',
content: computed(
() => product.value.shortDescription || ''
)
},
{
property: 'og:title',
content: computed(() => product.value.name || '')
}
]
})
const fetchProduct = async () => {
const res = await fetch(`/api/products/${route.params.slug}`)
product.value = await res.json()
}
// Attente du fetch AVANT le rendu SSR
await fetchProduct()
</script>
Deux corrections critiques dans ce patch :
-
computed()autour de chaque valeur dynamique.useHeadobserve les refs et computed. Quandproduct.valuechange, les balises meta se mettent à jour — côté client ET dans la collecte SSR si le fetch est résolu avant le rendu. -
awaitdevantfetchProduct(). En Vue 3 avec<script setup>, unawaitau top-level transforme le composant en composant asynchrone. Le serveur SSR (Vite SSR, Nuxt, ou un setup custom) attend la résolution avant de rendre le HTML. Sans ceawait, le fetch est fire-and-forget, et le rendu SSR se fait avecproduct.valueencore vide.
La documentation officielle de @unhead/vue précise explicitement que les valeurs dynamiques doivent être passées via des refs ou computed pour bénéficier de la réactivité. L'équipe avait porté la syntaxe, pas la sémantique.
Ajout d'un test SSR dans la CI
L'équipe ajoute un script dans le pipeline CI qui vérifie le HTML SSR brut après chaque build :
#!/bin/bash
# ci/check-ssr-meta.sh
# Vérifie que les meta titles ne sont pas vides sur les pages critiques
URLS=(
"/produit/chaussure-trail-x500"
"/produit/veste-gore-tex-pro"
"/produit/sac-a-dos-40l"
"/categorie/chaussures-trail"
)
EXIT_CODE=0
for path in "${URLS[@]}"; do
TITLE=$(curl -s "http://localhost:4173${path}" | grep -oP '<title>\K[^<]*')
if [ -z "$TITLE" ]; then
echo "FAIL: ${path} — title vide"
EXIT_CODE=1
else
echo "OK: ${path} — title: ${TITLE}"
fi
done
exit $EXIT_CODE
Ce script tourne contre le serveur SSR de preview (port 4173 par défaut avec vite preview). Pas besoin de headless browser. Un simple curl suffit pour attraper le problème.
Cache et re-crawl
Le site utilise un CDN Cloudflare avec cache HTML. Après le déploiement du fix, l'équipe purge le cache de toutes les URLs /produit/* via l'API Cloudflare. Sans cette purge, le CDN continuerait à servir le HTML avec les titles vides — y compris à Googlebot.
L'équipe soumet ensuite les 47 URLs via l'outil d'inspection d'URL de la Search Console, puis relance un crawl Screaming Frog complet en mode "HTML statique" (sans exécution JavaScript) pour confirmer que chaque fiche produit retourne un title et une description non vides.
Temps de récupération
Les premiers signes de récupération apparaissent 5 jours après le fix. Les impressions remontent progressivement. Au bout de 14 jours, le segment produit retrouve 91 % de son niveau de trafic d'avant la régression. Les 9 % restants mettent encore une semaine à revenir — certaines pages avaient perdu leur position en featured snippet et la reconquête est plus lente.
Bilan total de l'incident : 21 jours de régression + 19 jours de récupération. Soit 40 jours d'impact. Environ 5 600 clics perdus sur la période, estimés à 8 400 € de chiffre d'affaires manqué d'après le taux de conversion moyen du segment.
Ce qu'on en retient
Le portage syntaxique n'est pas un portage fonctionnel. Réécrire un composant de l'Options API vers <script setup> sans comprendre les différences de cycle de vie SSR crée des régressions invisibles aux tests classiques.
Trois règles auraient empêché cet incident :
- Tester le HTML SSR brut, pas seulement le DOM post-hydratation. Un
curldans la CI coûte zéro euro et cinq minutes de setup. - Toujours passer des computed à
useHeadquand les données viennent d'un fetch asynchrone. Les valeurs statiques dansuseHeadsont un piège documenté. - Comparer les meta avant/après chaque déploiement touchant des composants de page. Un outil de monitoring continu type Seogard détecte ce type de divergence SSR/CSR en quelques minutes — pas trois semaines après, quand la Search Console daigne remonter l'alerte.
La migration technique la plus propre du monde ne vaut rien si personne ne vérifie ce que Googlebot reçoit vraiment. Et Googlebot ne lance pas Cypress.