[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fqe7EmBCdNOcu3Dj8MexLtpW4ukQyU-hsWunvCqu-4cY":3,"$fMSbCVwcE2dUsgKi4yiJjOcJpXI2w8yYxHbpf2MwNpg0":24},{"_id":4,"slug":5,"__v":6,"author":7,"body":8,"canonical":9,"category":10,"createdAt":11,"date":12,"description":13,"htmlContent":14,"image":15,"imageAlt":15,"readingTime":16,"tags":17,"title":22,"updatedAt":23},"6a133d06aa6b273b0cd08cc0","migration-nuxt-2-vers-nuxt-3-200-pages-en-fallback-layout-pendant-6-semaines",0,"Equipe Seogard","# Migration Nuxt 2 vers Nuxt 3 : 200 pages en fallback layout pendant 6 semaines\n\nJeudi 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.\n\n## Lundi 28 avril, 9h12 — Le dashboard qui saigne\n\nLe 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.\n\nPremier 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.\n\nLe 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. »\n\nÀ 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 `\u003Ctitle>` : **\"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é.\n\nLe 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. »\n\nL'é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.\n\nÀ 14h, le lead dev lance la commande qui confirme le désastre :\n\n```bash\ncurl -s https://www.outdoor-store.example/produits/veste-gore-tex-alpine \\\n  | grep -i '\u003Ctitle>'\n```\n\nRésultat :\n\n```html\n\u003Ctitle>Outdoor Store — Votre boutique en ligne\u003C/title>\n```\n\nLe 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.\n\nSix semaines. 204 pages. Aucune alerte.\n\n## Le bug : head() est mort, useHead() n'a jamais pris le relais\n\nPour comprendre ce qui s'est passé, il faut remonter à l'architecture Nuxt 2 du site.\n\n### Ce qui fonctionnait en Nuxt 2\n\nChaque page produit utilisait la méthode `head()` de l'Options API, une feature native de `vue-meta` intégrée à Nuxt 2 :\n\n```javascript\n// pages/produits/_slug.vue — Nuxt 2\nexport default {\n  async asyncData({ params, $axios }) {\n    const product = await $axios.$get(`/api/products/${params.slug}`)\n    return { product }\n  },\n  head() {\n    return {\n      title: `${this.product.name} — Outdoor Store`,\n      meta: [\n        {\n          hid: 'description',\n          name: 'description',\n          content: this.product.metaDescription\n        },\n        {\n          hid: 'og:title',\n          property: 'og:title',\n          content: this.product.name\n        }\n      ]\n    }\n  }\n}\n```\n\nCe code fonctionnait parfaitement côté serveur. `vue-meta` s'exécutait pendant le rendu SSR, injectait les balises dans le `\u003Chead>` du HTML final. Googlebot recevait un document complet.\n\n### Ce qui a été migré en Nuxt 3\n\nL'é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.\n\nVoici ce que le code est devenu :\n\n```vue\n\u003C!-- pages/produits/[slug].vue — Nuxt 3 (version buggée) -->\n\u003Cscript setup>\nconst route = useRoute()\nconst { data: product } = await useAsyncData(\n  `product-${route.params.slug}`,\n  () => $fetch(`/api/products/${route.params.slug}`)\n)\n\u003C/script>\n\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003CHead>\n      \u003CTitle>{{ product?.name }} — Outdoor Store\u003C/Title>\n      \u003CMeta\n        name=\"description\"\n        :content=\"product?.metaDescription\"\n      />\n    \u003C/Head>\n    \u003CProductDetail :product=\"product\" />\n  \u003C/div>\n\u003C/template>\n```\n\nÀ première vue, ça semble correct. Le composant `\u003CHead>` de Nuxt 3 est bien utilisé. Le title est dynamique. Mais le problème est plus subtil.\n\n### La divergence SSR / client\n\nLe fichier `app.vue` du projet contenait un layout par défaut avec ses propres balises :\n\n```vue\n\u003C!-- app.vue -->\n\u003Ctemplate>\n  \u003CNuxtLayout>\n    \u003CHead>\n      \u003CTitle>Outdoor Store — Votre boutique en ligne\u003C/Title>\n      \u003CMeta\n        name=\"description\"\n        content=\"Découvrez notre sélection outdoor.\"\n      />\n    \u003C/Head>\n    \u003CNuxtPage />\n  \u003C/NuxtLayout>\n\u003C/template>\n```\n\nLe problème réside dans l'ordre de résolution SSR. Dans Nuxt 3, le composant `\u003CHead>` utilise [Unhead](https://unhead.unjs.io/) 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.\n\nSauf que dans ce cas précis, `useAsyncData` avec `$fetch` provoquait une subtilité de timing. Lors du rendu SSR, le `\u003CHead>` de `app.vue` s'exécutait immédiatement. Le `\u003CHead>` 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 `\u003CHead>` dans la page.\n\nRésultat : le `\u003CTitle>` 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.\n\nLe HTML SSR final :\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml>\n\u003Chead>\n  \u003Ctitle>Outdoor Store — Votre boutique en ligne\u003C/title>\n  \u003Cmeta name=\"description\" content=\"Découvrez notre sélection outdoor.\" />\n  \u003C!-- ... -->\n\u003C/head>\n\u003Cbody>\n  \u003Cdiv id=\"__nuxt\">\n    \u003C!-- contenu produit rendu correctement côté serveur -->\n    \u003Ch1>Veste Gore-Tex Alpine Pro\u003C/h1>\n    \u003C!-- ... -->\n  \u003C/div>\n\u003C/body>\n\u003C/html>\n```\n\nLe `\u003Ch1>` était correct — il était dans le template, pas conditionné par le timing du `\u003CHead>`. Mais le `\u003Ctitle>` dans le `\u003Chead>` restait celui du layout parent.\n\nCôté client, après hydratation, `useAsyncData` résolvait la donnée, le composant `\u003CHead>` 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é.\n\n### Pourquoi les tests n'ont rien détecté\n\nL'équipe avait trois filets de sécurité. Tous les trois ont échoué.\n\n**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.\n\n**2. Lighthouse CI.** Lighthouse utilise un Chromium headless qui exécute le JS. Même résultat : le title final était correct.\n\n**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 ».\n\nAucun 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](/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client) et dans la [migration Vue 2 vers Vue 3](/blog/migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence) : la divergence entre ce que voit le développeur et ce que voit le crawler.\n\n### Reproduction du bug\n\nPour quiconque veut vérifier sur son propre projet Nuxt 3 :\n\n```bash\n# 1. Construire le projet en mode production\nnpx nuxi build\n\n# 2. Lancer le serveur de production\nnode .output/server/index.mjs\n\n# 3. Récupérer le HTML brut d'une page dynamique\ncurl -s http://localhost:3000/produits/veste-gore-tex-alpine | grep -i '\u003Ctitle>'\n\n# 4. Comparer avec le title attendu\n# Si le résultat affiche le title du layout parent → le bug est confirmé\n```\n\nCette commande doit être dans chaque pipeline CI de migration. Pas en option.\n\n## Le fix : useHead() au bon endroit, au bon moment\n\nL'équipe a exploré deux pistes de correction. La première, rapide mais fragile. La seconde, propre et définitive.\n\n### Piste écartée : `definePageMeta` + `useHead` dans un middleware\n\nUn 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.\n\n### Fix retenu : `useHead()` dans le `\u003Cscript setup>` avec accès garanti aux données\n\nLa solution propre consiste à utiliser le composable `useHead()` au lieu du composant `\u003CHead>` dans le template, et à s'assurer que les données sont résolues **avant** l'appel :\n\n```vue\n\u003C!-- pages/produits/[slug].vue — Nuxt 3 (version corrigée) -->\n\u003Cscript setup>\nconst route = useRoute()\n\nconst { data: product } = await useAsyncData(\n  `product-${route.params.slug}`,\n  () => $fetch(`/api/products/${route.params.slug}`)\n)\n\n// useHead() est appelé APRÈS la résolution de useAsyncData\n// grâce au await dans \u003Cscript setup> (Nuxt suspend le rendu)\nuseHead({\n  title: () => product.value\n    ? `${product.value.name} — Outdoor Store`\n    : 'Outdoor Store — Votre boutique en ligne',\n  meta: [\n    {\n      name: 'description',\n      content: () => product.value?.metaDescription\n        ?? 'Découvrez notre sélection outdoor.'\n    },\n    {\n      property: 'og:title',\n      content: () => product.value?.name ?? 'Outdoor Store'\n    }\n  ]\n})\n\u003C/script>\n\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003CProductDetail :product=\"product\" />\n  \u003C/div>\n\u003C/template>\n```\n\nPoints critiques du correctif :\n\n1. **`await useAsyncData` dans `\u003Cscript setup>`** — Le `await` top-level dans un `\u003Cscript setup>` de Nuxt 3 déclenche le mode Suspense. Le rendu SSR attend la résolution de la donnée avant de continuer. Le `product.value` est donc disponible quand `useHead()` s'exécute côté serveur.\n\n2. **`useHead()` au lieu de `\u003CHead>`** — 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.\n\n3. **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](https://unhead.unjs.io/docs/head/guides/reactivity).\n\n4. **Suppression du `\u003CHead>` dans `app.vue`** — Le title/description par défaut a été déplacé dans `nuxt.config.ts` via la clé `app.head`, qui sert de fallback propre sans interférer avec les pages :\n\n```typescript\n// nuxt.config.ts\nexport default defineNuxtConfig({\n  app: {\n    head: {\n      title: 'Outdoor Store — Votre boutique en ligne',\n      meta: [\n        {\n          name: 'description',\n          content: 'Découvrez notre sélection outdoor.'\n        }\n      ]\n    }\n  }\n})\n```\n\n### Déploiement et invalidation\n\nLe fix a été déployé un mercredi à 10h. L'équipe a ensuite :\n\n- Purgé le cache Cloudflare sur l'ensemble du domaine.\n- Soumis les 204 URLs impactées via l'API Indexing de Search Console (par batch de 50).\n- Forcé un re-crawl via l'outil d'inspection d'URL sur les 15 pages les plus stratégiques.\n- Vérifié chaque URL avec `curl` avant de fermer le ticket.\n\n```bash\n# Vérification post-déploiement sur les pages critiques\nfor slug in veste-gore-tex-alpine sac-rando-50l chaussures-trek-gtx; do\n  echo \"=== $slug ===\"\n  curl -s \"https://www.outdoor-store.example/produits/$slug\" \\\n    | grep -oP '(?\u003C=\u003Ctitle>).*(?=\u003C/title>)'\ndone\n```\n\n### Temps de récupération\n\nLes 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.\n\nAu 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.\n\n### Filets de sécurité ajoutés post-incident\n\nL'équipe a mis en place trois garde-fous :\n\n**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 `\u003Ctitle>` dans le HTML brut ne correspond pas au title par défaut.\n\n**2. Crawl Screaming Frog hebdomadaire.** Automatisé via CLI, avec alerte Slack si plus de 2% des pages partagent le même title.\n\n**3. Monitoring continu.** Vérification quotidienne que le HTML SSR et le rendu post-hydratation produisent les mêmes balises `\u003Ctitle>` et `\u003Cmeta description>` sur un échantillon de pages.\n\n## Ce qu'on en retient\n\nLa 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 `\u003CHead>` 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.\n\nLa 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 '\u003Ctitle>'` dans la CI aurait suffi à éviter 29K clics perdus.\n\nLe framework change. Le compilateur change. Googlebot, lui, lit toujours le HTML.\n```","https://seogard.io/blog/migration-nuxt-2-vers-nuxt-3-200-pages-en-fallback-layout-pendant-6-semaines","Migration","2026-05-24T18:01:42.250Z","2026-05-24","Récit d'une migration Nuxt 2 → Nuxt 3 où head() disparaît, 200 pages servent un title générique, et le trafic chute de 38%. Diagnostic et fix.","\u003Ch1>Migration Nuxt 2 vers Nuxt 3 : 200 pages en fallback layout pendant 6 semaines\u003C/h1>\n\u003Cp>Jeudi 14 mars, 18h20. Une équipe de quatre développeurs pousse en production la branche \u003Ccode>feat/nuxt3-migration\u003C/code> 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.\u003C/p>\n\u003Ch2>Lundi 28 avril, 9h12 — Le dashboard qui saigne\u003C/h2>\n\u003Cp>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.\u003C/p>\n\u003Cp>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 \u003Ccode>/categories/\u003C/code> et \u003Ccode>/produits/\u003C/code>. L'onglet Couverture ne signale aucune erreur d'indexation nouvelle. Pas de page en 404, pas de noindex suspect dans le rapport.\u003C/p>\n\u003Cp>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. »\u003C/p>\n\u003Cp>À 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 \u003Ccode>&#x3C;title>\u003C/code> : \u003Cstrong>\"Outdoor Store — Votre boutique en ligne\"\u003C/strong>. C'est le title du layout par défaut. Aucune meta description spécifique. Aucun \u003Ccode>og:title\u003C/code> différencié.\u003C/p>\n\u003Cp>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. »\u003C/p>\n\u003Cp>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 \u003Ccode>curl\u003C/code> sur la même URL raconte une autre histoire.\u003C/p>\n\u003Cp>À 14h, le lead dev lance la commande qui confirme le désastre :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -s\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> https://www.outdoor-store.example/produits/veste-gore-tex-alpine\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  |\u003C/span>\u003Cspan style=\"color:#B392F0\"> grep\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -i\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '&#x3C;title>'\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Résultat :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Outdoor Store — Votre boutique en ligne&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>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.\u003C/p>\n\u003Cp>Six semaines. 204 pages. Aucune alerte.\u003C/p>\n\u003Ch2>Le bug : head() est mort, useHead() n'a jamais pris le relais\u003C/h2>\n\u003Cp>Pour comprendre ce qui s'est passé, il faut remonter à l'architecture Nuxt 2 du site.\u003C/p>\n\u003Ch3>Ce qui fonctionnait en Nuxt 2\u003C/h3>\n\u003Cp>Chaque page produit utilisait la méthode \u003Ccode>head()\u003C/code> de l'Options API, une feature native de \u003Ccode>vue-meta\u003C/code> intégrée à Nuxt 2 :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// pages/produits/_slug.vue — Nuxt 2\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> default\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  async\u003C/span>\u003Cspan style=\"color:#B392F0\"> asyncData\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">params\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#FFAB70\">$axios\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> product\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> $axios.\u003C/span>\u003Cspan style=\"color:#B392F0\">$get\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`/api/products/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">params\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">slug\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { product }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">() {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      title: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">`${\u003C/span>\u003Cspan style=\"color:#79B8FF\">this\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">product\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">name\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} — Outdoor Store`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      meta: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          hid: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'description'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          name: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'description'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          content: \u003C/span>\u003Cspan style=\"color:#79B8FF\">this\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.product.metaDescription\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          hid: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'og:title'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          property: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'og:title'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          content: \u003C/span>\u003Cspan style=\"color:#79B8FF\">this\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.product.name\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      ]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Ce code fonctionnait parfaitement côté serveur. \u003Ccode>vue-meta\u003C/code> s'exécutait pendant le rendu SSR, injectait les balises dans le \u003Ccode>&#x3C;head>\u003C/code> du HTML final. Googlebot recevait un document complet.\u003C/p>\n\u003Ch3>Ce qui a été migré en Nuxt 3\u003C/h3>\n\u003Cp>L'équipe a suivi le guide de migration officiel pour les composants : remplacement de \u003Ccode>asyncData\u003C/code> par \u003Ccode>useAsyncData\u003C/code>, passage à la Composition API. Mais pour \u003Ccode>head()\u003C/code>, 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.\u003C/p>\n\u003Cp>Voici ce que le code est devenu :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">&#x3C;!-- pages/produits/[slug].vue — Nuxt 3 (version buggée) -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">script\u003C/span>\u003Cspan style=\"color:#B392F0\"> setup\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> route\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> useRoute\u003C/span>\u003Cspan style=\"color:#E1E4E8\">()\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#FFAB70\">data\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">product\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> useAsyncData\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  `product-${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">route\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">params\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">slug\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#B392F0\"> $fetch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`/api/products/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">route\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">params\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">slug\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">script\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">template\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">Head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">Title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{{ product?.name }} — Outdoor Store&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">Title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">Meta\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">        name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">        :content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"product?.metaDescription\"\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">Head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">ProductDetail\u003C/span>\u003Cspan style=\"color:#B392F0\"> :product\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"product\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">template\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>À première vue, ça semble correct. Le composant \u003Ccode>&#x3C;Head>\u003C/code> de Nuxt 3 est bien utilisé. Le title est dynamique. Mais le problème est plus subtil.\u003C/p>\n\u003Ch3>La divergence SSR / client\u003C/h3>\n\u003Cp>Le fichier \u003Ccode>app.vue\u003C/code> du projet contenait un layout par défaut avec ses propres balises :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">&#x3C;!-- app.vue -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">template\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">NuxtLayout\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">Head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">Title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Outdoor Store — Votre boutique en ligne&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">Title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">Meta\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">        name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">        content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Découvrez notre sélection outdoor.\"\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">Head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">NuxtPage\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">NuxtLayout\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">template\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le problème réside dans l'ordre de résolution SSR. Dans Nuxt 3, le composant \u003Ccode>&#x3C;Head>\u003C/code> utilise \u003Ca href=\"https://unhead.unjs.io/\">Unhead\u003C/a> 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.\u003C/p>\n\u003Cp>Sauf que dans ce cas précis, \u003Ccode>useAsyncData\u003C/code> avec \u003Ccode>$fetch\u003C/code> provoquait une subtilité de timing. Lors du rendu SSR, le \u003Ccode>&#x3C;Head>\u003C/code> de \u003Ccode>app.vue\u003C/code> s'exécutait immédiatement. Le \u003Ccode>&#x3C;Head>\u003C/code> de la page produit, lui, dépendait de la résolution de \u003Ccode>useAsyncData\u003C/code>. Et la donnée \u003Ccode>product\u003C/code> était \u003Ccode>null\u003C/code> au moment du premier rendu SSR du composant \u003Ccode>&#x3C;Head>\u003C/code> dans la page.\u003C/p>\n\u003Cp>Résultat : le \u003Ccode>&#x3C;Title>\u003C/code> de la page tentait de rendre \u003Ccode>{{ null?.name }} — Outdoor Store\u003C/code>, ce qui produisait \u003Ccode>\" — Outdoor Store\"\u003C/code>. 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.\u003C/p>\n\u003Cp>Le HTML SSR final :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;!\u003C/span>\u003Cspan style=\"color:#85E89D\">DOCTYPE\u003C/span>\u003Cspan style=\"color:#B392F0\"> html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Outdoor Store — Votre boutique en ligne&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"Découvrez notre sélection outdoor.\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- ... -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#B392F0\"> id\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"__nuxt\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">    &#x3C;!-- contenu produit rendu correctement côté serveur -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">h1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>Veste Gore-Tex Alpine Pro&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">h1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">    &#x3C;!-- ... -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le \u003Ccode>&#x3C;h1>\u003C/code> était correct — il était dans le template, pas conditionné par le timing du \u003Ccode>&#x3C;Head>\u003C/code>. Mais le \u003Ccode>&#x3C;title>\u003C/code> dans le \u003Ccode>&#x3C;head>\u003C/code> restait celui du layout parent.\u003C/p>\n\u003Cp>Côté client, après hydratation, \u003Ccode>useAsyncData\u003C/code> résolvait la donnée, le composant \u003Ccode>&#x3C;Head>\u003C/code> 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é.\u003C/p>\n\u003Ch3>Pourquoi les tests n'ont rien détecté\u003C/h3>\n\u003Cp>L'équipe avait trois filets de sécurité. Tous les trois ont échoué.\u003C/p>\n\u003Cp>\u003Cstrong>1. Tests E2E Cypress.\u003C/strong> Les tests vérifiaient \u003Ccode>cy.title().should('include', product.name)\u003C/code>. Cypress exécute JavaScript. Il attendait l'hydratation. Le title était correct après hydratation. Test vert.\u003C/p>\n\u003Cp>\u003Cstrong>2. Lighthouse CI.\u003C/strong> Lighthouse utilise un Chromium headless qui exécute le JS. Même résultat : le title final était correct.\u003C/p>\n\u003Cp>\u003Cstrong>3. Review visuelle.\u003C/strong> Les développeurs ouvraient les pages dans Chrome. Le title dans l'onglet était correct. Personne n'a inspecté le HTML initial via \u003Ccode>curl\u003C/code> ou via « View Page Source ».\u003C/p>\n\u003Cp>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 \u003Ca href=\"/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client\">Next.js Pages Router vers App Router\u003C/a> et dans la \u003Ca href=\"/blog/migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence\">migration Vue 2 vers Vue 3\u003C/a> : la divergence entre ce que voit le développeur et ce que voit le crawler.\u003C/p>\n\u003Ch3>Reproduction du bug\u003C/h3>\n\u003Cp>Pour quiconque veut vérifier sur son propre projet Nuxt 3 :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># 1. Construire le projet en mode production\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">npx\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> nuxi\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> build\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># 2. Lancer le serveur de production\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">node\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> .output/server/index.mjs\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># 3. Récupérer le HTML brut d'une page dynamique\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -s\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> http://localhost:3000/produits/veste-gore-tex-alpine\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#B392F0\"> grep\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -i\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '&#x3C;title>'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># 4. Comparer avec le title attendu\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># Si le résultat affiche le title du layout parent → le bug est confirmé\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Cette commande doit être dans chaque pipeline CI de migration. Pas en option.\u003C/p>\n\u003Ch2>Le fix : useHead() au bon endroit, au bon moment\u003C/h2>\n\u003Cp>L'équipe a exploré deux pistes de correction. La première, rapide mais fragile. La seconde, propre et définitive.\u003C/p>\n\u003Ch3>Piste écartée : \u003Ccode>definePageMeta\u003C/code> + \u003Ccode>useHead\u003C/code> dans un middleware\u003C/h3>\n\u003Cp>Un dev a proposé de passer les metas via \u003Ccode>definePageMeta\u003C/code> et un middleware global. Cette approche fonctionnait mais créait une indirection inutile et rendait le code difficile à maintenir pour 200 pages.\u003C/p>\n\u003Ch3>Fix retenu : \u003Ccode>useHead()\u003C/code> dans le \u003Ccode>&#x3C;script setup>\u003C/code> avec accès garanti aux données\u003C/h3>\n\u003Cp>La solution propre consiste à utiliser le composable \u003Ccode>useHead()\u003C/code> au lieu du composant \u003Ccode>&#x3C;Head>\u003C/code> dans le template, et à s'assurer que les données sont résolues \u003Cstrong>avant\u003C/strong> l'appel :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">&#x3C;!-- pages/produits/[slug].vue — Nuxt 3 (version corrigée) -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">script\u003C/span>\u003Cspan style=\"color:#B392F0\"> setup\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> route\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> useRoute\u003C/span>\u003Cspan style=\"color:#E1E4E8\">()\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#FFAB70\">data\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">product\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> useAsyncData\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  `product-${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">route\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">params\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">slug\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#B392F0\"> $fetch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`/api/products/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">route\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">params\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">slug\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// useHead() est appelé APRÈS la résolution de useAsyncData\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// grâce au await dans &#x3C;script setup> (Nuxt suspend le rendu)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">useHead\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> product.value\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    ?\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> `${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">product\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">value\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">name\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} — Outdoor Store`\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    :\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'Outdoor Store — Votre boutique en ligne'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  meta: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      name: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'description'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">      content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> product.value?.metaDescription\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">        ??\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'Découvrez notre sélection outdoor.'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      property: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'og:title'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">      content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> product.value?.name \u003C/span>\u003Cspan style=\"color:#F97583\">??\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'Outdoor Store'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  ]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">})\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">script\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">template\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">ProductDetail\u003C/span>\u003Cspan style=\"color:#B392F0\"> :product\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"product\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">div\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">template\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Points critiques du correctif :\u003C/p>\n\u003Col>\n\u003Cli>\n\u003Cp>\u003Cstrong>\u003Ccode>await useAsyncData\u003C/code> dans \u003Ccode>&#x3C;script setup>\u003C/code>\u003C/strong> — Le \u003Ccode>await\u003C/code> top-level dans un \u003Ccode>&#x3C;script setup>\u003C/code> de Nuxt 3 déclenche le mode Suspense. Le rendu SSR attend la résolution de la donnée avant de continuer. Le \u003Ccode>product.value\u003C/code> est donc disponible quand \u003Ccode>useHead()\u003C/code> s'exécute côté serveur.\u003C/p>\n\u003C/li>\n\u003Cli>\n\u003Cp>\u003Cstrong>\u003Ccode>useHead()\u003C/code> au lieu de \u003Ccode>&#x3C;Head>\u003C/code>\u003C/strong> — 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.\u003C/p>\n\u003C/li>\n\u003Cli>\n\u003Cp>\u003Cstrong>Fonctions réactives pour les valeurs\u003C/strong> — Les \u003Ccode>() =>\u003C/code> permettent à Unhead de recalculer les valeurs si la donnée change côté client (navigation SPA). Documentation Unhead : \u003Ca href=\"https://unhead.unjs.io/docs/head/guides/reactivity\">Reactivity\u003C/a>.\u003C/p>\n\u003C/li>\n\u003Cli>\n\u003Cp>\u003Cstrong>Suppression du \u003Ccode>&#x3C;Head>\u003C/code> dans \u003Ccode>app.vue\u003C/code>\u003C/strong> — Le title/description par défaut a été déplacé dans \u003Ccode>nuxt.config.ts\u003C/code> via la clé \u003Ccode>app.head\u003C/code>, qui sert de fallback propre sans interférer avec les pages :\u003C/p>\n\u003C/li>\n\u003C/ol>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// nuxt.config.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> default\u003C/span>\u003Cspan style=\"color:#B392F0\"> defineNuxtConfig\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  app: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    head: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      title: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Outdoor Store — Votre boutique en ligne'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      meta: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          name: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'description'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">          content: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'Découvrez notre sélection outdoor.'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      ]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">})\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Ch3>Déploiement et invalidation\u003C/h3>\n\u003Cp>Le fix a été déployé un mercredi à 10h. L'équipe a ensuite :\u003C/p>\n\u003Cul>\n\u003Cli>Purgé le cache Cloudflare sur l'ensemble du domaine.\u003C/li>\n\u003Cli>Soumis les 204 URLs impactées via l'API Indexing de Search Console (par batch de 50).\u003C/li>\n\u003Cli>Forcé un re-crawl via l'outil d'inspection d'URL sur les 15 pages les plus stratégiques.\u003C/li>\n\u003Cli>Vérifié chaque URL avec \u003Ccode>curl\u003C/code> avant de fermer le ticket.\u003C/li>\n\u003C/ul>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># Vérification post-déploiement sur les pages critiques\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">for\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> slug \u003C/span>\u003Cspan style=\"color:#F97583\">in\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> veste-gore-tex-alpine\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> sac-rando-50l\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> chaussures-trek-gtx\u003C/span>\u003Cspan style=\"color:#E1E4E8\">; \u003C/span>\u003Cspan style=\"color:#F97583\">do\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  echo\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"=== \u003C/span>\u003Cspan style=\"color:#E1E4E8\">$slug\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> ===\"\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -s\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> \"https://www.outdoor-store.example/produits/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">$slug\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"\u003C/span>\u003Cspan style=\"color:#79B8FF\"> \\\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    |\u003C/span>\u003Cspan style=\"color:#B392F0\"> grep\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -oP\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '(?&#x3C;=&#x3C;title>).*(?=&#x3C;/title>)'\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">done\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Ch3>Temps de récupération\u003C/h3>\n\u003Cp>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.\u003C/p>\n\u003Cp>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.\u003C/p>\n\u003Ch3>Filets de sécurité ajoutés post-incident\u003C/h3>\n\u003Cp>L'équipe a mis en place trois garde-fous :\u003C/p>\n\u003Cp>\u003Cstrong>1. Test SSR dans la CI.\u003C/strong> Un script Node qui démarre le serveur Nuxt en production, \u003Ccode>fetch\u003C/code> 20 URLs critiques, et vérifie que le \u003Ccode>&#x3C;title>\u003C/code> dans le HTML brut ne correspond pas au title par défaut.\u003C/p>\n\u003Cp>\u003Cstrong>2. Crawl Screaming Frog hebdomadaire.\u003C/strong> Automatisé via CLI, avec alerte Slack si plus de 2% des pages partagent le même title.\u003C/p>\n\u003Cp>\u003Cstrong>3. Monitoring continu.\u003C/strong> Vérification quotidienne que le HTML SSR et le rendu post-hydratation produisent les mêmes balises \u003Ccode>&#x3C;title>\u003C/code> et \u003Ccode>&#x3C;meta description>\u003C/code> sur un échantillon de pages.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>La méthode \u003Ccode>head()\u003C/code> de Nuxt 2 fonctionnait parce que \u003Ccode>vue-meta\u003C/code> était synchrone dans le cycle SSR. Nuxt 3 et Unhead offrent plus de flexibilité, mais cette flexibilité crée un angle mort : le composant \u003Ccode>&#x3C;Head>\u003C/code> 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.\u003C/p>\n\u003Cp>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 \u003Ccode>curl | grep '&#x3C;title>'\u003C/code> dans la CI aurait suffi à éviter 29K clics perdus.\u003C/p>\n\u003Cp>Le framework change. Le compilateur change. Googlebot, lui, lit toujours le HTML.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,12,[18,19,20,21],"nuxt 3","migration","head","seo regression","Nuxt 2 vers Nuxt 3 : 200 pages SEO cassées 6 semaines","Sun May 24 2026 18:01:42 GMT+0000 (Coordinated Universal Time)",[25,39,52],{"_id":26,"slug":27,"__v":6,"author":7,"canonical":28,"category":10,"createdAt":29,"date":12,"description":30,"image":15,"imageAlt":15,"readingTime":31,"tags":32,"title":37,"updatedAt":38},"6a129444aa6b273b0c453fac","migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence","https://seogard.io/blog/migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence","2026-05-24T06:01:40.987Z","Récit d'une migration Vue 2 vers Vue 3 où useHead mal porté a supprimé les meta titles de 47 pages produit. Diagnostic, code du bug, et fix complet.",11,[33,19,34,35,36],"vue 3","usehead","composition api","seo","Migration Vue 3 : 47 pages produit sans meta titles pendant 21 jours","Sun May 24 2026 06:01:40 GMT+0000 (Coordinated Universal Time)",{"_id":40,"slug":41,"__v":6,"author":7,"canonical":42,"category":10,"createdAt":43,"date":12,"description":44,"image":15,"imageAlt":15,"readingTime":16,"tags":45,"title":50,"updatedAt":51},"6a1312d1aa6b273b0cadb52b","migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client","https://seogard.io/blog/migration-next-js-pages-router-vers-app-router-les-metadata-ignorees-sur-les-pages-client","2026-05-24T15:01:37.060Z","Migration Next.js Pages Router vers App Router : les metadata disparaissent sur les composants 'use client'. Récit d'incident, diagnostic et fix complet.",[46,47,48,49,19],"next.js","app router","metadata","use client","Next.js App Router : metadata ignorées sur les pages client","Sun May 24 2026 15:01:37 GMT+0000 (Coordinated Universal Time)",{"_id":53,"slug":54,"__v":6,"author":7,"canonical":55,"category":10,"createdAt":56,"date":57,"description":58,"image":15,"imageAlt":15,"readingTime":59,"tags":60,"title":63,"updatedAt":64},"69d7e9c3aa6b273b0c95cc57","migration-http-vers-https-checklist-seo-complete","https://seogard.io/blog/migration-http-vers-https-checklist-seo-complete","2026-04-09T18:02:43.120Z","2026-04-09","Checklist technique pour migrer de HTTP à HTTPS sans perdre de trafic organique. Redirections, HSTS, Search Console, mixed content.",14,[61,19,62,36],"https","redirections","Migration HTTP vers HTTPS : checklist SEO complète","Thu Apr 09 2026 18:02:43 GMT+0000 (Coordinated Universal Time)"]