Lazy-load du hero Vue : H1 invisible pour Google

Lazy-load du hero : quand le H1 n'existe pas pour Googlebot

Mercredi 14h20. Un développeur front pousse une refonte du composant hero sur un site e-commerce Nuxt 3 — 320 pages catégories, 40 000 visites organiques par semaine. Le hero embarque le H1, une image plein écran, et un CTA. Pour améliorer le Largest Contentful Paint, il enveloppe la section entière dans un v-if="isMounted". Dans le navigateur, l'animation d'entrée est fluide. Dans le HTML brut servi au fetch HTTP, le H1 n'existe plus.

Jeudi, T+18h — L'alerte qui ne vient pas

Personne ne remarque rien. Le déploiement est passé en CI sans erreur. Les tests Cypress valident que le hero s'affiche. Lighthouse donne un score Performance en hausse de 8 points grâce au lazy-load de l'image hero. L'équipe se félicite.

Le lead SEO ouvre Search Console le lundi suivant pour un reporting hebdomadaire. Les impressions sont stables. Rien d'alarmant. Il ne regarde pas les données par page — le volume global masque la tendance.

C'est au jour 12 que le premier signal apparaît. Un chef de produit signale que la page catégorie "Mobilier de bureau" a perdu sa position 3 sur une requête à 2 400 recherches mensuelles. Le lead SEO vérifie. Position passée de 3 à 14. Il regarde les autres catégories. Neuf pages dans le top 10 ont glissé entre 5 et 15 positions.

Premier réflexe : vérifier les backlinks. Rien n'a bougé. Deuxième réflexe : regarder les mises à jour de l'algorithme. Aucune annoncée. Troisième réflexe : inspecter l'URL dans Search Console avec l'outil "Inspection d'URL". Le HTML rendu affiché par Google montre le hero. Mais le lead SEO sait que cet outil utilise un rendu JavaScript complet — pas le fetch HTTP initial.

Il ouvre un terminal.

curl -s -A "Googlebot" https://www.example.com/c/mobilier-de-bureau | grep -i "<h1"

Aucun résultat. Le H1 n'est pas dans la réponse HTTP.

Il relance avec un user-agent Chrome classique. Même résultat. Le H1 n'est tout simplement pas dans le HTML initial. Il est injecté côté client, après hydration.

L'équipe réalise à ce moment que 320 pages catégories sont servies sans H1 depuis 12 jours. Le trafic organique sur ces pages a déjà baissé de 18 %. Les requêtes brandées tiennent, mais les requêtes informationnelles et transactionnelles longue traîne décrochent.

Le CTO est prévenu. Un channel Slack incident est créé. Priorité : comprendre pourquoi le SSR ne rend pas le hero.

Le bug : v-if client-only tue le H1 côté serveur

Le composant hero, avant la refonte, ressemblait à ceci dans le template Nuxt 3 :

<template>
  <section class="hero">
    <h1>{{ category.title }}</h1>
    <NuxtImg :src="category.heroImage" alt="" />
    <NuxtLink :to="category.ctaLink" class="cta">{{ category.ctaText }}</NuxtLink>
  </section>
</template>

Le H1 était rendu côté serveur sans condition. Google recevait un document HTML complet avec le titre principal en place.

Le développeur, soucieux de performance, a modifié le composant pour lazy-loader l'image et animer l'entrée du hero. La nouvelle version :

<template>
  <section v-if="isMounted" class="hero">
    <h1>{{ category.title }}</h1>
    <NuxtImg
      :src="category.heroImage"
      loading="lazy"
      alt=""
    />
    <NuxtLink :to="category.ctaLink" class="cta">{{ category.ctaText }}</NuxtLink>
  </section>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const isMounted = ref(false)

onMounted(() => {
  isMounted.value = true
})
</script>

Le piège est limpide une fois identifié. onMounted ne s'exécute jamais côté serveur. Dans le cycle de vie Vue 3, onMounted est un hook client-only. Côté SSR, isMounted reste à false. La directive v-if="isMounted" évalue à false. La section entière — y compris le H1 — est exclue du HTML généré par le serveur.

Le HTML servi par Nuxt en SSR pour cette page :

<!DOCTYPE html>
<html lang="fr">
<head>
  <title>Mobilier de bureau — Example Store</title>
  <meta name="description" content="Découvrez notre sélection..." />
</head>
<body>
  <div id="__nuxt">
    <header><!-- nav --></header>
    <main>
      <!-- section hero : absente -->
      <div class="product-grid">
        <!-- produits -->
      </div>
    </main>
    <footer><!-- footer --></footer>
  </div>
