
Start by checking what a crawler actually receives, with curl: if the HTML is empty, no meta tag added on the client side will help. Prerendering or server-side rendering is the real answer. Only then handle titles, descriptions and structured data with unhead, which replaces vue-meta.
A Vue application builds its page in the browser. The HTML served by the server is almost empty, and everything then depends on the JavaScript running. That is workable for Google, far less so for other crawlers, starting with the ones behind AI assistants. Here is how to deal with the problem, in the right order.
The real problem is not the meta tags
The first question to ask is not “how do I change my title” but “what does the crawler actually receive”. Check it without a browser:
curl -s https://mon-site.com/ma-page | head -40If the answer is an empty <div id="app"></div>, no meta tag added on the client side will change anything for a crawler that does not run JavaScript. The rendering then has to happen before the response is sent. That is the subject of the last section, and by far the most important one.
The examples assume a project created with create-vue and Vite. The sections that follow start with metadata, because it is useful in every case, server rendering included.
Metadata with unhead
Every page needs its own title and description. unhead installs like any other Vue plugin. In a single-page application, both have to change on every navigation.
vue-meta was the answer back in the Vue 2 days, and most articles online still point to it. Its Vue 3 version was never stabilised. The library used today, by Nuxt included, is 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')Inside a component, useHead and useSeoMeta write into the <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>The composables come from @unhead/vue, but createHead comes from @unhead/vue/client, or from @unhead/vue/server when rendering on the server. Importing everything from the same path gives you the error createHead is not a function.
useSeoMeta saves you writing arrays of tags by hand: each key maps to a known tag, and typos show up in autocompletion.
Values can be reactive: pass a ref or a computed property, and the tag follows the data as it loads.
const produit = ref(null)
useHead({
title: computed(() => produit.value?.nom ?? 'Chargement…')
})The canonical link
The same page reachable through several URLs, with or without tracking parameters, dilutes the signal. Declare the reference address:
useHead({
link: [{ rel: 'canonical', href: `https://mon-site.com${route.path}` }]
})Build it from the route path, never from window.location.href, which would drag the campaign parameters along with it.
The sitemap
A sitemap.xml file lists the URLs you consider important. On a single-page application it cannot be discovered by crawling: it has to be generated.
Generate it from the same source as your routes, at build time, rather than by hand:
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>`
)Routes with a parameter are excluded: their real URLs have to come from your data, not from the route definitions. Then declare the file in robots.txt:
User-agent: *
Allow: /
Sitemap: https://mon-site.com/sitemap.xmlStructured data
A JSON-LD block states explicitly what the page contains. It is what feeds rich results, and it is read far more easily than prose that has to be interpreted.
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'
}
})
}]
})Check the result with Google’s Rich Results Test, against the live URL rather than the source code.
Performance
Core Web Vitals count towards ranking. Three levers give you most of the gain on a Vue application.
Split the bundle by route. A dynamic import in the route definitions is enough: Vite produces a separate file, loaded on demand.
const routes = [
{ path: '/', component: () => import('../views/AccueilView.vue') },
{ path: '/produit/:id', component: () => import('../views/ProduitView.vue') }
]Size your images. An explicit width and height avoid the layout shift that ruins CLS. AVIF or WebP cuts the weight sharply.
Measure before you optimise. Run Lighthouse against the built site, not against the dev server: the numbers have nothing to do with each other.
npm run build
npm run preview
npx lighthouse http://localhost:4173 --viewServer-side rendering, the real answer
Everything above improves an application that is already indexable. If your initial HTML is empty, this is where the game is decided.
Three options, in increasing order of effort.
Prerendering generates a complete HTML file per route at build time. It is the simplest solution, and it is enough as soon as the content does not depend on the visitor: marketing pages, documentation, a blog. A plugin such as vite-plugin-prerender slots into the existing configuration without touching the application code.
Static site generation takes the same idea further, with data handling and routing included. Nuxt in nuxt generate mode covers that need.
Server rendering, or SSR for server-side rendering, builds the page on every request. It is necessary when the content depends on the visitor or changes constantly. It requires a Node server, and care with any code that assumes window or document exists. Nuxt is still the shortest path, hand-rolled server rendering is a project in its own right.
If the content is the same for everyone, prerender: you get the best result for the least effort. Server rendering is only justified when the page genuinely depends on who is asking for it.
Showing up in AI assistant answers
The crawlers behind AI assistants generally do not run JavaScript. An application rendered entirely on the client is invisible to them, whatever its meta tags say.
What matters to them lines up with the classic good practices: HTML served as-is, a heading structure that follows the argument, structured data, a clear answer at the top of the page rather than after three paragraphs of introduction.
The order of priorities
- Serve complete HTML, through prerendering or server rendering. Without it, the rest is pointless.
- A title and a description specific to each page, with unhead.
- A canonical link on pages reachable through several URLs.
- A generated sitemap, declared in
robots.txt. - Structured data on pages that describe an identifiable entity.
- Performance, measured on the built site.
If Vue is new to you, the Vue 3 tutorial for beginners lays the groundwork you need before taking on routing and server rendering.
Common errors
@unhead/vue, createHead from @unhead/vue/client. Importing everything from the same path gives “useHead is not a function”.curl first.npm run build.

