Vue SEO

Vue 3 defaults to client-side rendering, so the content isn't in the raw HTML. This is how to make Vue apps crawlable and indexable — router mode, @unhead/vue, prerendering with vite-ssg, SSR/SSG with Nuxt, hydration, and Core Web Vitals.

First published: Jun 26, 2026 · Last updated: Jul 18, 2026 · Advanced
demand #5 in JavaScript Frameworks#30 in Platform SEO#193 in Technical SEO#267 on the site

Vue 3 defaults to client-side rendering, so a plain Vite + Vue app ships an empty HTML shell and builds the page in the browser. Google can render it — but rendering is delayed and async data can be missed, and Bing and social scrapers often can't run the JS at all. The fixes: use Vue Router history mode (not hash mode), manage <head> with @unhead/vue, and put content in the HTML with prerendering (vite-ssg), SSR, or SSG via Nuxt. Dynamic rendering is deprecated — don't build on it.

TL;DR — Vue 3 is CSR by default, so the content isn’t in the raw HTML — Google renders it later via its evergreen Chromium WRSTurning HTML, CSS, and JavaScript into the final visual page and DOM., but the render is queued and async data fetched in onMounted can be missed, and Bing/social scrapers often don’t run the JS at all. Non-negotiables: Vue Router createWebHistory() (hash mode has “a bad impact in SEO”), <head> via @unhead/vue / useSeoMeta(), and a renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. strategy that gets content into HTML — vite-ssg prerendering for content-stable sites, Nuxt for full SSR/SSG. Dynamic rendering is deprecated; prerender-spa-plugin is legacy. Watch hydrationActivating server-rendered HTML in the browser by attaching JavaScript handlers. mismatches (a server/client content delta is an SEO problem, not just a perf one) and the CSR bundle’s hit on LCPLargest Contentful Paint — render time of the largest visible image or text block, relative to when the page started loading. ≤2.5 s (at the 75th percentile) is good.. For the framework-agnostic version of all of this, see JavaScript SEOMaking sure search engines can crawl, render, and index content that depends on JavaScript..

The default Vite + Vue scaffold is client-side rendered

A plain npm create vue@latest app (Vite + Vue) ships a near-empty HTML shell and a JavaScript bundle. The browser executes that bundle to build the DOM. This is fine for users and a problem for crawlersA crawler — also called a spider or bot — is an automated program that fetches web pages, extracts their links, and queues new URLs to visit. Search engines use crawlers to discover and download content for their index.: the raw HTML contains almost no content, and everything depends on rendering.

That’s a property of the default scaffold, not a ceiling on Vue itself — Vue core also supports server rendering (createSSRApp) and static generation as first-class paths, and Vue’s own SSR guide steers most production SSR/SSG work toward Nuxt rather than a hand-rolled setup. So before reaching for a fix, check what your app is actually shipping: a bare Vite SPA with no rendering config is CSR; the same app behind Nuxt or vite-ssg isn’t.

Vue’s own SSR guide is direct about the upside of not doing this: with server-side rendering, “the search engine crawlersA crawler — also called a spider or bot — is an automated program that fetches web pages, extracts their links, and queues new URLs to visit. Search engines use crawlers to discover and download content for their index. will directly see the fully rendered page.” The flip side is what you ship by default — content that only exists after JavaScript runs. Evidence for this claim Vue server-side rendering sends rendered HTML so crawlers can directly see page content. Scope: Vue SSR benefits; does not guarantee indexing. Confidence: high · Verified: Vue: SSR guide

(Vue 2 reached end of life in December 2023; everything here is Vue 3.)

How Googlebot handles a Vue app