</body>
</html>

Pas de <h1>. Pas de section hero. Juste un commentaire vide dans le DOM virtuel sérialisé.

Ce que voit le développeur vs ce que voit Googlebot

Dans Chrome, le développeur ouvre la page. Le JavaScript s'exécute. onMounted tire. isMounted passe à true. Le hero apparaît en 200ms. Le H1 est visible dans l'inspecteur. Tout semble normal.

Googlebot, dans sa première passe de crawl, récupère le HTML brut. Pas de H1. Google peut exécuter le JavaScript dans une seconde passe (le Web Rendering Service), mais cette seconde passe intervient avec un délai variable — parfois quelques secondes, parfois plusieurs jours. Et même quand le WRS exécute le JS, le budget de rendering est limité. Les pages à faible PageRank passent en dernier.

Le résultat concret : sur 320 pages catégories, Google a indexé pendant 26 jours des pages sans H1. Pour un moteur qui utilise le heading principal comme signal fort de pertinence thématique, c'est une amputation directe du ranking.

Pourquoi les tests n'ont rien détecté

L'équipe utilisait Cypress pour les tests e2e. Cypress exécute le JavaScript. Le hero apparaissait. Le test passait.

Le pipeline CI incluait un Lighthouse audit. Lighthouse rend la page avec Chromium. Le hero était rendu. Score OK.

Personne n'avait de test qui vérifiait le HTML SSR brut. Pas de curl dans la CI. Pas de check HTML statique. Pas de diff entre le rendu serveur et le rendu client.

L'outil "Inspection d'URL" de Search Console, souvent utilisé comme filet de sécurité, rend lui aussi le JavaScript complet. Il montrait le H1. C'est un faux ami dans ce scénario exact.

L'équipe n'avait pas non plus de crawl automatisé en mode fetch-only. Un passage avec Screaming Frog en mode "HTML brut" (désactiver le rendering JavaScript dans Configuration > Spider > Rendering) aurait immédiatement révélé le H1 manquant sur toutes les pages catégories.

Ce type de divergence entre le HTML SSR et le DOM hydraté est un classique des régressions silencieuses en Vue et Nuxt. Un scénario proche avait frappé la même stack lors d'une migration Vue 2 vers Vue 3, mais cette fois le composant useHead n'était pas en cause. C'est plus insidieux : le contenu sémantique structurant vit dans un template conditionné par un état client-only.

L'illusion du v-show

Un développeur de l'équipe a suggéré de remplacer v-if par v-show. Sur le papier, v-show rend l'élément dans le DOM et applique un display: none en CSS. Le H1 serait donc dans le HTML SSR.

Mais un H1 en display: none pose un autre problème. Google a explicitement documenté que le contenu masqué en CSS peut être dépriorisé. Un H1 rendu mais caché en CSS n'est pas une solution propre. C'est exactement le scénario documenté dans le cas du H1 en display none sur desktop.

Le fix : séparer le H1 du lazy-load

La solution correcte est de ne jamais conditionner le rendu du H1 à un état client-only. Le contenu sémantique critique — H1, méta-données, contenu textuel principal — doit être dans le HTML SSR initial, inconditionnellement.

Le fix déployé par l'équipe sépare le H1 de la section animée :

<template>
  <section class="hero">
    <h1>{{ category.title }}</h1>
    <div v-if="isMounted" class="hero__media">
      <NuxtImg
        :src="category.heroImage"
        loading="lazy"
        alt=""
      />
      <NuxtLink :to="category.ctaLink" class="cta">
        {{ category.ctaText }}
      </NuxtLink>
    </div>
    <div v-else class="hero__media hero__media--placeholder" aria-hidden="true">
      <!-- placeholder SSR : même dimensions, pas de contenu lourd -->
    </div>
  </section>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const isMounted = ref(false)

onMounted(() => {
  isMounted.value = true
})
</script>

Le H1 est rendu côté serveur, toujours. La partie media/CTA du hero reste lazy-loadée côté client pour la performance. Le placeholder SSR évite le layout shift.

Le HTML SSR après le fix :

<section class="hero">
  <h1>Mobilier de bureau</h1>
  <div class="hero__media hero__media--placeholder" aria-hidden="true">
    <!-- placeholder -->
  </div>
</section>

Le H1 est là. Google le voit dès le premier fetch.

Vérification et redéploiement

Après le merge, l'équipe ajoute un test dans la CI pour prévenir toute régression future :

