Un catalogue e-commerce de 12 000 fiches produit, construit en Vue.js SPA avec Vue Router en mode history. Trois mois après la mise en production, Google n'a indexé que 1 400 pages. Le reste est coincé dans la file de rendu de Googlebot — ou pire, indexé avec un <title> vide et un body qui contient uniquement <div id="app"></div>. Ce scénario n'est pas hypothétique : c'est le cas le plus fréquent qu'on rencontre sur les projets Vue sans server-side rendering.
Le problème fondamental : Vue.js SPA et le rendering pipeline de Google
Googlebot utilise un pipeline en deux phases distinctes. La première phase — le crawl — récupère le HTML brut retourné par le serveur. La seconde phase — le rendering — exécute le JavaScript dans une instance headless de Chrome (WRS, Web Rendering Service) pour obtenir le DOM final. Ces deux phases sont découplées et la file de rendering a une capacité limitée.
La documentation officielle de Google sur le JavaScript SEO le confirme : le rendu JavaScript est différé et soumis à des contraintes de ressources. En pratique, cela signifie que vos pages Vue.js SPA peuvent attendre des heures, des jours, voire des semaines avant d'être rendues.
Ce que Googlebot voit réellement sur une SPA Vue
Quand votre serveur retourne une application Vue.js classique générée par create-vue ou Vue CLI, voici le HTML que Googlebot reçoit en phase 1 :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mon E-commerce</title>
<!-- Pas de meta description dynamique -->
<!-- Pas de balise canonical dynamique -->
<!-- Pas de données structurées -->
</head>
<body>
<div id="app"></div>
<script type="module" src="/assets/index-Dk4f8rBn.js"></script>
</body>
</html>
Aucune balise <title> spécifique à la page. Aucune meta description. Aucun contenu textuel. Aucun lien interne exploitable. Le maillage interne de votre site est invisible tant que le JavaScript n'a pas été exécuté — et c'est un problème critique pour la découverte de pages. Pour comprendre l'étendue des limites de Googlebot face au JavaScript, consultez notre article détaillé sur ce que Google peut et ne peut pas crawler en JavaScript.
Les trois risques concrets
1. Indexation partielle. Sur un site de 12 000 pages, si le WRS n'arrive à traiter que 200 pages par jour (chiffre réaliste pour un site sans autorité massive), il faut 60 jours pour un premier pass complet — en supposant aucune erreur, aucun timeout, aucune modification entre-temps.
2. Métadonnées absentes ou génériques. Même si la page finit par être rendue, les bibliothèques comme @unhead/vue ou vue-meta injectent les balises <title> et <meta> côté client. Si le rendering échoue partiellement (timeout à 5 secondes, erreur réseau sur une API tierce), Google indexe la page avec le title par défaut du index.html. Résultat : des dizaines de pages indexées avec le même title "Mon E-commerce" dans la Search Console.
3. Crawl budget gaspillé. Googlebot doit faire deux requêtes (crawl + render) au lieu d'une pour chaque page. Sur un site de plusieurs milliers de pages, cela réduit mécaniquement le nombre de pages que Google peut traiter dans une fenêtre de crawl donnée.
Nuxt.js : le SSR comme réponse architecturale
Nuxt.js n'est pas un simple framework au-dessus de Vue — c'est une couche d'exécution serveur qui transforme fondamentalement la façon dont vos pages sont servies aux moteurs de recherche.
Le fonctionnement du rendu universel Nuxt
Avec Nuxt en mode SSR (le mode par défaut depuis Nuxt 3), chaque requête HTTP déclenche l'exécution de votre composant Vue sur le serveur via Nitro (le moteur serveur de Nuxt 3). Le serveur retourne un HTML complet, avec le contenu textuel, les métadonnées, les liens internes et les données structurées déjà présents dans le markup. Le navigateur reçoit ensuite le bundle JavaScript qui "hydrate" le HTML statique pour rendre la page interactive.
Du point de vue de Googlebot, la différence est radicale : la phase 1 (crawl) suffit pour obtenir tout le contenu indexable. La phase 2 (rendering) n'est plus qu'un bonus pour vérifier d'éventuels comportements dynamiques.
Modes de rendu Nuxt et leurs cas d'usage SEO
Nuxt 3 propose une flexibilité via les routeRules qui permet de mixer les stratégies de rendu page par page :
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// Pages produit : SSR classique (contenu qui change souvent, stock, prix)
'/produit/**': { ssr: true },
// Pages catégorie : ISR avec revalidation toutes les 60 minutes
'/categorie/**': { isr: 3600 },
// Pages institutionnelles : pré-rendues au build (contenu quasi-statique)
'/a-propos': { prerender: true },
'/mentions-legales': { prerender: true },
// Espace client : pas besoin de SEO, SPA pure
'/mon-compte/**': { ssr: false },
}
})
Ce niveau de granularité est un avantage décisif. Un e-commerce de 15 000 pages n'a pas besoin du même mode de rendu pour ses fiches produit (SSR, car les prix changent), ses pages catégorie (ISR avec revalidation horaire) et son tunnel de checkout (SPA, aucun intérêt SEO).
L'ISR (Incremental Static Regeneration) est particulièrement intéressant pour les pages à fort trafic organique mais à mise à jour modérée : Nuxt sert une version statique en cache et la régénère en arrière-plan selon l'intervalle défini. Vous obtenez les performances d'un site statique avec la fraîcheur du SSR.
Gestion des métadonnées SEO avec Nuxt 3
Un des points les plus sous-estimés dans les projets Vue.js est la gestion des métadonnées. Avec une SPA, les balises <title>, <meta name="description">, les canonicals et les données structurées doivent être injectées côté client — ce qui dépend entièrement de la bonne exécution du JavaScript.
Nuxt 3 intègre nativement @unhead/vue, qui gère les balises head côté serveur et côté client de manière unifiée.
Composable useHead et useSeoMeta
Voici un pattern robuste pour une fiche produit :
<script setup lang="ts">
// pages/produit/[slug].vue
const route = useRoute()
const { data: product } = await useFetch(`/api/products/${route.params.slug}`)
if (!product.value) {
throw createError({ statusCode: 404, statusMessage: 'Produit introuvable' })
}
// Métadonnées SEO — rendues côté serveur
useSeoMeta({
title: `${product.value.name} | MonShop`,
description: product.value.metaDescription
|| `${product.value.name} - ${product.value.shortDescription}. Livraison 48h.`,
ogTitle: product.value.name,
ogDescription: product.value.shortDescription,
ogImage: product.value.images[0]?.url,
ogType: 'product',
})
// Canonical explicite
useHead({
link: [
{ rel: 'canonical', href: `https://monshop.fr/produit/${product.value.slug}` }
]
})
// Données structurées Product
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.value.name,
description: product.value.shortDescription,
image: product.value.images.map(img => img.url),
sku: product.value.sku,
brand: {
'@type': 'Brand',
name: product.value.brand,
},
offers: {
'@type': 'Offer',
url: `https://monshop.fr/produit/${product.value.slug}`,
priceCurrency: 'EUR',
price: product.value.price,
availability: product.value.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
}
})
}
]
})
</script>
Chaque élément — title, description, canonical, Product schema — est présent dans le HTML initial servi par le serveur. Googlebot n'a pas besoin d'exécuter une seule ligne de JavaScript pour les obtenir.
Le piège des métadonnées dupliquées
Un cas fréquent sur les projets Nuxt mal configurés : la meta description définie dans nuxt.config.ts via app.head se retrouve en doublon avec celle injectée par useSeoMeta dans le composant page. Le résultat : deux balises <meta name="description"> dans le HTML. Google prendra celle qu'il veut, pas nécessairement la bonne.
La solution : ne définissez des métadonnées par défaut dans nuxt.config.ts que pour les éléments globaux (charset, viewport, favicon). Tout ce qui est spécifique à une page doit être géré exclusivement dans le composant page via useSeoMeta. Si vous avez un layout qui injecte un title par défaut, utilisez titleTemplate :
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
titleTemplate: '%s | MonShop',
htmlAttrs: { lang: 'fr' },
}
}
})
Le %s est remplacé par le title défini dans chaque page. Si une page ne définit pas de title, le template produit " | MonShop" — ce qui est un signal clair dans la Search Console que quelque chose manque.
Scénario réel : migration d'une marketplace Vue SPA vers Nuxt SSR
Prenons un cas concret. Une marketplace B2B dans le secteur du matériel industriel. Stack initiale : Vue 3 + Vue Router + Pinia, déployée comme SPA sur un CDN. 8 500 pages produit, 340 pages catégorie, 45 pages de contenu éditorial.
État initial (SPA)
Données extraites de la Search Console sur 90 jours avant migration :
- Pages indexées : 1 847 sur 8 885 (20,8 %)
- Pages avec erreurs "Page non indexée — Détectée, actuellement non indexée" : 4 230
- Pages avec "Explorée, actuellement non indexée" : 2 808
- Title dupliqué "MatPro — Matériel Industriel" : 1 203 pages
- Trafic organique : ~3 200 sessions/mois
Un crawl avec Screaming Frog en mode "JavaScript rendering" (Chromium embarqué) montrait un site correct. Mais en mode "Text only" (ce qui simule la phase 1 de Googlebot), chaque page retournait le même HTML vide — confirmant le diagnostic.
La migration
L'équipe a migré vers Nuxt 3 en 6 semaines. Points clés de la migration :
Mapping des routes. Vue Router utilisait des routes flat (/product/:id), Nuxt utilise le file-based routing. Les anciens chemins /product/12345 ont été redirigés vers /produit/pompe-hydraulique-ph200 via des redirections 301 gérées dans le server/middleware Nuxt :
// server/middleware/legacy-redirects.ts
import redirectMap from '~/data/redirect-map.json'
export default defineEventHandler((event) => {
const path = getRequestURL(event).pathname
// Redirections des anciennes URLs /product/:id vers /produit/:slug
if (redirectMap[path]) {
return sendRedirect(event, redirectMap[path], 301)
}
})
Le fichier redirect-map.json contenait les 8 500 correspondances anciennes URL → nouvelle URL, généré par un script qui interrogeait la base de données produit. Point critique : vérifier qu'aucune chaîne de redirections ne se forme entre les anciennes routes, les variantes avec et sans trailing slash, et les nouvelles URLs.
Stratégie de rendu. Pages produit en SSR pur. Pages catégorie en ISR (revalidation toutes les 2 heures). Sitemap XML généré automatiquement via le module @nuxtjs/sitemap. Pages de l'espace client exclues du SSR et bloquées via meta robots noindex.
Monitoring post-migration. Un outil de monitoring comme Seogard permettait de détecter en temps réel les pages qui retournaient un title vide ou une meta description manquante après le déploiement — un problème fréquent quand une API produit timeout et que le composable useFetch retourne null.
Résultats à 90 jours post-migration
- Pages indexées : 7 920 sur 8 885 (89,2 %, contre 20,8 % avant)
- Pages "Détectée, actuellement non indexée" : 312 (contre 4 230)
- Titles dupliqués : 0
- Trafic organique : ~11 400 sessions/mois (+256 %)
- LCP médian : 1,8s (contre 3,4s en SPA — le FCP est immédiat car le HTML est prêt)
La majorité du gain de trafic venait des pages produit longue traîne qui n'avaient jamais été indexées en SPA.
Les pièges techniques du SSR Nuxt à anticiper
Migrer vers Nuxt ne résout pas magiquement tous les problèmes. Plusieurs pièges techniques attendent les équipes qui ne maîtrisent pas le rendering universel.
Erreurs d'hydratation
L'erreur la plus fréquente en Nuxt SSR : un mismatch entre le HTML généré sur le serveur et le DOM produit par le client après hydratation. Vue 3 affiche un warning en console : Hydration node mismatch.
Causes typiques :
- Utiliser
Date.now()ouMath.random()directement dans le template (valeurs différentes côté serveur et client) - Accéder à
window,documentoulocalStoragesans vérifier l'environnement - Composants qui dépendent de l'état du navigateur (viewport width, user agent)
La solution canonique :
<script setup>
// ❌ Mauvais : exécuté côté serveur ET client, résultats différents
const timestamp = Date.now()
// ✅ Correct : exécuté uniquement côté client après hydratation
const timestamp = ref(0)
onMounted(() => {
timestamp.value = Date.now()
})
</script>
<template>
<!-- Utiliser <ClientOnly> pour les composants purement client -->
<ClientOnly>
<MapboxWidget :lat="product.lat" :lng="product.lng" />
<template #fallback>
<div class="map-placeholder" style="height: 400px; background: #f0f0f0;">
Chargement de la carte...
</div>
</template>
</ClientOnly>
</template>
Le wrapper <ClientOnly> est essentiel pour les composants qui ne peuvent pas tourner côté serveur (cartes, lecteurs vidéo, widgets tiers). Le slot #fallback fournit un placeholder dans le HTML initial — utile pour le CLS puisqu'il réserve l'espace dans le layout.
Appels API et gestion des erreurs serveur
En SPA, si un appel API échoue, l'utilisateur voit un spinner ou un message d'erreur. Pas grave pour Google — la page n'existe pas de toute façon dans le HTML initial.
En SSR, un appel API qui échoue pendant le rendu serveur peut provoquer une erreur 500 sur la page entière. Si votre API produit est lente ou instable, votre site SEO devient directement impacté.
Pattern défensif recommandé :
// composables/useProductSafe.ts
export const useProductSafe = async (slug: string) => {
const { data, error } = await useFetch(`/api/products/${slug}`, {
timeout: 3000, // Timeout agressif côté serveur
})
if (error.value) {
// Log l'erreur pour monitoring
if (import.meta.server) {
console.error(`[SSR] Product API failed for ${slug}:`, error.value.message)
}
// Retourner un 404 propre plutôt qu'un 500
throw createError({
statusCode: 404,
statusMessage: `Produit ${slug} introuvable`,
})
}
return data
}
Un timeout de 3 secondes côté serveur est un bon compromis. Au-delà, le TTFB de votre page se dégrade et Googlebot peut abandonner la requête. Vérifiez dans la Search Console (rapport Statistiques d'exploration) que le temps de réponse moyen de vos pages SSR reste sous 800ms.
Performance serveur et mise en cache
Le SSR a un coût : chaque requête exécute du code Vue sur le serveur. Sur un site à fort trafic, la charge CPU peut devenir un bottleneck. Les Core Web Vitals se dégradent si le TTFB explose sous la charge.
Nuxt 3 via Nitro supporte nativement le cache de route :
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/categorie/**': {
isr: 3600,
cache: {
maxAge: 3600,
staleMaxAge: 86400, // Servir le stale pendant 24h si la régénération échoue
}
},
'/produit/**': {
cache: {
maxAge: 300, // Cache 5 min pour les fiches produit
varies: ['x-forwarded-proto'], // Varier selon le protocole
}
}
}
})
Pour les déploiements sur infrastructure propre (pas Vercel/Netlify), placez un reverse proxy Nginx ou Varnish devant Nitro. La config Nginx de base pour cacher les réponses SSR :
proxy_cache_path /var/cache/nginx/nuxt levels=1:2 keys_zone=nuxt_cache:10m max_size=1g inactive=60m;
server {
listen 80;
server_name monshop.fr;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_cache nuxt_cache;
proxy_cache_valid 200 5m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
add_header X-Cache-Status $upstream_cache_status;
}
# Assets statiques : cache long, pas de proxy vers Node
location /_nuxt/ {
alias /var/www/monshop/.output/public/_nuxt/;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Le header X-Cache-Status vous permet de vérifier dans les DevTools ou via curl si la réponse vient du cache (HIT) ou du serveur Nuxt (MISS). Surveillez le ratio — un taux de HIT inférieur à 80% sur les pages catégorie indique un problème de configuration cache.
Vérification et debugging SEO d'un site Nuxt
Déployer du SSR ne suffit pas. Vous devez vérifier que le rendu serveur produit effectivement le HTML attendu par les moteurs.
Tester le HTML brut servi par le serveur
La méthode la plus directe — et la plus fiable :
# Récupérer le HTML brut sans exécuter le JS
curl -s -A "Googlebot" https://monshop.fr/produit/pompe-hydraulique-ph200 | head -100
# Vérifier la présence du title
curl -s https://monshop.fr/produit/pompe-hydraulique-ph200 | grep -oP '<title>.*?</title>'
# Vérifier la canonical
curl -s https://monshop.fr/produit/pompe-hydraulique-ph200 | grep 'rel="canonical"'
# Vérifier les données structurées
curl -s https://monshop.fr/produit/pompe-hydraulique-ph200 | grep -oP '<script type="application/ld\+json">.*?</script>'
Si le curl retourne <div id="__nuxt"></div> sans contenu, votre SSR est cassé. Cause fréquente : une variable d'environnement manquante sur le serveur de production qui fait échouer silencieusement le rendu.
Screaming Frog en mode comparatif
Lancez deux crawls Screaming Frog du même périmètre :
- En mode "Text only" (Configuration > Spider > Rendering > Text Only)
- En mode "JavaScript" (Configuration > Spider > Rendering > JavaScript)
Exportez les deux jeux de données et comparez les colonnes Title 1, Meta Description 1 et Word Count. Sur un site Nuxt correctement configuré en SSR, les valeurs doivent être identiques entre les deux modes. Toute divergence signale une page où le SSR n'inclut pas le contenu qui est ajouté côté client — un bug à corriger.
Search Console : l'outil de vérité
L'outil d'inspection d'URL de la Search Console montre deux vues : "HTML tel que vu par Googlebot" et la capture d'écran du rendu. Utilisez les deux systématiquement. Vérifiez en particulier que les pages qui vous importent sont effectivement indexées et que leurs métadonnées correspondent à ce que vous attendez.
Pour les sites avec des milliers de pages, l'inspection manuelle ne passe pas à l'échelle. L'URL Inspection API permet d'automatiser ces vérifications en batch. Combinée à un outil de monitoring continu comme Seogard, vous pouvez détecter en quelques minutes une régression SSR qui aurait autrement pris des semaines à identifier via la chute de trafic dans Analytics.
Et les alternatives à Nuxt ?
Nuxt n'est pas la seule option pour ajouter du SSR à un projet Vue. Mais les alternatives présentent des trade-offs significatifs.
Vite-SSR / vite-plugin-ssr (Vike). Flexibilité maximale, mais vous devez gérer vous-même le routing serveur, la gestion du head, le cache, le sitemap. Pertinent si vous avez une équipe infra solide et des besoins très spécifiques. Pour 95 % des projets, Nuxt fait le travail avec moins de code custom.
Prerendering statique (SSG pur). Nuxt le supporte via nuxt generate. Parfait pour des sites de contenu < 1 000 pages avec des mises à jour peu fréquentes. Au-delà, les temps de build deviennent problématiques (un build complet de 15 000 pages peut prendre 45 minutes), et la fraîcheur du contenu en souffre. L'ISR est généralement un meilleur choix pour les catalogues volumineux.
Prerendering à la volée (Rendertron, Prerender.io). Ces solutions interceptent les requêtes des bots et servent une version pré-rendue. Elles fonctionnent, mais ajoutent une couche d'infrastructure, un point de défaillance supplémentaire, et Google a explicitement indiqué que le cloaking — servir un contenu différent aux bots et aux utilisateurs — est contraire à leurs guidelines. Le SSR universel (même HTML pour bots et utilisateurs) est l'approche recommandée.
Les équipes qui viennent de React connaissent le même dilemme avec Next.js. Le pattern est identique : le framework UI seul (React ou Vue) ne suffit pas pour le SEO ; il faut un meta-framework avec SSR intégré.
Les points de vigilance post-déploiement
Même avec Nuxt en SSR, certaines régressions sont insidieuses. Une mise à jour de dépendance qui casse le rendu d'un composant. Un changement d'API qui retourne une structure de données différente, provoquant un crash silencieux du useSeoMeta. Un déploiement qui oublie une variable d'environnement.
Surveillez ces métriques en continu :
- Taux de pages avec status 200 dans les logs serveur filtrés sur Googlebot (identifiable via le user agent)
- Temps de réponse serveur (TTFB) des pages SSR — une dégradation soudaine indique un problème d'API ou de cache
- Couverture de l'index dans la Search Console — une chute du nombre de pages "Valides" est un signal d'alarme immédiat
- Cohérence des métadonnées — vérifier périodiquement que les titles et descriptions servis en HTML correspondent à ce qui est attendu
Mettre en place un lazy loading correct des images et une optimisation des images reste indispensable même avec le SSR — le rendu serveur résout le problème d'indexation, pas celui de la performance perçue.
Vue.js seul est un framework d'interface, pas une solution SEO. Sans SSR, vous construisez un site invisible pour Google sur la majorité de ses pages. Nuxt transforme votre application Vue en un site indexable nativement, avec un contrôle granulaire sur les modes de rendu page par page. La migration demande un investissement technique réel — gestion des redirections, adaptation des composants au rendu universel, monitoring de la stabilité SSR — mais le retour est mesurable en semaines dans la Search Console. La clé est de ne pas considérer le déploiement comme la ligne d'arrivée : un monitoring continu des métadonnées et du rendu serveur est ce qui sépare un site Nuxt bien référencé d'un site Nuxt qui régresse silencieusement.