Vue.js SEO: how to make a Vue 3 app properly indexable

Search engine optimisation, or SEO, is a crucial part of any website or web application. Sites and applications that search engines struggle to index, or that are badly optimised, end up buried behind pages and pages of results.

Vue.js SEO: how to make a Vue 3 app properly indexable
Quick answer

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:

bash
curl -s https://mon-site.com/ma-page | head -40

If 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 belongs to the past

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.

bash
npm install @unhead/vue
src/main.js
import { 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>:

src/views/ProduitView.vue
<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>
Two different import paths

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.

javascript
const produit = ref(null)

useHead({
  title: computed(() => produit.value?.nom ?? 'Chargement…')
})

The same page reachable through several URLs, with or without tracking parameters, dilutes the signal. Declare the reference address:

javascript
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:

scripts/sitemap.js
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:

public/robots.txt
User-agent: *
Allow: /

Sitemap: https://mon-site.com/sitemap.xml

Structured 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.

javascript
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.

src/router/index.js
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.

bash
npm run build
npm run preview
npx lighthouse http://localhost:4173 --view

Server-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.

Choosing without getting it wrong

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

  1. Serve complete HTML, through prerendering or server rendering. Without it, the rest is pointless.
  2. A title and a description specific to each page, with unhead.
  3. A canonical link on pages reachable through several URLs.
  4. A generated sitemap, declared in robots.txt.
  5. Structured data on pages that describe an identifiable entity.
  6. 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

Installing vue-meta Its Vue 3 version was never stabilised. The library used today, by Nuxt included, is unhead.
Two different unhead import paths The composables come from @unhead/vue, createHead from @unhead/vue/client. Importing everything from the same path gives “useHead is not a function”.
Polishing meta tags on an empty application A crawler that does not run JavaScript sees nothing. Check the output of curl first.
Building the canonical from window.location.href Campaign parameters end up inside the canonical link. Use the route path instead.
Measuring performance on the dev server The numbers have nothing to do with production. Measure on the output of npm run build.

JavaScriptSEOVue.js

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.