# test-ssr-h1.sh — exécuté dans le pipeline CI après build
URL="http://localhost:3000/c/mobilier-de-bureau"
H1_COUNT=$(curl -s "$URL" | grep -c "<h1")

if [ "$H1_COUNT" -lt 1 ]; then
  echo "ERREUR : aucun H1 trouvé dans le HTML SSR de $URL"
  exit 1
fi

echo "OK : $H1_COUNT H1 trouvé(s) dans le HTML SSR"

Simple. Brutal. Efficace. Ce script tourne sur 5 URL représentatives (une catégorie, une page produit, la homepage, une page CMS, une page recherche). Temps d'exécution : 3 secondes.

L'équipe lance également un crawl Screaming Frog complet en mode HTML brut (rendering JavaScript désactivé) sur les 320 pages catégories. Résultat : 320/320 ont maintenant un H1 dans le HTML SSR.

Le déploiement est poussé un mardi à 10h. Les caches CDN (Cloudflare) sont purgés immédiatement via l'API :

curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache" \
  -H "Authorization: Bearer CF_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

Le lead SEO soumet les 30 URL les plus stratégiques via l'API Indexing de Search Console pour accélérer le re-crawl.

Récupération

Les premiers signes de récupération apparaissent au jour 5 après le fix. Les pages à fort PageRank (celles qui reçoivent des backlinks directs) remontent en premier. Au jour 12, 80 % des positions perdues sont récupérées. Au jour 19, le trafic organique sur les pages catégories revient à son niveau pré-incident.

Bilan de l'incident : 26 jours d'exposition, 19 jours de récupération. Impact estimé : −12 000 visites organiques, soit environ −35 % de trafic sur les pages catégories pendant la période.

Le pattern identifié ici — un composant de design system qui contrôle le rendu d'un heading sémantique — n'est pas isolé. Une variante a été documentée avec un composant Heading qui rend un div selon une prop mal configurée. Et le même type de divergence SSR/client a provoqué des incidents similaires lors de migrations Nuxt 2 vers Nuxt 3, où des layouts entiers disparaissaient du rendu serveur.

Un header refondu qui remplace un H1 par un div est un cousin germain de ce bug. Le point commun : le développeur ne pense pas "HTML sémantique pour le crawler" quand il manipule des composants visuels.

Ce qu'on en retient

Le H1 n'est pas un élément visuel. C'est un signal sémantique. Le conditionner à un état client-only, c'est le supprimer pour tout consommateur qui ne rend pas le JavaScript — et Googlebot en première passe en fait partie.

Trois règles émergent de cet incident. Un : le H1, les headings structurants et le contenu textuel principal ne doivent jamais vivre dans un v-if lié à onMounted, useRequestEvent, ou tout état client-only. Deux : un test SSR en curl dans la CI coûte 3 secondes et attrape ce type de régression avant la production. Trois : l'outil "Inspection d'URL" de Search Console n'est pas un substitut à un crawl en HTML brut.

Un monitoring continu qui compare le HTML SSR au DOM hydraté — comme ce que propose Seogard — détecte cette divergence en quelques minutes après le déploiement, pas 12 jours plus tard dans un reporting hebdomadaire.

Articles connexes

Rendering5 avril 2026

SSR vs CSR : impact réel sur le SEO technique

Comparaison technique SSR et CSR avec exemples de crawl, code et scénarios concrets. Ce que Googlebot voit vraiment selon votre mode de rendering.

Rendering5 avril 2026

Google voit une page blanche sur votre SPA : diagnostic et solutions

Diagnostic technique complet des problèmes de rendering JavaScript sur les SPA. Solutions SSR, prerendering et monitoring pour Googlebot.

Rendering5 avril 2026

Hydration mismatch : le bug invisible qui tue votre SEO

Détectez et corrigez les erreurs d'hydratation SSR qui dégradent silencieusement votre indexation. Méthodes, outils et code pour debug avancé.

Rendering5 avril 2026

ISR, SSR, SSG : quel rendering choisir pour le SEO

Guide technique pour choisir entre ISR, SSR et SSG selon votre type de site. Comparatif, code Next.js/Nuxt, et scénarios réels e-commerce, média, SaaS.

Rendering5 avril 2026

Prerendering SEO : quand et comment l'implémenter

Guide technique du prerendering pour le SEO : cas d'usage concrets, implémentation avec Next.js, Nuxt, Astro, et pièges à éviter sur les SPA.

Rendering5 avril 2026

Dynamic rendering : solution temporaire ou piège SEO

Avantages, limites et alternatives au dynamic rendering. Pourquoi cette solution recommandée par Google devient un risque technique à mesure que votre site scale.