Vue SEO: Best Practices and Techniques for Vue.js Apps
Vue SEO optimization guide: how Vue handles dynamic SEO, per-route meta, structured data and how to make a Vue.js app SEO friendly. By Epicmax.

Vue SEO is a strategy problem before it is a technical one. Vue will happily build a fast, pleasant app that search engines barely see, and just as happily one that ranks. The difference is a handful of decisions made early: how pages are rendered, how each route describes itself, how URLs and links are formed. This guide, from a team that has shipped Vue and Nuxt sites since 2017, covers those decisions and the techniques behind them.
Why Vue.js SEO Needs Extra Attention
A plain Vue app is a single HTML file and a JavaScript bundle. The file has one <title>, one <meta name="description"> and an empty <div id="app">; everything else, including the text a crawler would index, is built in the browser after the bundle runs. That is the root of every Vue JS SEO complaint.
Rendering is the first consequence, and the biggest. Google can run your JavaScript, but it queues pages for rendering and gets to them when it has capacity, and most other crawlers never run it. The fix is to produce HTML on the server or at build time, and we cover that choice in a separate guide to Vue SEO rendering. This article assumes you have read it and focuses on everything else.
Everything else is not small. One HTML file means one title unless you change it per route. Client-side routing means the server answers 200 for any path, so missing pages look like real ones. Links built with click handlers instead of anchors are invisible. Meta tags written by JavaScript arrive late or, for noindex, may not be honoured at all. None of these is hard to fix. All of them are easy to forget, because the app works perfectly for a human with a browser.
How Vue Handles Dynamic SEO
How Vue handles dynamic SEO is the question behind "my title never changes". Vue core deliberately has no opinion about the document head; it manages what is inside your root element and nothing above it. The head is handled by the ecosystem, and today that means Unhead, the library Nuxt ships by default and that any Vue 3 app can install as @unhead/vue.
Unhead treats head tags like reactive state. Call useHead() or useSeoMeta() in a page component, pass refs or computed values, and the title, description, canonical and Open Graph tags follow the route and the data:
<script setup lang="ts">
const { data: product } = await useFetch(`/api/products/${useRoute().params.slug}`)
useSeoMeta({
title: () => product.value?.name ?? 'Product',
description: () => product.value?.summary,
ogTitle: () => product.value?.name,
ogImage: () => product.value?.image,
})
</script>
Two things make this "dynamic SEO" rather than a title swap. The tags are resolved on the server when the page is rendered there, so the crawler receives them in the HTML, not after a script runs. And they are scoped: when the user navigates away, the page's tags are removed and the next page's take over, so a product title never leaks onto the checkout.
Mind the traps, though. Tags that only exist after client-side rendering are read by Google late and by other crawlers never, which is the rendering problem again. And Google's own documentation warns that if the raw HTML already carries noindex, Googlebot may skip rendering entirely, so changing or removing a robots tag with JavaScript may not work as expected. Decide indexability on the server.
Vue.js SEO Best Practices
The Vue JS SEO best practices below are the ones we apply on every project, and the Vue SEO techniques behind them are mostly configuration, not code. The table is the checklist; the sections after it explain each row.
| Practice | What it fixes | Vue/Nuxt tool | Effort |
|---|---|---|---|
| Use History Mode and clean URLs | Hash URLs that crawlers cannot resolve; ugly, duplicate or inconsistent paths | Vue Router createWebHistory(); Nuxt file-based routing | Low |
| Set unique titles, descriptions and canonicals per route | One title for the whole site; duplicate-content signals | useSeoMeta(), useHead(); Nuxt site config | Low |
| Add structured data with JSON-LD | Missing rich results; unclear page type and author | useSchemaOrg() / nuxt-schema-org | Medium |
| Keep sitemap, robots.txt and 404s in sync with routes | Orphan pages; crawled junk; soft 404s | @nuxtjs/sitemap, @nuxtjs/robots, createError() | Low |
| Make internal links crawlable | Links hidden in click handlers; crawl paths that end early | <NuxtLink> / <RouterLink> | Low |
| Optimise Core Web Vitals | Slow LCP, janky INP, layout shifts that cost rankings | <NuxtImg>, lazy hydration, code splitting | Medium to high |
| Keep key content visible to crawlers | Text hidden behind v-if, tabs, infinite scroll or client-only widgets | Server data fetching, v-show, paginated URLs | Medium |
Use History Mode and Clean URLs
Vue Router offers two URL styles. Hash history puts the route after a #, which the browser never sends to the server, so /#/pricing and /#/about are one page to a crawler; the Vue Router docs say it has a negative impact on SEO. Use HTML5 history mode:
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({ history: createWebHistory(), routes })
History mode needs the server to return index.html for unknown paths so a direct visit to /pricing does not 404; in nginx that is try_files $uri $uri/ /index.html;. Nuxt handles the router and the server side for you, with file-based routes.
Clean also means consistent: lowercase paths, hyphens between words, one trailing-slash policy, no tracking parameters in internal links. Every URL variant that returns the same page is a duplicate your canonical tag then has to clean up.
Set Unique Titles, Descriptions and Canonicals per Route
Every indexable route needs its own title, description and canonical URL, generated from the page's data rather than a hand-maintained list. With Unhead that is one call per page, as in the example above. Then make it scale. A title template adds the site name once:
useHead({ titleTemplate: (title) => title ? `${title} | Epicmax` : 'Epicmax' })
And the canonical should be absolute and point at the one URL you want indexed, which matters most for filtered listings and paginated pages. In Nuxt with the SEO modules installed, set site.url once in nuxt.config.ts and a canonical is generated for every route; override it only where it differs from the current path. In plain Vue, add it with useHead({ link: [{ rel: 'canonical', href }] }) and build the href from a configured origin, never from window.location. Keep descriptions under about 160 characters and specific to the page; a description repeated across routes counts for nothing.
Add Structured Data with JSON-LD
Structured data tells search engines what a page is: an article with an author and date, a product with a price, an organisation with a logo. It feeds rich results and, increasingly, how AI answers attribute sources. JSON-LD in a <script type="application/ld+json"> is the format Google recommends, and Unhead's Schema.org integration writes it from typed helpers:
useSchemaOrg([
defineArticle({
headline: 'Vue SEO: Best Practices and Techniques for Vue.js Apps',
datePublished: '2026-10-08',
author: { name: 'Pavel Mironov' },
}),
])
In Nuxt the nuxt-schema-org module adds the site-wide Organization and WebSite nodes, breadcrumbs and per-page types, linked by @id so one graph describes the site; this page's own JSON-LD is produced that way. Validate with Google's Rich Results Test before shipping, and keep it honest: the visible page must contain what the schema claims.
Keep Your Sitemap, Robots.txt and 404s in Sync with Your Routes
A sitemap built from your routes is a crawl map; one built by hand is a list of pages you remembered. @nuxtjs/sitemap generates it from the app's routes and content collections at build time, so a new blog post is in the sitemap the moment it exists. @nuxtjs/robots writes robots.txt from config, which is also where you decide which AI crawlers to allow; our guide to SEO for AI agents in Vue and Nuxt lists them.
Missing pages need real status codes. A client-side app answers 200 for every path, so Google sees a thin live page instead of a 404 and files it as a soft 404. In Nuxt, throw from the page and the server answers correctly:
if (!product.value) throw createError({ statusCode: 404, statusMessage: 'Product not found' })
Moved pages should answer 301. On a server-rendered Nuxt deployment a route rule does it without code: '/old-path': { redirect: { to: '/new-path', statusCode: 301 } }. On a statically generated site, put the redirects in the host's own file instead, Netlify's _redirects for example: the prerendered stub a route rule emits is a client-side redirect, which passes no link equity. We learned that one on this site.
Make Internal Links Crawlable
Crawlers discover pages by following <a href>. A <div @click="router.push('/pricing')"> navigates fine for people and is invisible to a crawler, as is a button that calls a store action or a card that becomes a link only after a script attaches a listener. Use <NuxtLink> in Nuxt or <RouterLink> in plain Vue: both render a real anchor with an href and intercept clicks for client-side navigation, and Nuxt's prefetches the target page's code when the link enters the viewport.
Then think about paths. Every important page should be reachable within a few clicks of the home page, with descriptive anchor text rather than "click here", and listings need paginated URLs as well as a "load more" button. Run a link checker too: Nuxt SEO's reports broken and redirected internal links at build time, which is how the links in this article were verified.
Optimise Core Web Vitals
Core Web Vitals are a ranking signal and, more to the point, the difference between a page that feels instant and one that does not. Three metrics, three Vue-specific levers.
Largest Contentful Paint is usually the hero image or first block of text. Serve images through <NuxtImg> so they are resized and converted to modern formats, give the hero fetchpriority="high", and self-host fonts with font-display: swap.
Interaction to Next Paint suffers when the main thread is busy. Ship less JavaScript: split routes, lazy-load heavy components, and use lazy hydration so a component below the fold renders on the server but only becomes interactive when seen:
<LazyProductReviews hydrate-on-visible />
Cumulative Layout Shift comes from content arriving without reserved space: give images width and height, give async sections a fixed-height skeleton, never inject a banner above content after load. Measure with Lighthouse in development and the Core Web Vitals report in Search Console in production; the second reflects real users and is what ranking uses.
Keep Key Content Visible to Crawlers
The content a page should rank for has to be in the HTML the crawler receives, which starts with fetching data where the server can see it, as the rendering guide explains. Beyond that, a few Vue habits quietly hide content. v-if removes an element from the DOM; v-show keeps it and hides it with CSS, so for tabs, accordions and "read more" sections whose text matters, keep it in the markup. <ClientOnly> is for widgets with no SEO value, maps, charts, chat, and should always have a fallback. Back infinite scroll with paginated URLs. Put real text next to text in images, and an alt attribute on every image. And render a sensible default for personalised content on the server, so the crawler sees a complete page rather than a placeholder.
How to Test Your Vue SEO and Check the App Is SEO Friendly
A Vue.js app only becomes SEO friendly when every route ships its own title, description and canonical URL, and the only way to know is to look at what a crawler receives, not what your browser shows. This checklist takes ten minutes per page type:
- View the source, not the DOM.
curlthe URL or open view-source and confirm the title, description, canonical and main content are in the raw HTML. Anything present in DevTools but not here is only visible after JavaScript runs. - Inspect it as Google. In Search Console, run URL Inspection, choose "Test live URL" and open "View crawled page" for the HTML Googlebot rendered and any resources it could not load.
- Run Lighthouse. The SEO audit catches missing descriptions, uncrawlable links and blocked resources; the performance audit gives you lab Core Web Vitals.
- Validate structured data in the Rich Results Test and preview Open Graph tags with a social sharing debugger.
- Check status codes with
curl -Ifor a real page, a missing page and a moved page: 200, 404 and 301. - Confirm the sitemap lists the page and
robots.txtdoes not block it, then submit the sitemap once.
Repeat after every rendering or routing change. Most regressions we see in a Vue.js code audit are pages that passed once and quietly broke in a refactor.
Conclusion
Vue SEO optimization is a strategy: decide how pages are rendered, make every route describe itself, keep URLs and links crawlable, and measure with the tools crawlers use. The techniques are small. Unhead for meta tags, JSON-LD from typed helpers, a sitemap built from routes, real status codes, anchors instead of click handlers, and a performance budget. Nuxt packages most of this as defaults, which is why migrating to Nuxt 4 is often the first SEO fix we recommend. If you want a second pair of eyes on an existing Vue app, or a Nuxt build that gets it right from the start, our Nuxt development services team does both.
FAQs
Is Vue.js good for SEO?
Yes, once it is set up for it. Vue itself is neutral: it renders what you tell it, where you tell it. A Vue app with server-rendered or prerendered HTML, per-route meta tags, clean URLs and a sitemap ranks like any other site. A default single-page app with none of that is where the bad reputation comes from.
Does Google index Vue.js single-page apps?
Yes, with a delay. Googlebot fetches the HTML, queues the page for rendering, runs the JavaScript in a headless Chromium when resources allow, and indexes the result. Content that loads late or fails after mount is missed, and other crawlers, including most AI bots, do not render at all.
Do I need Nuxt for Vue SEO?
Not strictly, but it removes most of the work. Nuxt gives you server rendering or prerendering per route, Unhead for meta tags, and modules for sitemaps, robots, schema and link checking. A plain Vue app can reach the same result with Unhead, Vue Router history mode and a rendering strategy; our rendering guide covers that choice.
How do I add meta tags in Vue 3?
Use Unhead. Install @unhead/vue, register createHead() as a plugin, then call useHead() or useSeoMeta() inside any component or page. The tags are reactive, so they update with route and data, and the same calls work on the server. In Nuxt both composables are built in and auto-imported.
Does Vue SEO affect visibility in AI search tools?
Yes, and more directly than classic search. Most AI crawlers fetch raw HTML and do not execute JavaScript, so a Vue page whose content only appears after rendering is invisible to them. The same fixes apply: ship content in the HTML, keep meta and structured data accurate, and allow the search bots you want in robots.txt.
Vue.js is our passion. Open Source is our culture 😍