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 #6 in JavaScript Frameworks#31 in Platform SEO#243 in Technical SEO#339 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 WRS, 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 rendering 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 hydration mismatches (a server/client content delta is an SEO problem, not just a perf one) and the CSR bundle’s hit on LCP. For the framework-agnostic version of all of this, see JavaScript SEO.

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 crawlers: 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 crawlers 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 index 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 SEO 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-found 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 redirect 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 description. 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 tags including Open Graph 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 tags, 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 SEO 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.

Evidence for this claim Module-scope singleton state in Vue SSR can leak user-specific data across requests; create application, router and store instances per request. Scope: SSR and hydration Confidence: high · Verified: Server-Side Rendering

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 sitemaps, robots.txt, and structured data. 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 LCP 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-LD 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 SEO is one instance of the general JavaScript SEO problem — parity, interaction, state, timing — and it shares almost everything with the headless CMS 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 indexing, rankings, Core Web Vitals, 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 an expert quote first.