
Commencez par vérifier ce que reçoit un robot avec curl : si le HTML est vide, aucune balise meta ajoutée côté client ne servira. Le prérendu ou le rendu serveur est la vraie réponse. Ensuite seulement, gérez titres, descriptions et données structurées avec unhead, qui remplace vue-meta.
Une application Vue construit sa page dans le navigateur. Le HTML servi par le serveur est presque vide, et tout dépend ensuite de l’exécution du JavaScript. C’est jouable pour Google, beaucoup moins pour les autres robots, à commencer par ceux des assistants conversationnels. Voici comment traiter le problème dans l’ordre.
Le vrai problème n’est pas les balises meta
La première question à se poser n’est pas « comment changer mon titre » mais « qu’est-ce que le robot reçoit ». Vérifiez-le sans navigateur :
curl -s https://mon-site.com/ma-page | head -40Si la réponse est un <div id="app"></div> vide, aucune balise meta ajoutée côté client n’y changera quoi que ce soit pour un robot qui n’exécute pas JavaScript. Le rendu doit alors se faire avant l’envoi. C’est le sujet de la dernière section, et de loin le plus important.
Les exemples supposent un projet créé avec create-vue et Vite. Les sections qui suivent traitent d’abord des métadonnées, parce qu’elles sont utiles dans tous les cas, y compris avec un rendu serveur.
Les métadonnées avec unhead
Chaque page doit avoir son titre et sa description. unhead s’installe comme n’importe quel plugin Vue. Dans une application à page unique, ils doivent changer à chaque navigation.
vue-meta était la réponse du temps de Vue 2 et l’essentiel des articles en ligne s’y réfèrent encore. Sa version pour Vue 3 n’a jamais été stabilisée. La bibliothèque utilisée aujourd’hui, y compris par Nuxt, est unhead.
npm install @unhead/vueimport { createApp } from 'vue'
import { createHead } from '@unhead/vue/client'
import App from './App.vue'
const app = createApp(App)
app.use(createHead())
app.mount('#app')Dans un composant, useHead et useSeoMeta écrivent dans le <head> :
<script setup>
import { useHead, useSeoMeta } from '@unhead/vue'
useHead({
title: 'T-Shirt Gekkode',
titleTemplate: '%s — Boutique Gekkode',
link: [{ rel: 'canonical', href: 'https://mon-site.com/t-shirt' }]
})
useSeoMeta({
description: 'Un T-shirt en coton, laine et polyester.',
ogTitle: 'T-Shirt Gekkode',
ogDescription: 'Un T-shirt en coton, laine et polyester.',
ogImage: 'https://mon-site.com/images/t-shirt.png',
ogUrl: 'https://mon-site.com/t-shirt',
twitterCard: 'summary_large_image'
})
</script>Les composables viennent de @unhead/vue, mais createHead vient de @unhead/vue/client, ou de @unhead/vue/server en rendu serveur. Tout importer depuis le même chemin donne l’erreur createHead is not a function.
useSeoMeta évite d’écrire à la main les tableaux de balises : chaque clé correspond à une balise connue, et les fautes de frappe se voient à l’autocomplétion.
Les valeurs peuvent être réactives : passez une ref ou une propriété calculée, et la balise suit le chargement des données.
const produit = ref(null)
useHead({
title: computed(() => produit.value?.nom ?? 'Chargement…')
})Le lien canonique
Une même fiche accessible par plusieurs URL, avec ou sans paramètres de suivi, dilue le signal. Déclarez l’adresse de référence :
useHead({
link: [{ rel: 'canonical', href: `https://mon-site.com${route.path}` }]
})Construisez-la à partir du chemin de la route, jamais de window.location.href qui embarquerait les paramètres de campagne.
Le sitemap
Un fichier sitemap.xml liste les URL que vous jugez importantes. Sur une application à page unique, il ne peut pas être découvert par exploration : il doit être généré.
Générez-le à partir de la même source que vos routes, au moment du build, plutôt qu’à la main :
import { writeFileSync } from 'node:fs'
import { routes } from '../src/router/routes.js'
const base = 'https://mon-site.com'
const urls = routes
.filter((r) => !r.meta?.noindex && !r.path.includes(':'))
.map((r) => ` <url><loc>${base}${r.path}</loc></url>`)
.join('\n')
writeFileSync(
'public/sitemap.xml',
`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`
)Les routes à paramètre sont exclues : leurs URL réelles doivent venir de vos données, pas de la définition des routes. Déclarez ensuite le fichier dans robots.txt :
User-agent: *
Allow: /
Sitemap: https://mon-site.com/sitemap.xmlLes données structurées
Un bloc JSON-LD décrit explicitement ce que contient la page. C’est ce qui alimente les résultats enrichis, et c’est lu bien plus facilement qu’un texte à interpréter.
useHead({
script: [{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: 'T-Shirt Gekkode',
offers: {
'@type': 'Offer',
price: '25.00',
priceCurrency: 'EUR',
availability: 'https://schema.org/InStock'
}
})
}]
})Vérifiez le résultat avec le test des résultats enrichis de Google, sur l’URL en ligne et non sur le code source.
La performance
Les Core Web Vitals pèsent dans le classement. Trois leviers donnent l’essentiel du gain sur une application Vue.
Découper le bundle par route. Un import dynamique dans la définition des routes suffit : Vite produit un fichier séparé, chargé à la demande.
const routes = [
{ path: '/', component: () => import('../views/AccueilView.vue') },
{ path: '/produit/:id', component: () => import('../views/ProduitView.vue') }
]Dimensionner les images. Un width et un height explicites évitent le décalage de mise en page qui dégrade le CLS. Le format AVIF ou WebP réduit fortement le poids.
Mesurer avant d’optimiser. Lancez Lighthouse sur le site construit, pas sur le serveur de développement : les chiffres n’ont rien à voir.
npm run build
npm run preview
npx lighthouse http://localhost:4173 --viewLe rendu serveur (SSR), la vraie réponse
Tout ce qui précède améliore une application déjà indexable. Si votre HTML initial est vide, c’est ici que se joue la partie.
Trois options, par ordre d’effort croissant.
Le prérendu génère un fichier HTML complet par route au moment du build. C’est la solution la plus simple, et elle suffit dès que le contenu ne dépend pas du visiteur : pages de présentation, documentation, blog. Un plugin comme vite-plugin-prerender s’ajoute à la configuration existante, sans toucher au code de l’application.
La génération de site statique pousse la même idée plus loin, avec la gestion des données et du routage. Nuxt en mode nuxt generate couvre ce besoin.
Le rendu serveur, ou SSR pour server-side rendering, construit la page à chaque requête. C’est nécessaire quand le contenu dépend du visiteur ou change en permanence. Cela impose un serveur Node et de faire attention au code qui suppose l’existence de window ou document. Nuxt reste le chemin le plus court, le rendu serveur monté à la main est un projet en soi.
Si le contenu est le même pour tout le monde, prérendez : vous obtenez le meilleur résultat pour le moindre effort. Le rendu serveur ne se justifie que si la page dépend vraiment de qui la demande.
Le référencement dans les réponses des assistants
Les robots des assistants conversationnels n’exécutent généralement pas JavaScript. Une application entièrement rendue côté client leur est invisible, quelles que soient ses balises meta.
Ce qui compte pour eux rejoint les bonnes pratiques classiques : du HTML servi tel quel, une structure de titres qui suit le propos, des données structurées, une réponse claire en tête de page plutôt qu’après trois paragraphes d’introduction.
L’ordre des priorités
- Servir du HTML complet, par prérendu ou rendu serveur. Sans cela, le reste ne sert à rien.
- Un titre et une description propres à chaque page, avec unhead.
- Un lien canonique sur les pages accessibles par plusieurs URL.
- Un sitemap généré et déclaré dans
robots.txt. - Les données structurées sur les pages qui décrivent une entité identifiable.
- La performance, mesurée sur le site construit.
Si vous découvrez Vue, le tutoriel Vue 3 pour les débutants pose les bases nécessaires avant d’attaquer le routage et le rendu serveur.
Erreurs fréquentes
@unhead/vue, createHead de @unhead/vue/client. Tout importer du même chemin donne « useHead is not a function ».curl.npm run build.

