Un site Vue.js standard livre un <div id="app"></div> au navigateur. Googlebot reçoit la même chose. La différence : le navigateur exécute JavaScript et affiche le contenu en quelques centaines de millisecondes. Googlebot place la page dans une file de rendu (Web Rendering Service) et y reviendra… quand il aura des ressources disponibles. Parfois en quelques secondes, parfois en plusieurs jours, parfois jamais pour les pages à faible PageRank interne.
Ce que Googlebot voit réellement sur un site Vue.js SPA
Le Web Rendering Service (WRS) de Google utilise une version headless de Chromium à jour. En théorie, il exécute JavaScript. En pratique, le processus est en deux phases : crawl (récupération du HTML) puis render (exécution JS). Entre ces deux phases, un délai variable s'installe — et c'est là que le SEO se dégrade.
Prenez un catalogue e-commerce de 12 000 fiches produit construit avec Vue CLI et Vue Router en mode history. Voici ce que Googlebot récupère au crawl initial :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>MonCatalogue</title>
<link rel="stylesheet" href="/css/app.3f2a1b.css">
</head>
<body>
<div id="app"></div>
<script src="/js/chunk-vendors.8e3b4c.js"></script>
<script src="/js/app.1d4f7a.js"></script>
</body>
</html>
Pas de title dynamique par produit. Pas de meta description. Pas de contenu textuel. Pas de liens internes exploitables. Le HTML initial est identique pour les 12 000 URLs.
Le problème du crawl budget
Google doit dépenser deux requêtes par page : une pour le HTML, une pour le rendu. Sur un site de 12 000 pages, le coût de crawl est doublé. Si votre robots.txt n'est pas finement configuré, une partie significative du budget se perd sur des ressources JS, CSS et API endpoints.
La file d'attente du WRS
Martin Splitt (Google Developer Advocate, équipe Search Relations) a expliqué dans plusieurs présentations que le WRS fonctionne comme une file : les pages sont rendues quand des ressources sont disponibles. Les pages à faible importance perçue (produits profonds, pages de pagination) passent après les pages jugées prioritaires. Sur un catalogue de 12 000 pages, les fiches produit longue traîne — celles qui génèrent souvent le plus de chiffre d'affaires cumulé — sont précisément celles qui risquent d'attendre le plus longtemps.
Vous pouvez vérifier ce comportement dans la Search Console via l'outil d'inspection d'URL : comparez le "HTML brut" (ce que Googlebot crawle) et la "capture d'écran" (ce que le WRS rend). Sur un site Vue SPA, le HTML brut sera systématiquement vide. L'URL Inspection API permet d'automatiser cette vérification à grande échelle.
Les limites techniques de Vue.js côté SEO
Le problème ne se limite pas au rendu. Plusieurs patterns courants dans l'écosystème Vue.js créent des obstacles spécifiques au SEO.
Gestion du <head> et des meta tags
Vue.js n'a pas de mécanisme natif pour manipuler le <head> côté serveur. Les solutions côté client comme vue-meta (Vue 2) ou @unhead/vue (Vue 3) modifient le DOM après le montage du composant. Résultat : au moment du crawl initial, les balises <title> et <meta> sont celles du HTML statique — souvent un title générique identique sur toutes les pages.
C'est un problème majeur. Un title tag incorrect ou dupliqué sur 12 000 pages signifie 12 000 pages avec le même title dans l'index de Google, avant que le WRS ne passe (s'il passe).
Le routing côté client et les liens internes
Vue Router en mode SPA intercepte les clics et empêche la navigation réelle. Les liens <router-link> génèrent bien des balises <a href="..."> dans le DOM rendu, mais elles n'existent pas dans le HTML initial. Googlebot peut les découvrir au rendu, mais uniquement si le rendu a lieu. Sans rendu, zéro lien interne exploitable — et donc zéro découverte de nouvelles pages via le maillage interne.
Les appels API asynchrones
Un pattern classique Vue.js : charger les données produit dans un hook onMounted() via un appel à une API REST ou GraphQL. Le WRS exécute JavaScript, mais avec des limites. Les appels réseau doivent se résoudre rapidement. Un endpoint API lent (> 5 secondes) ou protégé par authentification signifie un rendu partiel ou vide.
// Composant Vue 3 — ProductPage.vue
// Ce pattern est problématique pour le SEO
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const product = ref(null)
const loading = ref(true)
onMounted(async () => {
try {
const response = await fetch(`https://api.moncatalogue.fr/products/${route.params.slug}`)
// Si cet appel prend > 5s, le WRS peut abandonner le rendu
product.value = await response.json()
} catch (error) {
// Googlebot verra une page vide si l'API est inaccessible
console.error('Erreur chargement produit:', error)
} finally {
loading.value = false
}
})
Si l'API renvoie un 403 ou un timeout quand l'IP de Googlebot la sollicite (ce qui arrive plus souvent qu'on ne le croit avec des WAF ou des rate limiters agressifs), la page reste vide dans l'index. Pour approfondir ces mécanismes, l'article sur ce que Google peut et ne peut pas crawler en JavaScript détaille les limites du WRS.
Nuxt.js : ce qu'il résout et comment
Nuxt est un framework basé sur Vue qui ajoute une couche serveur. Son apport principal pour le SEO : le HTML envoyé au client (et à Googlebot) contient déjà le contenu rendu, les meta tags, et les liens internes. Le rendu JavaScript côté client n'est plus une condition préalable à l'indexation.
Modes de rendu disponibles
Nuxt 3 propose plusieurs stratégies de rendu configurables par route :
- SSR (Server-Side Rendering) : le serveur exécute les composants Vue et envoie du HTML complet. Le client hydrate ensuite le HTML pour retrouver l'interactivité.
- SSG (Static Site Generation) : le HTML est généré au build. Pertinent pour les contenus qui changent rarement (pages institutionnelles, articles de blog).
- ISR (Incremental Static Regeneration) : hybride entre SSR et SSG. La page est servie depuis un cache et regénérée en arrière-plan après une durée configurable.
- SPA : mode client-only, comme un Vue.js classique. Utile pour les espaces membres ou dashboards qui n'ont pas besoin d'indexation.
La puissance de Nuxt 3 réside dans les route rules, qui permettent d'appliquer une stratégie différente par groupe de routes :
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// Pages produit : SSR avec cache ISR de 3600 secondes
'/produits/**': { isr: 3600 },
// Pages catégorie : SSR pur (contenu dynamique, stock, prix)
'/categories/**': { ssr: true },
// Blog : génération statique au build
'/blog/**': { prerender: true },
// Espace client : pas de SSR, pas d'indexation
'/mon-compte/**': {
ssr: false,
headers: { 'X-Robots-Tag': 'noindex' }
},
// Page d'accueil : ISR courte durée (5 min)
'/': { isr: 300 }
}
})
Cette granularité est exactement ce qui manque à un Vue.js SPA. Vous pouvez optimiser le SEO des pages qui le nécessitent tout en gardant un rendu client-only pour les sections privées. Pas de compromis architectural global.
Gestion native du <head>
Nuxt 3 intègre Unhead nativement. Chaque page peut déclarer ses meta tags via le composable useHead() ou le composable useSeoMeta(), et ces meta tags sont rendus côté serveur dans le HTML initial :
// pages/produits/[slug].vue
<script setup lang="ts">
const route = useRoute()
const { data: product } = await useFetch(`/api/products/${route.params.slug}`)
// Ces meta tags seront présents dans le HTML initial envoyé à Googlebot
useSeoMeta({
title: () => `${product.value?.name} — MonCatalogue`,
description: () => product.value?.metaDescription,
ogTitle: () => product.value?.name,
ogDescription: () => product.value?.metaDescription,
ogImage: () => product.value?.imageUrl,
ogType: 'product'
})
// Données structurées — rendues côté serveur
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.value?.name,
description: product.value?.metaDescription,
image: product.value?.imageUrl,
offers: {
'@type': 'Offer',
price: product.value?.price,
priceCurrency: 'EUR',
availability: product.value?.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock'
}
})
}
]
})
</script>
Le JSON-LD est intégré directement dans le HTML initial. Google n'a pas besoin d'exécuter JavaScript pour voir le Product Schema et les données structurées. C'est la différence entre une indexation fiable et un pari sur le WRS.
Scénario concret : migration d'un e-commerce Vue SPA vers Nuxt
Le contexte
Un site e-commerce spécialisé en pièces automobiles — 14 800 fiches produit, 320 pages catégorie, 45 pages de contenu éditorial. Stack initiale : Vue 3 + Vue Router + Pinia, hébergé sur un CDN statique (Netlify). Toutes les données proviennent d'une API headless (Strapi).
Les symptômes SEO avant migration
- Search Console : 2 340 pages indexées sur 15 165 soumises dans le sitemap. Soit 15,4% d'indexation.
- Screaming Frog (crawl en mode JavaScript rendering) : title tag identique sur 14 800 pages ("PiecesAuto — Votre spécialiste"). Le crawl sans JS rendering montrait zéro contenu sur toutes les pages.
- Outil d'inspection d'URL : HTML brut vide. Capture d'écran montrant le contenu rendu, mais avec un bandeau "Page partiellement chargée" sur ~30% des fiches produit testées.
- Trafic organique : 8 200 sessions/mois, quasi exclusivement sur les 45 pages éditoriales (qui bénéficiaient d'un prerendering partiel via un plugin Webpack).
La migration
Passage de Vue CLI à Nuxt 3 avec la configuration route rules suivante :
- Fiches produit : ISR avec revalidation toutes les 2 heures
- Pages catégorie : SSR pur (prix et stocks en temps réel)
- Contenu éditorial : SSG (prerender au build)
- Espace client : SPA (ssr: false)
Le déploiement s'est fait sur un serveur Node.js (PM2 + Nginx en reverse proxy). Point critique : les redirections 301 depuis les anciennes URLs. La structure avait légèrement changé (suppression du hash # résiduel sur certaines URLs historiques, normalisation du trailing slash).
Les résultats à 12 semaines
- Pages indexées : 2 340 → 13 890 (94% du catalogue)
- Trafic organique : 8 200 → 41 600 sessions/mois
- Time to First Byte moyen (fiches produit) : 1,2 secondes (ISR cache hit : 85ms)
- Taux de crawl quotidien (Search Console) : augmentation de 340% — Google crawlait plus de pages car chaque requête HTTP renvoyait du contenu immédiatement exploitable
Le changement le plus significatif : les fiches produit longue traîne ("kit distribution Peugeot 308 1.6 HDi 2014") ont commencé à se positionner. Ces pages existaient depuis des mois mais n'avaient jamais été rendues par le WRS.
Pièges fréquents lors d'une migration Vue → Nuxt
L'hydration mismatch
L'erreur la plus courante : un composant qui génère un HTML différent côté serveur et côté client. Nuxt affichera un warning en développement, mais en production, cela peut provoquer un re-rendu complet côté client — et dans le pire cas, un contenu différent entre ce que Google indexe (HTML serveur) et ce que l'utilisateur voit.
Cause fréquente : utiliser Date.now(), Math.random(), ou accéder à window/localStorage dans le setup d'un composant sans vérifier le contexte d'exécution.
// ❌ Provoque un hydration mismatch
const timestamp = ref(Date.now())
// ✅ Utiliser onMounted pour le code client-only
const timestamp = ref(0)
onMounted(() => {
timestamp.value = Date.now()
})
// ✅ Ou utiliser le composant <ClientOnly> de Nuxt
// <ClientOnly>
// <MonComposantBrowserOnly />
// </ClientOnly>
Les variables d'environnement exposées
En mode SSR, le serveur Nuxt a accès aux variables d'environnement. Si votre API nécessite une clé privée, assurez-vous d'utiliser runtimeConfig et non runtimeConfig.public — sinon la clé sera exposée dans le bundle client. C'est un problème de sécurité, pas de SEO, mais une fuite de credentials menant à un abus de l'API peut indirectement casser tout le rendu de vos pages.
Le double fetch de données
Un piège classique : les données chargées côté serveur via useFetch() ou useAsyncData() sont automatiquement sérialisées dans le payload Nuxt et transférées au client. Si vous utilisez un onMounted() qui refait le même appel API, vous doublez les requêtes et risquez un flash de contenu.
La règle : utilisez exclusivement useFetch() pour les données nécessaires au SEO. Ce composable gère automatiquement la déduplication serveur/client.
La gestion des erreurs 404 et 500
Sur une SPA Vue, un produit inexistant affiche souvent une page "Produit non trouvé" avec un status HTTP 200 (puisque c'est le même index.html qui est servi pour toutes les routes). Google indexe cette page comme du contenu valide.
Nuxt permet de retourner les bons codes HTTP depuis le serveur :
// pages/produits/[slug].vue
const { data: product, error } = await useFetch(`/api/products/${route.params.slug}`)
if (error.value?.statusCode === 404) {
throw createError({
statusCode: 404,
statusMessage: 'Produit non trouvé',
fatal: true
})
}
Ce comportement est crucial : un status 404 correct indique à Googlebot de ne pas indexer la page. Un status 200 avec un contenu "Produit non trouvé" crée du thin content dans l'index — et c'est exactement le genre de régression silencieuse qu'un outil de monitoring comme SEOGard détecte avant que l'impact ne se propage.
Optimisations avancées : au-delà du SSR de base
Sitemap dynamique avec nuxt-simple-sitemap
Un sitemap statique ne suffit pas sur un catalogue de 15 000 produits qui évolue quotidiennement. Le module nuxt-simple-sitemap génère un sitemap dynamique basé sur les routes Nuxt et des sources de données personnalisées :
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-simple-sitemap'],
sitemap: {
sources: [
'/api/__sitemap__/products'
],
defaults: {
changefreq: 'daily',
priority: 0.7
}
},
site: {
url: 'https://www.moncatalogue.fr'
}
})
// server/api/__sitemap__/products.ts
import type { SitemapUrlInput } from '#sitemap/types'
export default defineSitemapEventHandler(async () => {
const products = await $fetch('https://api.moncatalogue.fr/products?fields=slug,updatedAt')
return products.map((p): SitemapUrlInput => ({
loc: `/produits/${p.slug}`,
lastmod: p.updatedAt,
changefreq: 'daily',
priority: 0.8
}))
})
Le sitemap est régénéré à chaque requête (ou mis en cache avec les route rules). Chaque produit ajouté ou supprimé est immédiatement reflété. Combiné avec l'API Indexing pour les pages critiques, le délai d'indexation passe de jours à heures.
Headers HTTP et cache serveur
La configuration Nginx en reverse proxy devant le serveur Nuxt mérite attention. Le TTFB est un facteur direct de crawl efficiency — plus Googlebot obtient des réponses rapides, plus il crawle de pages :
upstream nuxt_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name www.moncatalogue.fr;
# Cache des assets statiques (JS, CSS, images)
location /_nuxt/ {
proxy_pass http://nuxt_backend;
proxy_cache nuxt_static;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Cache-Status $upstream_cache_status;
}
# Pages rendues SSR — cache court pour ISR
location / {
proxy_pass http://nuxt_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Stale-while-revalidate pour ne jamais servir de timeout à Googlebot
proxy_cache nuxt_pages;
proxy_cache_valid 200 10m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
add_header X-Cache-Status $upstream_cache_status;
}
}
La directive proxy_cache_use_stale est critique : si le serveur Nuxt met trop de temps à rendre une page (pic de trafic, surcharge), Nginx sert la version en cache plutôt qu'un timeout. Googlebot reçoit toujours du contenu, jamais une erreur 5xx.
Monitoring des régressions SSR
Le passage en SSR ne garantit rien si une mise à jour de code réintroduit un rendu client-only sur des pages critiques. Un développeur qui ajoute un if (process.client) au mauvais endroit peut faire disparaître le contenu du HTML serveur sans que personne ne le remarque avant la prochaine chute de trafic.
Points à surveiller en continu :
- Présence des meta tags dans le HTML initial (pas uniquement dans le DOM rendu)
- Status HTTP corrects (pas de 200 soft-404)
- Temps de réponse serveur par catégorie de route
- Cohérence du contenu entre HTML serveur et DOM rendu
L'impact entre un SSR fonctionnel et un SSR cassé est détaillé dans l'analyse SSR vs CSR et son impact réel sur le SEO. Les mêmes problématiques s'appliquent d'ailleurs à React — les pièges SEO de React sont étonnamment similaires à ceux de Vue, et les solutions architecturales (Next.js pour React, Nuxt pour Vue) suivent la même logique.
La différence entre comprendre le problème et le résoudre
Vue.js est un excellent framework frontend. Mais "frontend" signifie littéralement "côté client" — et le SEO se joue côté serveur, dans les premières millisecondes de la réponse HTTP. Nuxt comble cet écart en rendant le HTML sur le serveur avant de le livrer au navigateur et aux crawlers.
La migration d'une SPA Vue vers Nuxt SSR n'est pas un luxe technique. C'est la condition nécessaire pour que vos pages existent dans l'index de Google de manière fiable et prévisible. La configuration route rules de Nuxt 3 permet de cibler précisément les pages qui nécessitent un rendu serveur sans imposer de overhead sur les routes privées. Le piège, c'est l'après-migration : un monitoring continu du HTML servi — via SEOGard ou un setup custom d'alertes — reste indispensable pour détecter les régressions SSR avant qu'elles n'atteignent vos positions.