Google processes JavaScript in phases — crawl the raw HTML, then render it in an evergreen, headless Chromium instance (the Web Rendering Service), then indexStoring a crawled page in the search index so it can appear in results. Crawled is not the same as indexed — Google selects what to keep, and indexing isn't guaranteed. the rendered result. Two things follow from that:

  • Rendering is queued and delayed. A page sits in the render queue — usually seconds to minutes, but it can spike. Content that exists only after JS runs is not immediately indexable; content in the raw HTML is.
  • Async data is the real risk. Synchronous JavaScript renders reliably. Data fetched from an API after mount is not guaranteed to be caught. Vue’s SSR guide says it plainly: “If your app starts with a loading spinner, then fetches content via Ajax, the crawler will not wait for you to finish. This means if you have content fetched asynchronously on pages where SEO is important, SSR might be necessary.” Practically: data fetched in onMounted is client-only and invisible to crawlers that don’t render — and at risk even with the ones that do.

John Mueller has described the failure mode for SPA-style setups where the static HTML is mostly identical and all the unique content depends on JavaScript: if that JS can’t be executed properly, the pages end up looking the same to Google, and the focus stays on the boilerplate HTML rather than the JS-loaded content. The lesson is the same as the broader JavaScript SEOMaking sure search engines can crawl, render, and index content that depends on JavaScript. page: get the content into the DOM, fast and reliably.

Bing and social bots are worse at this than Google. Bing has no rendering pipeline comparable to Google’s, and social preview scrapers (X, Slack, iMessage) generally don’t run JavaScript at all — so a bare CSR Vue app produces empty share previews. If Bing traffic or social sharing matters, prerendering or SSR isn’t optional.

Vue Router: use createWebHistory(), not hash mode

Vue Router has two history modes. Hash mode (createWebHashHistory()) produces URLs like example.com/#/about; Vue Router’s docs say it “does however have a bad impact in SEO” because everything after the # is a fragment the server ignores. Evidence for this claim Vue Router hash history uses a URL fragment that is not sent to the server and is discouraged for SEO. Scope: Vue Router hash history. Confidence: high · Verified: Vue Router: History modes Use HTML5 history mode:

import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [/* ... */],
})

History mode gives clean URLs (example.com/about) but requires a server fallback — any direct request to a route has to serve index.html so Vue can take over, otherwise direct hits 404. Configure that catch-all on your host (Nginx try_files, an SPA rewrite on Netlify/Vercel/Cloudflare, etc.).

Scope that rewrite carefully — a catch-all that’s too broad also serves index.html for missing static assets, real API routes, or pages you actually want to 404. That silently undoes the soft-404 guidance below and can hide a broken link behind a page that looks like it “worked.”

