Nuxt useSeoMeta : quand le composant enfant efface silencieusement les meta du layout parent
Jeudi 16h20. L'équipe SEO d'une marketplace B2B française (12 000 références, 2 400 pages catégories) pousse en production un nouveau layout Nuxt 3 avec des meta par défaut centralisés via useSeoMeta. Le navigateur affiche les bonnes balises. Lighthouse score 100 en SEO. Le merge request est approuvé. Personne ne regarde le rendu SSR brut des pages enfants. Dix-neuf jours plus tard, Search Console signale un effondrement des impressions sur 340 URLs catégories. Les og:description, og:image et description ont disparu — remplacés par rien.
Lundi, T+19 jours — "C'est quoi ce trou dans les impressions ?"
Le lead SEO ouvre Search Console le lundi à 9h15. Le rapport Performances affiche une chute de 34 % des impressions sur les pages catégories. Pas les fiches produit. Pas le blog. Uniquement les catégories.
Premier réflexe : vérifier si Google a signalé un problème d'indexation. Onglet "Pages" → aucune alerte. Tout est marqué "Indexée". L'équipe suppose d'abord un changement algorithmique. Le dev frontend vérifie la branche main — aucun déploiement depuis quatre jours. Le CTO pense à un problème CDN.
À 10h30, le lead SEO lance un crawl Screaming Frog sur les 2 400 URLs catégories. Résultat : 340 pages retournent une balise <meta name="description"> vide. Pas absente — vide. content="". Idem pour og:description et og:image.
Le dev frontend ouvre une de ces pages dans Chrome, inspecte le DOM. La meta description est bien là, remplie. Il rafraîchit. Toujours là. "Chez moi ça marche."
À 11h15, quelqu'un a le réflexe de faire un curl brut :
curl -s https://marketplace.example.com/categorie/outillage-electrique | grep -i 'og:description'
Résultat :
<meta property="og:description" content="">
Le contenu est vide dans le HTML SSR. Côté navigateur, l'hydratation côté client corrige la meta après le mount du composant. Googlebot, qui utilise le HTML initial pour extraire les meta, ne voit rien.
À 11h45, l'équipe ouvre le diff Git du déploiement du 19 jours plus tôt. Le layout default.vue a été refactoré. Et un composant enfant — CategoryHead.vue — a été ajouté dans certaines pages catégories. Le lead SEO commence à comprendre. Le problème n'est pas côté serveur, ni côté CDN. C'est un problème de composition de useSeoMeta entre layout parent et page enfant.
L'impact mesuré a posteriori : −18 000 clics sur 19 jours, 340 pages touchées, temps moyen de récupération estimé à 10-14 jours après fix.
Le bug : useSeoMeta dans le child écrase le layout, même avec des valeurs vides
Pour comprendre ce qui s'est passé, il faut d'abord voir comment Nuxt 3 gère la composition des meta.
Le mécanisme attendu
useSeoMeta est un composable fourni par unhead, la librairie de gestion du <head> utilisée par Nuxt 3. Quand plusieurs composants appellent useSeoMeta, les entrées sont fusionnées selon un principe de dernier appelé gagne (last-in wins) pour chaque clé.
Le layout parent a été configuré ainsi :
<!-- layouts/default.vue -->
<script setup lang="ts">
useSeoMeta({
title: 'Marketplace B2B — Outillage Pro',
description: 'La marketplace de référence pour les pros du bâtiment. 12 000 références.',
ogTitle: 'Marketplace B2B — Outillage Pro',
ogDescription: 'La marketplace de référence pour les pros du bâtiment. 12 000 références.',
ogImage: 'https://marketplace.example.com/og-default.jpg',
robots: 'index, follow',
})
</script>
<template>
<div>
<AppHeader />
<slot />
<AppFooter />
</div>
</template>
L'idée est saine : le layout pose des valeurs par défaut. Chaque page enfant peut les surcharger si besoin. Et c'est exactement ce que fait la page catégorie :
<!-- pages/categorie/[slug].vue -->
<script setup lang="ts">
const { data: category } = await useFetch(`/api/categories/${route.params.slug}`)
useSeoMeta({
title: category.value?.seoTitle,
description: category.value?.seoDescription,
ogTitle: category.value?.seoTitle,
ogDescription: category.value?.seoDescription,
ogImage: category.value?.ogImage,
})
</script>
Le piège
Le problème est là, en clair : category.value?.seoDescription retourne undefined pour 340 catégories dont le champ seoDescription n'a jamais été renseigné dans le CMS (Strapi).
Or, dans useSeoMeta, passer undefined pour une clé ne signifie pas "ne touche pas à cette clé, laisse le parent la gérer". Cela signifie : "définis cette clé à vide". Le composable enfant écrase l'entrée du parent — même si la valeur est undefined.
Le rendu SSR final pour ces 340 pages ressemble à :
<head>
<title></title>
<meta name="description" content="">
<meta property="og:title" content="">
<meta property="og:description" content="">
<meta property="og:image" content="">
<meta name="robots" content="index, follow">
</head>
La clé robots est préservée parce que la page enfant ne la redéfinit pas. Toutes les autres sont écrasées par des chaînes vides.
Pourquoi personne n'a rien vu
Trois raisons convergentes.
1. L'hydratation côté client masque le bug. Après le rendu SSR, le composant page se monte côté client. À ce moment, useFetch résout potentiellement avec un cache Nuxt payload, et si la catégorie existe, category.value est peuplé. Le composable useSeoMeta est réévalué côté client avec les bonnes données (quand elles existent). Le DOM est patché. L'inspecteur Chrome montre les meta correctes. Mais Googlebot utilise le HTML initial dans la majorité des cas pour extraire les meta, pas le DOM post-hydratation.
2. Les tests E2E vérifient le DOM, pas le HTML SSR. L'équipe utilise Playwright. Les assertions page.locator('meta[name="description"]').getAttribute('content') passent parce que Playwright exécute le JavaScript. Le HTML brut n'est jamais vérifié.
3. Le QA humain se fait sur des catégories remplies. Les 5 catégories de test dans l'environnement de staging ont toutes leur champ seoDescription rempli. Les 340 catégories "vides" sont des sous-catégories secondaires, rarement visitées par l'équipe.
Reproduction pas à pas
Pour reproduire le comportement, voici un setup minimal :
// composables/test-override.ts
import { useSeoMeta } from '#imports'
// Simule le layout
useSeoMeta({
description: 'Valeur par défaut du layout',
ogDescription: 'Valeur par défaut du layout',
})
// Simule la page enfant avec une valeur undefined
const apiValue: string | undefined = undefined
useSeoMeta({
description: apiValue,
ogDescription: apiValue,
})
Le résultat dans le HTML SSR : <meta name="description" content="">. Le fallback du layout est perdu.
Pour vérifier le comportement côté SSR sans navigateur, la commande curl reste le test le plus fiable :
# Vérifier le HTML SSR brut d'une page
curl -s -A "Googlebot" https://marketplace.example.com/categorie/vis-inox \
| npx htmlq 'meta[name="description"]' --attribute content
Résultat attendu si le bug est présent : une chaîne vide. On peut aussi utiliser l'outil d'inspection d'URL de Search Console, qui affiche le HTML tel que Googlebot le perçoit. Pour ces 340 pages, la description affichée dans l'outil était bien vide.
Ce type de divergence entre rendu SSR et rendu client est un classique des frameworks hydratés. Un incident similaire a touché des sites Next.js où les metadata async qui throw servaient un fallback vide. L'angle diffère, mais le mécanisme est le même : ce que le développeur voit dans le navigateur n'est pas ce que le bot indexe.
Le fix : filtrer les undefined avant d'appeler useSeoMeta
Le patch immédiat
La correction tient en quelques lignes. L'objectif : ne jamais passer de clé undefined à useSeoMeta dans les pages enfants. Si une valeur n'existe pas, la clé ne doit pas être incluse dans l'appel — pour que le layout parent conserve son default.
<!-- pages/categorie/[slug].vue — APRÈS CORRECTION -->
<script setup lang="ts">
const { data: category } = await useFetch(`/api/categories/${route.params.slug}`)
// Utilitaire : supprime les clés undefined/null/vides
function definedOnly<T extends Record<string, unknown>>(obj: T): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v != null && v !== '')
) as Partial<T>
}
useSeoMeta(definedOnly({
title: category.value?.seoTitle,
description: category.value?.seoDescription,
ogTitle: category.value?.seoTitle,
ogDescription: category.value?.seoDescription,
ogImage: category.value?.ogImage,
}))
</script>
Avec definedOnly, si category.value?.seoDescription est undefined, la clé description n'est pas passée à useSeoMeta. Le layout parent reste maître de cette clé.
Le composable partagé
Pour éviter que chaque page répète cette logique, l'équipe a extrait un composable :
// composables/useSafeSeoMeta.ts
import type { UseSeoMetaInput } from '@unhead/schema'
export function useSafeSeoMeta(input: UseSeoMetaInput) {
const cleaned = Object.fromEntries(
Object.entries(input).filter(([, v]) => v != null && v !== '')
) as UseSeoMetaInput
if (Object.keys(cleaned).length > 0) {
useSeoMeta(cleaned)
}
}
Chaque page remplace désormais useSeoMeta(...) par useSafeSeoMeta(...). Le layout continue d'utiliser useSeoMeta directement — ses valeurs sont toujours définies en dur.
Déploiement et invalidation
Le fix a été déployé un lundi à 14h. L'équipe a ensuite :
- Purgé le cache CDN (Cloudflare) sur le pattern
/categorie/*pour que le nouveau HTML SSR soit servi immédiatement. - Soumis les 340 URLs impactées via l'API d'indexation Google (quota : 200/jour, complété en deux jours).
- Vérifié chaque page avec
curlet le rapport d'inspection d'URL de Search Console.
# Vérification post-déploiement sur un échantillon
for slug in vis-inox boulonnerie-acier disques-meulage; do
echo "=== $slug ==="
curl -s "https://marketplace.example.com/categorie/$slug" \
| grep -oP '<meta name="description" content="\K[^"]*'
done
Le HTML SSR retournait maintenant les valeurs par défaut du layout pour les catégories sans meta custom, et les valeurs CMS pour celles qui en avaient.
Récupération du trafic
Le ranking a commencé à remonter au bout de 5 jours. Les impressions ont retrouvé leur niveau d'avant incident en 12 jours. Le trafic organique sur les 340 pages catégories a mis 16 jours pour revenir à la normale — un délai cohérent avec la fréquence de recrawl observée pour ce type de pages (crawl budget moyen de 800 pages/jour sur ce site).
Ajouts au pipeline CI
L'équipe a ajouté deux garde-fous pour que l'incident ne se reproduise pas.
Test SSR automatisé dans la CI :
// tests/seo-meta-ssr.spec.ts (Vitest + $fetch Nuxt)
import { describe, it, expect } from 'vitest'
import { $fetch } from '@nuxt/test-utils'
describe('SSR meta defaults', () => {
it('category without CMS meta still has layout defaults', async () => {
const html = await $fetch('/categorie/vis-inox') // catégorie sans meta CMS
expect(html).toContain('<meta name="description" content="La marketplace')
expect(html).toContain('<meta property="og:description" content="La marketplace')
expect(html).not.toContain('content=""')
})
})
Lint rule custom : un script de pre-commit scanne les fichiers pages/**/*.vue et alerte si useSeoMeta est appelé directement (au lieu de useSafeSeoMeta).
Ce pattern — un composant enfant qui écrase involontairement les meta du parent — n'est pas spécifique aux meta. On retrouve la même logique dans les incidents où un composant heading rend un div selon une prop mal configurée, ou quand une refonte du header remplace un H1 par un div. Le point commun : un changement invisible côté navigateur, catastrophique côté crawl.
Ce qu'on en retient
useSeoMeta est un excellent composable. Mais son modèle de composition — dernier appel gagne, clé par clé — transforme un undefined innocent en bombe silencieuse. Le layout parent croit poser des defaults solides. Le composant enfant les pulvérise sans le vouloir.
Trois règles à graver :
- Ne jamais passer de clé
undefinedàuseSeoMetadans un composant enfant. Filtrer avant l'appel, ou utiliser un wrapper. - Toujours tester le HTML SSR brut, pas le DOM post-hydratation.
curlne ment pas. Le navigateur, si. - Monitorer les meta en continu. Un crawl hebdomadaire avec Screaming Frog ne suffit pas quand 340 pages peuvent basculer sur un seul déploiement. Un monitoring continu type Seogard détecte ce type de divergence SSR/client en quelques minutes — pas dix-neuf jours.
Le bug le plus dangereux en SEO technique reste celui que le navigateur corrige tout seul.