Migration Nuxt 2 vers Nuxt 3 : 200 pages en fallback layout pendant 6 semaines
Jeudi 14 mars, 18h20. Une équipe de quatre développeurs pousse en production la branche feat/nuxt3-migration d'un site e-commerce spécialisé outdoor — 1 200 pages, 80K visites organiques mensuelles. Le site est rapide, le Lighthouse est vert, les smoke tests passent. Le product owner envoie un GIF de célébration sur Slack. Six semaines plus tard, le responsable acquisition constate que le trafic organique du tunnel catégories/produits a fondu de 38%. Personne n'a touché au SEO. Personne n'a rien vu.
Lundi 28 avril, 9h12 — Le dashboard qui saigne
Le responsable acquisition ouvre Looker Studio pour préparer le comité mensuel. Le filtre « Organic Search » sur GA4 affiche une courbe descendante régulière depuis mi-mars. Pas un cliff, pas un décrochage brutal. Une lente hémorragie.
Premier réflexe : vérifier Search Console. Le rapport Performances sur 28 jours montre −31K clics par rapport à la période précédente. Les pages les plus touchées sont toutes dans /categories/ et /produits/. L'onglet Couverture ne signale aucune erreur d'indexation nouvelle. Pas de page en 404, pas de noindex suspect dans le rapport.
Le responsable SEO freelance, alerté à 10h, pose la question évidente : « Il y a eu un déploiement récent ? » L'équipe dev répond que la migration Nuxt 3 date de six semaines. « Mais on a tout testé, le rendu est identique. »
À 11h30, le freelance lance un crawl Screaming Frog sur 500 URLs. Le résultat tombe en vingt minutes. 204 pages partagent exactement le même <title> : "Outdoor Store — Votre boutique en ligne". C'est le title du layout par défaut. Aucune meta description spécifique. Aucun og:title différencié.
Le freelance remonte l'alerte : « Vos 204 pages catégories et produits n'ont plus de balises title uniques depuis la migration. Google les voit toutes identiques. »
L'équipe dev ouvre le code. Dans le navigateur, pourtant, les titles semblent corrects — le composant affiche bien le nom du produit dans l'onglet. Mais un curl sur la même URL raconte une autre histoire.
À 14h, le lead dev lance la commande qui confirme le désastre :
curl -s https://www.outdoor-store.example/produits/veste-gore-tex-alpine \
| grep -i '<title>'
Résultat :
<title>Outdoor Store — Votre boutique en ligne</title>
Le title du produit n'existe pas dans le HTML servi par le serveur. Il est injecté côté client, après hydratation. Pour un navigateur, ça marche. Pour Googlebot qui reçoit le HTML initial, c'est invisible — ou au mieux, crawlé avec retard et souvent ignoré au profit du SSR initial.
Six semaines. 204 pages. Aucune alerte.
Le bug : head() est mort, useHead() n'a jamais pris le relais
Pour comprendre ce qui s'est passé, il faut remonter à l'architecture Nuxt 2 du site.
Ce qui fonctionnait en Nuxt 2
Chaque page produit utilisait la méthode head() de l'Options API, une feature native de vue-meta intégrée à Nuxt 2 :
// pages/produits/_slug.vue — Nuxt 2
export default {
async asyncData({ params, $axios }) {
const product = await $axios.$get(`/api/products/${params.slug}`)
return { product }
},
head() {
return {
title: `${this.product.name} — Outdoor Store`,
meta: [
{
hid: 'description',
name: 'description',
content: this.product.metaDescription
},
{
hid: 'og:title',
property: 'og:title',
content: this.product.name
}
]
}
}
}
Ce code fonctionnait parfaitement côté serveur. vue-meta s'exécutait pendant le rendu SSR, injectait les balises dans le <head> du HTML final. Googlebot recevait un document complet.
Ce qui a été migré en Nuxt 3
L'équipe a suivi le guide de migration officiel pour les composants : remplacement de asyncData par useAsyncData, passage à la Composition API. Mais pour head(), le développeur en charge a fait une erreur d'interprétation. Il a vu que les pages s'affichaient correctement et a cru que le système fonctionnait.
Voici ce que le code est devenu :
<!-- pages/produits/[slug].vue — Nuxt 3 (version buggée) -->
<script setup>
const route = useRoute()
const { data: product } = await useAsyncData(
`product-${route.params.slug}`,
() => $fetch(`/api/products/${route.params.slug}`)
)
</script>
<template>
<div>
<Head>
<Title>{{ product?.name }} — Outdoor Store</Title>
<Meta
name="description"
:content="product?.metaDescription"
/>
</Head>
<ProductDetail :product="product" />
</div>
</template>
À première vue, ça semble correct. Le composant <Head> de Nuxt 3 est bien utilisé. Le title est dynamique. Mais le problème est plus subtil.
La divergence SSR / client
Le fichier app.vue du projet contenait un layout par défaut avec ses propres balises :
<!-- app.vue -->
<template>
<NuxtLayout>
<Head>
<Title>Outdoor Store — Votre boutique en ligne</Title>
<Meta
name="description"
content="Découvrez notre sélection outdoor."
/>
</Head>
<NuxtPage />
</NuxtLayout>
</template>
Le problème réside dans l'ordre de résolution SSR. Dans Nuxt 3, le composant <Head> utilise Unhead sous le capot. Unhead résout les tags par ordre de profondeur : les tags déclarés dans les composants enfants sont censés écraser ceux des parents.
Sauf que dans ce cas précis, useAsyncData avec $fetch provoquait une subtilité de timing. Lors du rendu SSR, le <Head> de app.vue s'exécutait immédiatement. Le <Head> de la page produit, lui, dépendait de la résolution de useAsyncData. Et la donnée product était null au moment du premier rendu SSR du composant <Head> dans la page.
Résultat : le <Title> de la page tentait de rendre {{ null?.name }} — Outdoor Store, ce qui produisait " — Outdoor Store". Unhead, recevant un title vide ou quasi-vide depuis l'enfant, laissait le title du parent prendre le dessus dans certaines configurations de résolution.
Le HTML SSR final :
<!DOCTYPE html>
<html>
<head>
<title>Outdoor Store — Votre boutique en ligne</title>
<meta name="description" content="Découvrez notre sélection outdoor." />
<!-- ... -->
</head>
<body>
<div id="__nuxt">
<!-- contenu produit rendu correctement côté serveur -->
<h1>Veste Gore-Tex Alpine Pro</h1>
<!-- ... -->
</div>
</body>
</html>
Le <h1> était correct — il était dans le template, pas conditionné par le timing du <Head>. Mais le <title> dans le <head> restait celui du layout parent.
Côté client, après hydratation, useAsyncData résolvait la donnée, le composant <Head> se mettait à jour, et l'onglet du navigateur affichait le bon titre. C'est exactement pour ça que personne dans l'équipe n'avait rien remarqué.
Pourquoi les tests n'ont rien détecté
L'équipe avait trois filets de sécurité. Tous les trois ont échoué.
1. Tests E2E Cypress. Les tests vérifiaient cy.title().should('include', product.name). Cypress exécute JavaScript. Il attendait l'hydratation. Le title était correct après hydratation. Test vert.
2. Lighthouse CI. Lighthouse utilise un Chromium headless qui exécute le JS. Même résultat : le title final était correct.
3. Review visuelle. Les développeurs ouvraient les pages dans Chrome. Le title dans l'onglet était correct. Personne n'a inspecté le HTML initial via curl ou via « View Page Source ».
Aucun test ne vérifiait le HTML SSR brut — celui que Googlebot reçoit en premier lors du crawl. C'est le même angle mort documenté dans l'incident Next.js Pages Router vers App Router et dans la migration Vue 2 vers Vue 3 : la divergence entre ce que voit le développeur et ce que voit le crawler.
Reproduction du bug
Pour quiconque veut vérifier sur son propre projet Nuxt 3 :
# 1. Construire le projet en mode production
npx nuxi build
# 2. Lancer le serveur de production
node .output/server/index.mjs
# 3. Récupérer le HTML brut d'une page dynamique
curl -s http://localhost:3000/produits/veste-gore-tex-alpine | grep -i '<title>'
# 4. Comparer avec le title attendu
# Si le résultat affiche le title du layout parent → le bug est confirmé
Cette commande doit être dans chaque pipeline CI de migration. Pas en option.
Le fix : useHead() au bon endroit, au bon moment
L'équipe a exploré deux pistes de correction. La première, rapide mais fragile. La seconde, propre et définitive.
Piste écartée : definePageMeta + useHead dans un middleware
Un dev a proposé de passer les metas via definePageMeta et un middleware global. Cette approche fonctionnait mais créait une indirection inutile et rendait le code difficile à maintenir pour 200 pages.
Fix retenu : useHead() dans le <script setup> avec accès garanti aux données
La solution propre consiste à utiliser le composable useHead() au lieu du composant <Head> dans le template, et à s'assurer que les données sont résolues avant l'appel :
<!-- pages/produits/[slug].vue — Nuxt 3 (version corrigée) -->
<script setup>
const route = useRoute()
const { data: product } = await useAsyncData(
`product-${route.params.slug}`,
() => $fetch(`/api/products/${route.params.slug}`)
)
// useHead() est appelé APRÈS la résolution de useAsyncData
// grâce au await dans <script setup> (Nuxt suspend le rendu)
useHead({
title: () => product.value
? `${product.value.name} — Outdoor Store`
: 'Outdoor Store — Votre boutique en ligne',
meta: [
{
name: 'description',
content: () => product.value?.metaDescription
?? 'Découvrez notre sélection outdoor.'
},
{
property: 'og:title',
content: () => product.value?.name ?? 'Outdoor Store'
}
]
})
</script>
<template>
<div>
<ProductDetail :product="product" />
</div>
</template>
Points critiques du correctif :
-
await useAsyncDatadans<script setup>— Leawaittop-level dans un<script setup>de Nuxt 3 déclenche le mode Suspense. Le rendu SSR attend la résolution de la donnée avant de continuer. Leproduct.valueest donc disponible quanduseHead()s'exécute côté serveur. -
useHead()au lieu de<Head>— Le composable s'exécute dans le contexte de setup, pas dans le template. Il a accès immédiat aux refs résolues. Pas de problème de timing de rendu du template. -
Fonctions réactives pour les valeurs — Les
() =>permettent à Unhead de recalculer les valeurs si la donnée change côté client (navigation SPA). Documentation Unhead : Reactivity. -
Suppression du
<Head>dansapp.vue— Le title/description par défaut a été déplacé dansnuxt.config.tsvia la cléapp.head, qui sert de fallback propre sans interférer avec les pages :
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
title: 'Outdoor Store — Votre boutique en ligne',
meta: [
{
name: 'description',
content: 'Découvrez notre sélection outdoor.'
}
]
}
}
})
Déploiement et invalidation
Le fix a été déployé un mercredi à 10h. L'équipe a ensuite :
- Purgé le cache Cloudflare sur l'ensemble du domaine.
- Soumis les 204 URLs impactées via l'API Indexing de Search Console (par batch de 50).
- Forcé un re-crawl via l'outil d'inspection d'URL sur les 15 pages les plus stratégiques.
- Vérifié chaque URL avec
curlavant de fermer le ticket.
# Vérification post-déploiement sur les pages critiques
for slug in veste-gore-tex-alpine sac-rando-50l chaussures-trek-gtx; do
echo "=== $slug ==="
curl -s "https://www.outdoor-store.example/produits/$slug" \
| grep -oP '(?<=<title>).*(?=</title>)'
done
Temps de récupération
Les premiers signes de reprise sont apparus au bout de 8 jours. Google a recrawlé les pages corrigées progressivement. Le trafic organique sur le tunnel catégories/produits a retrouvé son niveau d'avant-migration au bout de 23 jours. Certaines pages longue traîne ont mis plus de 5 semaines — les positions 6-10 sont les plus lentes à se restabiliser.
Au total, l'estimation de perte sur les 6 semaines + 3 semaines de récupération : environ 29K clics organiques et un manque à gagner estimé à 18K€ de chiffre d'affaires par le responsable acquisition.
Filets de sécurité ajoutés post-incident
L'équipe a mis en place trois garde-fous :
1. Test SSR dans la CI. Un script Node qui démarre le serveur Nuxt en production, fetch 20 URLs critiques, et vérifie que le <title> dans le HTML brut ne correspond pas au title par défaut.
2. Crawl Screaming Frog hebdomadaire. Automatisé via CLI, avec alerte Slack si plus de 2% des pages partagent le même title.
3. Monitoring continu. Vérification quotidienne que le HTML SSR et le rendu post-hydratation produisent les mêmes balises <title> et <meta description> sur un échantillon de pages.
Ce qu'on en retient
La méthode head() de Nuxt 2 fonctionnait parce que vue-meta était synchrone dans le cycle SSR. Nuxt 3 et Unhead offrent plus de flexibilité, mais cette flexibilité crée un angle mort : le composant <Head> dans un template peut être évalué avant que les données async soient disponibles côté serveur. Le bug est invisible dans un navigateur. Il est invisible dans Cypress. Il est invisible dans Lighthouse. Il n'est visible que dans le HTML brut — celui que Googlebot reçoit.
La leçon tient en une phrase : toute migration de framework nécessite un test automatisé qui compare le HTML SSR aux attentes SEO, page par page, avant chaque déploiement. Un outil de monitoring comme Seogard détecte ce type de divergence SSR/CSR en continu, sans attendre qu'un dashboard GA4 saigne six semaines plus tard. Mais même sans outil dédié, un simple curl | grep '<title>' dans la CI aurait suffi à éviter 29K clics perdus.
Le framework change. Le compilateur change. Googlebot, lui, lit toujours le HTML.