For genuine not-foundA 302 (\"Found\") is a temporary redirect: it forwards users to a new URL while telling search engines the original URL should stay in the index. It's a weak canonicalization signal, not the zero-equity dead end of SEO folklore. states, avoid soft-404s — a client-side “not found” view that still returns 200 can get indexed as an empty shell. Google’s guidance is to either redirectA redirect sends browsers and crawlers from a requested URL to a different one. An HTTP redirect specifically is a 3xx status code paired with a Location header; meta refresh and JavaScript redirects achieve a similar navigation without being a 3xx response themselves. Permanent redirects (301/308) are Google's signal the target should be canonical; temporary ones (302/303/307) aren't. to a URL that returns a real 404 status or add a <meta name="robots" content="noindex"> to error pages, and to use the History API for routing between views.

Head and meta management: @unhead/vue

By default every Vue route shares one <title> and one meta descriptionThe meta description is an HTML head tag — `<meta name=\"description\" content=\"…\">` — that suggests a short summary of the page for the search snippet. It's not a Google ranking factor, and Google rewrites it the majority of the time, but a good one can still lift click-through.. The tag history here matters because the old answers are dead ends:

  • vue-meta — the Nuxt 2-era library. Legacy.
  • @vueuse/head — its successor, now sunset.
  • @unhead/vue — the current community standard, and what you should use for a non-Nuxt Vue 3 app.

The ergonomic entry point is the useSeoMeta() composable — type-safe, XSS-safe, and aware of 100+ meta tagsMeta tags are HTML elements in a page's head that pass metadata about the page to search engines and browsers. For SEO only a few matter — the title element, the meta description, and the robots meta tag — while meta keywords and most others are ignored. including Open GraphOpen Graph (OG) tags are `<meta>` elements in a page's head, defined by the Open Graph protocol (ogp.me, created by Facebook), that describe a page as a shareable object — its title, description, image, URL, and type. They control how a link preview card looks when the page is shared on Facebook, LinkedIn, Slack, Discord, WhatsApp, and iMessage. They are not a direct Google ranking factor, though Google reads og:title, og:image, and og:site_name as inputs to how a result appears. and Twitter Card:

import { useSeoMeta } from '@unhead/vue'

useSeoMeta({
  title: 'Vue SEO Guide',
  description: 'How to make a Vue 3 app crawlable and indexable.',
  ogTitle: 'Vue SEO Guide',
  ogDescription: 'How to make a Vue 3 app crawlable and indexable.',
  twitterCard: 'summary_large_image',
})

Meta can be reactive — pass a getter or computed value and the tags update when your data does. For canonical tagsA rel=\"canonical\" annotation — in the HTML <head> or an HTTP Link header — that tells search engines which URL is the preferred version of duplicate or near-duplicate content., the safest pattern is still HTML, not JS: Google’s guidance is that “the best way to set the canonical URL is to use HTML,” and if you must inject it with JavaScript, always set it to the same value the HTML would. (I tested JS canonicals at Ahrefs and found Google does respect them — it even led Google to add an exception to its docs — but HTML is still the lower-risk path.)

Pick a rendering strategy

This is the decision that actually moves the needle. Same menu as the framework- agnostic JavaScript SEOMaking sure search engines can crawl, render, and index content that depends on JavaScript. page, applied to Vue.

Client-side rendering (CSR) — the default, and the risky one. Acceptable for app-like pages behind a login, dashboards, and anything you don’t need indexed. Not acceptable for content that must rank or be shared.

Prerendering with vite-ssg. For content-stable Vue 3 SPAs, vite-ssg generates static HTML at build time — swap your build script from vite build to vite-ssg build. It ships @unhead/vue built in. Great for marketing sites, docs, and blogs; not a substitute for SSR on highly dynamic, per-request pages. Note that the old webpack-era prerender-spa-plugin is legacy and effectively unmaintainedvite-ssg (or Nuxt) is the modern replacement.

Server-side rendering. Vue’s official guide covers a manual SSR setup with @vue/server-renderer, but it explicitly steers most projects to a meta-framework rather than rolling your own — and for simple cases it even says “if you’re only investigating SSR to improve the SEO of a handful of marketing pages … then you probably want SSG instead of SSR.” If you do roll manual SSR, create the app, router, and store fresh per request: on a long-running Node server, module-level singletons get reused across requests, and mutating shared state with one user’s data can leak it into another user’s response. That’s one more reason Vue’s docs point most projects at Nuxt, which handles per-request isolation for you.

Nuxt — the recommended full solution. Nuxt gives you SSR and SSG out of the box, built-in useSeoMeta() and head management, and the @nuxtjs/seo module for sitemapsA sitemap is a file that lists the pages, images, videos, and other files on your site so search engines can discover them. It helps discovery, but submitting a sitemap doesn't guarantee crawling or indexing., robots.txtA plain-text file at the root of a host that tells crawlers which URLs they may and may not request. It controls crawling, not indexing — a blocked URL can still be indexed if it's linked from elsewhere., and structured dataStructured data is a standardized way of labeling page content (using the schema.org vocabulary in JSON-LD, Microdata, or RDFa) so search engines can understand its meaning. It's not a direct ranking factor — its value is rich results and entity understanding.. For most Vue projects where SEO matters, this is the path Vue’s own docs point to. (Nuxt has its own depth — covered separately; treat this as the cross-link, not the deep dive.)

Dynamic rendering — don’t. Serving prerendered HTML to bots and the SPA to users was always a workaround, and Google deprecated it in 2024, removing the implementation docs. Build on SSR or SSG instead.

For documentation specifically, VitePress is the Vue-native static site generator. Every page ships as plain HTML — no JS rendering barrier — and SEO is configured via frontmatter (title, description, head) or the transformHead build hook for canonical/dynamic tags.

Hydration mismatches are an SEO problem, not just a perf one

When you do SSR/SSG, the client mounts with createSSRApp() — not the plain createApp() — and hydrates the server-rendered HTML instead of rebuilding it from scratch. If the server HTML and the client render diverge, Vue discards and re-renders the mismatched nodes. In SEO terms, that means the content Google indexed from the server render can differ from what the user sees after hydration — a quality-signal inconsistency, not just a flicker. Common causes: invalid HTML nesting, random values in templates, and date/time discrepancies between server and client. Vue 3.5+ adds data-allow-mismatch to suppress the mismatch warning selectively where a difference is intentional — it quiets the console, it doesn’t make the server and client output equivalent, so don’t reach for it just to silence a mismatch you haven’t actually diagnosed.

Core Web Vitals are a Vue-specific concern

Default CSR Vue ships a large JS bundle that has to download, parse, and execute before the largest content paints — so LCPLargest Contentful Paint — render time of the largest visible image or text block, relative to when the page started loading. ≤2.5 s (at the 75th percentile) is good. suffers if you do nothing. Levers:

  • Code splitting (Vite’s default) and lazy-loaded routes so you don’t ship the whole app up front.
  • fetchpriority="high" on the LCP hero image.
  • Vapor Mode — an opt-in compiler mode that bypasses the virtual DOM for eligible components, which can cut hydration cost on SSR pages. It’s Vue 3.6, not 3.5 — still experimental (beta/release-candidate as of mid-2026, not yet in the stable 3.5.x line) — worth watching, not something to depend on in production today.

Structured data in Vue

Google executes JavaScript before reading structured data, so injecting JSON-LDJSON-LD (JavaScript Object Notation for Linked Data) is a script-based structured data format, typically paired with the schema.org vocabulary to describe page content for search engines and AI systems. Google recommends it over Microdata and RDFa because it's the easiest format to implement and maintain at scale — but all three work, and structured data isn't a ranking signal. works — @unhead/vue’s useHead() with a script of type application/ld+json is the reliable pattern. Because it depends on rendering, confirm it with URL Inspection rather than assuming Google saw it.

Where this fits

Vue SEOVue SEO is the practice of making web apps built with Vue.js crawlable, renderable, and indexable. Because Vue 3 defaults to client-side rendering, the content isn't in the raw HTML — so router mode, head management, and a rendering strategy (CSR, prerendering, SSR, or SSG) all matter. is one instance of the general JavaScript SEOMaking sure search engines can crawl, render, and index content that depends on JavaScript. problem — parity, interaction, state, timing — and it shares almost everything with the headless CMSA headless CMS decouples content storage and editing (the backend) from how that content is rendered and delivered (the frontend), serving content over an API instead of a built-in templated 'head'. Its SEO outcomes come almost entirely from how the separate frontend renders pages. situation, where the rendering mode of the frontend decides the outcome.

One caveat that applies to every option above: SSR and SSG get your content into the HTML, but neither guarantees indexingStoring a crawled page in the search index so it can appear in results. Crawled is not the same as indexed — Google selects what to keep, and indexing isn't guaranteed., rankings, Core Web VitalsGoogle's three real-user UX metrics — LCP (loading), INP (responsiveness), and CLS (visual stability) — used by Google's ranking systems, with no official weight attached, measured on field data., or that hydration will match the server output exactly. They remove the JavaScript-rendering barrier — the rest of SEO (and correctness) is still on you.

If you remember one thing: get your content into the HTML. Everything else is detail.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin a quote first.