Nuxt SEO : réussir le référencement d'un site Vue.js

Vue.js seul ne pose pas de problème de référencement : c'est le rendu côté client qui en pose un. Un site Vue.js classique livre au navigateur, et à Googlebot, une page quasi vide, avec le contenu réel injecté après coup par JavaScript. Nuxt existe précisément pour résoudre ça, mais l'utiliser sans comprendre ses modes de rendu, ses composables de métadonnées et sa façon de récupérer les données peut recréer le même problème sous une couche de configuration qui donne l'impression d'être réglée.
Cet article couvre quatre points propres à Nuxt : le choix du mode de rendu par route grâce aux route rules, la gestion des balises meta avec useHead et useSeoMeta, la construction d'un sitemap fiable y compris en multilingue avec le module i18n, et le piège le plus fréquent sur ce framework, celui qui consiste à récupérer des données côté client alors qu'elles doivent apparaître dans le HTML envoyé au robot.
Ce que le rendu côté client casse concrètement
Un rendu entièrement côté client signifie que le serveur renvoie un fichier HTML minimal, une simple balise div vide accompagnée d'un lot de scripts, et que Vue construit la page dans le navigateur une fois ces scripts chargés et exécutés. Googlebot sait exécuter du JavaScript, mais il le fait en deux passes : une exploration initiale du HTML brut, puis un rendu différé placé dans une file d'attente qui peut prendre plusieurs jours sur un site peu prioritaire. Pendant cet intervalle, la page existe pour Google sous une forme presque vide. D'autres robots, comme les outils de prévisualisation de liens sur les réseaux sociaux ou certains moteurs secondaires, n'exécutent souvent aucun JavaScript et ne verront jamais le contenu réel.
Concrètement, un rendu purement client provoque trois symptômes reconnaissables. La même balise title et la même meta description apparaissent dans le code source pour toutes les routes, parce qu'elles sont posées après coup par le JavaScript et non par le serveur. Les aperçus de partage affichent un titre générique ou aucune image, parce que les balises Open Graph n'existent pas encore au moment où l'outil de prévisualisation lit la page. Et le temps avant l'affichage du contenu principal, mesuré par le LCP, se dégrade, puisque le navigateur doit télécharger, analyser et exécuter le bundle JavaScript avant d'afficher quoi que ce soit d'utile.
- Titre et meta description identiques sur toutes les pages dans le code source brut
- Balises Open Graph absentes au moment où les robots de partage lisent la page
- LCP dégradé, puisque rien ne s'affiche avant l'exécution complète du JavaScript
Modes de rendu et route rules : choisir le bon rendu par page
Nuxt propose plusieurs modes de rendu, et le choix se fait idéalement page par page plutôt que pour le site entier. Le rendu universel, activé par défaut, exécute le composant sur le serveur à chaque requête et renvoie du HTML déjà complet, puis Vue reprend la main côté client pour rendre la page interactive : c'est l'hydratation. C'est le mode à privilégier pour tout ce qui doit être indexé. Le rendu client pur, obtenu avec ssr défini à false, revient au comportement d'une application à page unique classique et devrait être réservé aux zones qui n'ont aucune raison d'apparaître dans les résultats de recherche, comme un tableau de bord derrière authentification. La génération statique, via la commande de build dédiée, prérend chaque route détectée en fichiers HTML au moment du build, ce qui convient très bien à un blog ou un site marketing dont le contenu change par déploiement plutôt qu'en temps réel.
Depuis que Nuxt s'appuie sur son moteur de serveur interne pour le routage, les route rules définies dans la configuration permettent d'assigner un mode différent selon le motif d'URL : rendu statique prérendu pour des pages produit stables, rendu à la demande avec revalidation régulière pour une page qui change souvent, rendu client pur pour l'espace d'administration, en-têtes de cache personnalisés pour une section précise. C'est ce mécanisme, plus que le choix binaire entre rendu serveur complet et application monopage, qui fait la différence sur un site de taille réelle.
Le piège classique concerne les routes dynamiques : le prérendu statique ne génère que les URLs qu'il parvient à découvrir, en suivant les liens internes au moment du build ou via une liste explicite fournie dans la configuration du serveur. Une nouvelle fiche produit ajoutée après le build, et vers laquelle aucun lien interne ne pointe encore, ne sera tout simplement pas générée : elle basculera silencieusement sur un rendu de repli qui peut être moins complet que prévu. Sur un catalogue qui évolue souvent, mieux vaut fournir explicitement la liste des routes à prérendre, ou choisir un rendu universel à la demande plutôt qu'un prérendu statique complet.
- Prérendu statique pour un contenu stable qui doit être indexé, comme un article de blog
- Rendu client pur pour une zone jamais destinée à l'indexation, comme un espace connecté
- Revalidation régulière pour un contenu qui change souvent sans nécessiter une reconstruction complète
useHead et useSeoMeta : deux outils complémentaires, pas concurrents
useHead est le composable bas niveau : il permet de définir n'importe quel élément du head, qu'il s'agisse d'un title, d'une meta, d'un link ou d'un script, et accepte des valeurs réactives, ce qui est indispensable pour poser une balise canonique qui dépend de l'URL courante ou injecter un bloc de données structurées calculé à partir du contenu de la page. useSeoMeta est une couche dédiée et typée au-dessus de ça : elle expose directement des propriétés comme le titre, la description, l'image de partage ou le type de carte sociale, sans qu'il faille se souvenir qu'une balise Open Graph utilise l'attribut property et qu'une balise standard utilise name. C'est cette confusion entre les deux attributs, plus qu'un oubli de balise, qui casse le plus souvent les aperçus de partage sur un projet Vue construit à la main.
Dans la pratique, on utilise les deux ensemble : useSeoMeta pour tout ce qui concerne les balises sociales et les meta standard, useHead pour le lien canonique, les balises de langue si elles ne sont pas déjà gérées par un module dédié, et les scripts de données structurées. Un modèle de titre global, défini une seule fois au niveau de la configuration de l'application, permet à chaque page de ne fournir que sa partie spécifique via useSeoMeta, ce qui évite de dupliquer le nom du site partout et d'obtenir des titres tronqués dans les résultats de recherche.
L'erreur qui neutralise tout ça consiste à appeler useHead ou useSeoMeta uniquement à l'intérieur d'un hook déclenché après le montage du composant, en pensant que cela suffit puisque ça s'exécute au chargement de la page. Ce hook ne s'exécute jamais côté serveur : les balises posées à cet endroit n'existent pas dans le HTML envoyé à Googlebot, seulement dans le DOM une fois l'hydratation terminée côté client. Ces composables doivent être appelés directement dans le corps de la fonction de configuration du composant, ou dans le callback exécuté au moment où les données arrivent via la récupération asynchrone, ce qui garantit qu'ils tournent aussi pendant le rendu serveur.
- useSeoMeta pour le titre, la description et les balises sociales avec une syntaxe typée
- useHead pour le lien canonique et les scripts de données structurées
- Jamais l'un ou l'autre dans un hook post-montage : ils doivent tourner côté serveur
Construire un sitemap fiable, y compris avec des routes dynamiques
Le module de sitemap détecte automatiquement les routes statiques présentes dans l'arborescence des pages, mais il ne peut pas deviner l'existence d'une route dynamique comme une fiche produit identifiée par un paramètre, sans qu'on lui fournisse la liste réelle des identifiants existants. Cette liste doit se construire en interrogeant la même source de données que celle utilisée pour afficher les pages elles-mêmes, base de données, API ou système de gestion de contenu, au moment de la génération du sitemap, et non en la recopiant à la main, sous peine de désynchronisation dès qu'une fiche est ajoutée ou retirée.
La date de dernière modification doit refléter la modification réelle du contenu, et non la date du déploiement du site : Google s'en sert pour décider s'il vaut la peine de re-crawler une URL, et une date qui change à chaque déploiement alors que le contenu n'a pas bougé finit par être ignorée comme signal peu fiable. Sur un site multilingue ou comportant un grand nombre de pages, un index de sitemaps, un fichier qui référence plusieurs sous-sitemaps, évite de dépasser les limites de taille et permet de séparer le contenu par type.
Une contradiction fréquente consiste à laisser une page marquée comme non indexable dans son useSeoMeta tout en la gardant présente dans le sitemap. Google finit par constater l'incohérence et le signal de non-indexation l'emporte, mais chaque visite gaspillée sur cette URL est du budget de crawl qui n'a pas servi à explorer une page qui, elle, doit réellement être indexée. La règle simple est qu'une page exclue de l'index doit être exclue du sitemap au même moment, dans le même changement de code, jamais dans un correctif séparé qu'on oublie de faire.
- Exclure du sitemap les pages non indexables, les pages d'administration et les URLs à paramètres dupliqués
- Référencer l'URL du sitemap dans le fichier robots.txt
- Vérifier périodiquement qu'aucune URL du sitemap ne redirige ou ne renvoie une erreur
SEO multilingue : i18n, hreflang et pièges de redirection
Le module d'internationalisation de Nuxt propose plusieurs stratégies de routage, et ce choix a un effet direct sur l'URL que Google va traiter comme faisant autorité. Une stratégie préfixe toutes les langues, y compris la langue par défaut. Une autre laisse la langue par défaut sans préfixe et préfixe seulement les autres. Une approche par domaine ou sous-domaine sépare chaque langue sur une racine différente. Aucune de ces stratégies n'est universellement meilleure, mais il faut la choisir une fois et s'y tenir : en changer après coup revient à changer l'URL canonique de tout le site.
Chaque variante linguistique doit porter sa propre balise canonique, pointant vers elle-même et non vers la langue par défaut, accompagnée des balises de langue alternative réciproques qui listent toutes les variantes existantes, plus une entrée par défaut générique. Le module expose des utilitaires qui génèrent cela automatiquement, à condition que l'URL de base du site et la liste complète des locales soient correctement renseignées dans la configuration ; sans cela, les liens produits pointent vers des chemins relatifs qui ne veulent plus rien dire une fois sortis du contexte du site.
Le piège le plus coûteux touche la détection automatique de la langue du visiteur. Rediriger systématiquement un visiteur, ou un robot, vers la langue déduite de son navigateur ou de sa géolocalisation empêche Googlebot, qui explore le plus souvent avec un en-tête générique, de découvrir les autres versions linguistiques par lui-même s'il tombe toujours sur la même redirection depuis la même adresse. La détection devrait se limiter à une suggestion visible, une bannière que l'on peut ignorer, jamais à une redirection forcée sur la première visite d'une URL destinée à être explorée, et elle devrait idéalement être désactivée pour les user-agents connus comme des robots.
- Une stratégie de routage par langue choisie une fois, jamais changée ensuite
- Une balise canonique par variante linguistique, jamais une balise pointant vers la langue par défaut
- Aucune redirection automatique forcée basée sur la langue du navigateur pour les robots
Le piège serveur contre client dans la récupération des données
C'est le point qui casse le plus de sites Nuxt par ailleurs bien configurés. Les composables dédiés à la récupération de données s'exécutent côté serveur pendant le rendu, et leur résultat est intégré au HTML puis transmis avec la page pour éviter un second appel réseau au moment de l'hydratation. Un appel réseau classique posé dans un hook post-montage, en revanche, ne se déclenche qu'une fois le composant monté côté client, c'est-à-dire après l'hydratation, jamais pendant le rendu serveur. Le contenu ainsi récupéré n'existe simplement pas dans le HTML que Googlebot reçoit au premier passage, même si le reste de la page est parfaitement rendu côté serveur.
La conséquence directe : le corps d'un article, le prix d'un produit, sa disponibilité, ou toute donnée qui doit compter pour l'indexation, doit passer par les composables de récupération dédiés, dont le comportement par défaut est déjà côté serveur, sans rien à activer, seulement à ne pas désactiver par erreur ou par copie d'un exemple trouvé ailleurs. À l'inverse, un élément vraiment secondaire, une liste d'articles vus récemment ou un comparateur interactif sans valeur pour le référencement, peut rester exécuté uniquement côté client, à condition que ce soit un choix explicite et non un oubli.
Un deuxième symptôme du même problème est le décalage d'hydratation : si les données renvoyées côté serveur diffèrent de celles obtenues immédiatement après par le client, à cause d'une clé de cache mal choisie ou d'une donnée qui a changé entre les deux appels, Vue détecte l'incohérence, émet un avertissement, et peut re-rendre certains éléments. Google, qui capture surtout le tout premier rendu de la page, peut alors indexer une version du contenu que l'utilisateur humain ne voit jamais bien longtemps, ce qui devient particulièrement problématique quand cette version diffère sur le prix ou la disponibilité d'un produit.
- Contenu qui doit compter pour l'indexation : toujours via une récupération de données exécutée côté serveur
- Contenu réellement secondaire, sans valeur SEO : client uniquement, mais par choix explicite
- Clé de cache unique par paramètre, pour éviter qu'une page réutilise les données d'une autre
Vérifier que le rendu obtenu est réellement celui attendu
Aucune de ces configurations ne se vérifie correctement dans les outils de développement du navigateur, parce que l'inspecteur affiche le DOM après hydratation, exactement ce qu'il faut éviter de confondre avec ce que reçoit un robot au premier passage.
- Afficher le code source réel de la page, et non l'inspecteur, pour confirmer que le titre, la meta description et le contenu principal sont déjà présents
- Vérifier que la balise canonique de chaque page se référence bien elle-même, y compris sur les variantes linguistiques
- Confirmer que le sitemap est accessible, à jour, et référencé dans le fichier robots.txt
- Comparer le HTML effectivement récupéré par Google à ce qui est attendu, via l'outil d'inspection d'URL de la Search Console
- Chercher les titres dupliqués sur l'ensemble du site : c'est souvent le signe qu'un composable de meta n'est jamais appelé côté serveur pour certaines routes
Questions fréquentes
Faut-il désactiver le rendu serveur pour améliorer les performances ?
Non : désactiver le rendu serveur sur tout le site échange un problème de performance contre un problème d'indexation. La bonne approche est d'utiliser les route rules pour ne le désactiver que sur les zones qui n'ont aucune raison d'être indexées, comme un espace connecté, tout en gardant le rendu serveur pour les pages destinées au trafic organique.
useHead et useSeoMeta peuvent-ils être utilisés sur la même page ?
Oui, ils sont complémentaires plutôt que redondants. useSeoMeta couvre les balises meta et sociales standard avec une syntaxe typée, tandis que useHead reste nécessaire pour le lien canonique, les scripts de données structurées, ou toute balise que useSeoMeta ne couvre pas.
Le module i18n génère-t-il les balises de langue alternative automatiquement ?
Il peut les générer automatiquement, mais seulement si l'URL de base du site et la liste complète des locales sont correctement renseignées dans la configuration. Sans cela, les liens produits pointent vers des chemins incomplets, donc il faut toujours vérifier le résultat dans le code source plutôt que de faire confiance à la seule configuration.
Comment savoir si une route dynamique a bien été prérendue au moment du build ?
Le journal du build affiche la liste des routes découvertes et effectivement générées pendant l'étape de prérendu. Si une route attendue n'y figure pas, c'est qu'aucun lien interne ne pointait vers elle au moment du build, ou qu'elle n'a pas été ajoutée explicitement à la configuration de prérendu.
Une page en rendu client pur doit-elle figurer dans le sitemap ?
Seulement si elle est malgré tout destinée à être indexée, ce qui est rare pour ce type de page. Dans la grande majorité des cas, une page en rendu client pur sert une zone non indexable, et elle doit alors être absente à la fois du sitemap et marquée comme non indexable.