Vue.js SEO Guide: SSR vs Prerendering for Vue SPAs (2026)
Make a Vue.js SPA crawlable: SSR vs prerendering vs SSG, head tags with Unhead, history mode, real 404s and hydration fixes, with a benchmark and a check script.
Quick answer: A Vue single-page app is SEO-friendly only if crawlers get real HTML. Googlebot renders JavaScript, but later and not always fully, and most AI crawlers don't render at all. Server-render (Nuxt or Vue's
renderToString) or prerender your routes at build time, manage<title>and meta tags per route with Unhead, use history-mode URLs, return real 404s, and fix hydration mismatches.
In our benchmark below, the same Vue 3 page shipped 0 words in its raw HTML when client-rendered and 440 words when server-rendered, and painted about three times sooner on a throttled mobile profile. In a random sample of real sites, 27% of client-rendered Vue sites shipped less than half their visible text in the HTML.
SSR vs Prerendering vs SSG vs CSR for Vue
| Approach | How it works | Raw HTML has content | Best for | Vue tooling |
|---|---|---|---|---|
| CSR (default SPA) | Browser downloads JS, then renders | ❌ | Logged-in apps, dashboards | Vite + Vue |
| Prerendering | Render each route to static HTML at build time | ✅ | Marketing pages, docs on an existing SPA | vite-ssg, prerender plugins |
| SSG | Whole site generated at build time | ✅ | Content sites, blogs | Nuxt (nuxi generate), VitePress |
| SSR | HTML rendered per request on the server | ✅ | Pages with fresh or personalised data | Nuxt, vue/server-renderer |
| Hybrid | Per-route choice of SSR, SSG or CSR | ✅ (where enabled) | Mixed sites | Nuxt routeRules |
If you're starting fresh, Nuxt gives you SSR, SSG and hybrid rendering without assembling it yourself. Our Nuxt 3 SEO checklist covers it. If you have an existing Vite SPA, prerendering your public routes is the fastest fix.
Benchmark: The Same Vue Page, Client-Rendered vs Server-Rendered
We built one Vue 3.5 product page (an H1, three paragraphs, a spec table and two links) three ways and measured each:
- CSR: empty
<div id="app">, rendered in the browser. - SSR: rendered to HTML with
vue/server-renderer, then hydrated withcreateSSRApp. - SSR, fixed: the same, after fixing a hydration mismatch (explained below).
Browser numbers are medians of five loads with Lighthouse-style mobile throttling (4× CPU, 150 ms RTT, 1.6 Mbps), cache disabled.
| Variant | Raw HTML words | <title> in HTML | H1 in HTML | Links in HTML | First Contentful Paint | Largest Contentful Paint | Hydration errors |
|---|---|---|---|---|---|---|---|
| CSR | 0 | no | no | 0 | 680 ms | 680 ms | — |
| SSR | 440 | yes | yes | 2 | 216 ms | 216 ms | 1 per load |
| SSR, fixed | 440 | yes | yes | 2 | 223 ms | 223 ms | 0 |
Vue 3.5.13, esbuild 0.24.0, Chromium via Playwright. Measured 8 October 2026.
The CSR page is empty to any crawler that doesn't run JavaScript, and even for Googlebot its content waits for the bundle. The SSR page paints as soon as the HTML arrives, because the content doesn't depend on the bundle.
The hydration mismatch we hit
The first SSR build logged Hydration completed but contains mismatches. on every load. The cause was the spec table: the component rendered <tr> directly inside <table>. The server string kept that structure, but the browser's HTML parser inserts a <tbody> automatically, so the DOM no longer matched the virtual DOM Vue expected.
// ❌ Mismatch: the browser inserts <tbody>, the virtual DOM doesn't have one
h('table', specs.map(([k, v]) => h('tr', [h('th', k), h('td', v)])))
// ✅ Fixed: render the <tbody> yourself
h('table', [h('tbody', specs.map(([k, v]) => h('tr', [h('th', k), h('td', v)])))])Other common Vue mismatch causes are invalid nesting (a <div> inside a <p>), dates and random values that differ between server and client, and reading window or localStorage during render. Vue 3.5 adds a data-allow-mismatch attribute for values that are expected to differ, such as a timestamp.
The core of the benchmark's server render is three lines:
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
const html = await renderToString(createSSRApp(App)) // put inside <div id="app">How Vue Sites Look to Crawlers in the Wild
In our raw-vs-rendered study of 219 randomly sampled homepages (Tranco ranks 1,001–50,000), we detected Vue on 11 sites and Nuxt on 4:
| Framework | Sites | Median raw/rendered word ratio | Sites with under half their text in raw HTML |
|---|---|---|---|
| Vue (no Nuxt) | 11 | 0.93 | 27% |
| Nuxt | 4 | 0.85 | 0% |
Small samples, but the direction matches the benchmark: Vue apps that server-render (here, all the Nuxt sites) ship their content; plain client-rendered Vue apps often don't. The full study is in do AI crawlers render JavaScript?
The Vue.js SEO Checklist
1. Get content into the HTML
Server-render or prerender every page you want indexed. Keep CSR for authenticated areas.
2. Manage head tags per route
Vue has no built-in head manager. Use Unhead (@unhead/vue), which Nuxt uses internally:
// main.js
import { createHead } from '@unhead/vue/client' // '@unhead/vue/server' in your SSR entry
app.use(createHead())
// ProductPage.vue <script setup>
import { useSeoMeta } from '@unhead/vue'
useSeoMeta({
title: () => `${product.value.name} | Example Shop`,
description: () => product.value.summary,
ogImage: () => product.value.image,
})On a CSR-only app these tags are only set after JavaScript runs, so they don't help non-rendering crawlers. They need SSR or prerendering too.
3. Use history mode, not hash mode
createWebHashHistory() produces URLs like /#/pricing. Crawlers treat everything after # as a fragment of the same page, so every route collapses into one URL. Use createWebHistory() and configure the server to serve your app (or prerendered file) for each path.
4. Return real 404s
A client-side catch-all route renders "Not found" with HTTP 200, which search engines treat as a soft 404. With SSR, set the status in your server handler; with static hosting, make unknown paths return your 404.html with status 404.
5. Use real links
<RouterLink> renders an <a href>, which crawlers can follow. Avoid @click="router.push(...)" on non-link elements for navigation.
6. Add structured data in the HTML
Render JSON-LD on the server (Unhead's useHead({ script: [...] }) with type: 'application/ld+json') rather than injecting it after mount.
7. Watch hydration and bundle size
Fix mismatches (they log in production as "Hydration completed but contains mismatches") and lazy-load route components so the first page doesn't ship the whole app.
Check Your Vue Site's Crawlability
This standard-library script checks the raw HTML of any page for the usual Vue SPA problems:
"""Vue SPA SEO check: what crawlers get from a Vue site before JavaScript runs.
Usage: python3 vue_seo_check.py https://example.com/
Checks the raw HTML for: an empty mount point (#app / #__nuxt), visible text,
<title>, meta description, H1, crawlable links, hash-mode (#/) routes, and
whether an unknown URL returns a real 404 (SPAs often return 200: a soft 404).
"""
import re, sys, urllib.error, urllib.request, uuid
UA = {"User-Agent": "Mozilla/5.0 (vue-seo-check)"}
def get(url):
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
return r.status, r.read(3_000_000).decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, ""
def main(url):
status, html = get(url)
body = re.sub(r"(?is)<(script|style|noscript|template)\b.*?</\1>", " ", html.split("<body", 1)[-1])
words = len(re.findall(r"\w+", re.sub(r"<[^>]+>", " ", body)))
mount = re.search(r'<div[^>]+id=["\'](app|__nuxt)["\'][^>]*>\s*</div>', html)
links = re.findall(r'<a\s[^>]*href=["\']([^"\']+)', html, re.I)
hash_links = [l for l in links if l.startswith("#/") or "/#/" in l]
soft_status, _ = get(url.rstrip("/") + f"/{uuid.uuid4().hex[:12]}-does-not-exist")
vue_markers = [m for m in ("data-v-", "data-v-app", "__NUXT__", "/_nuxt/", "vue") if m in html]
checks = [
("Mount point has server-rendered content", not mount, "empty <div id=" + mount.group(1) + ">" if mount else ""),
("Visible text in raw HTML (>= 150 words)", words >= 150, f"{words} words"),
("<title> in raw HTML", bool(re.search(r"<title[^>]*>\s*\S", html, re.I)), ""),
("Meta description in raw HTML", bool(re.search(r'name=["\']description["\'][^>]*content=["\']\s*\S', html, re.I)), ""),
("H1 in raw HTML", bool(re.search(r"<h1[\s>]", html, re.I)), ""),
("Crawlable <a href> links", len(links) >= 5, f"{len(links)} links"),
("No hash-mode routes (#/)", not hash_links, ", ".join(hash_links[:3])),
("Unknown URL returns 404", soft_status == 404, f"got {soft_status}"),
]
print(f"HTTP {status}; Vue markers: {', '.join(vue_markers) or 'none found'}\n")
for name, ok, detail in checks:
print(f"{'PASS' if ok else 'FAIL'} {name:<42} {detail}")
if __name__ == "__main__":
main(sys.argv[1])Output for a client-rendered Vue site and a Nuxt site from our sample:
FAIL Visible text in raw HTML (>= 150 words) 3 words
FAIL Meta description in raw HTML
FAIL Crawlable <a href> links 1 links
FAIL Unknown URL returns 404 got 200HTTP 200; Vue markers: data-v-, __NUXT__, /_nuxt/, vue
PASS Visible text in raw HTML (>= 150 words) 1276 words
PASS Crawlable <a href> links 123 links
PASS Unknown URL returns 404 got 404How BugViso Audits Vue Sites
A BugViso audit renders each page in headless Chromium, so it sees your Vue app the way users do, and then checks what crawlers get without JavaScript:
- Raw HTML vs rendered DOM parity: flags pages where less than 25% of the content exists before JavaScript runs.
- Hydration mismatches: reads the console on every crawled page for React, Next and Vue SSR hydration messages.
- Head tags, H1s and canonicals per page, plus structured data validation.
- Unused JavaScript by file via Chrome DevTools coverage, and long tasks with the worst script named.
The checks behind this are covered on the developer website audit page.
Common Vue SEO Mistakes
- Relying on Google's rendering. It works for Googlebot, eventually; it doesn't help AI crawlers that don't render.
- Setting
document.titleinmounted(). Crawlers that read the HTML never see it. - Hash-mode routing on a public site. Every route collapses to one URL.
- A catch-all route that returns 200. Soft 404s waste crawl budget and confuse indexing.
- Ignoring production hydration errors. They mean the server and client disagree about your markup.
FAQ
Is Vue.js good for SEO?
Vue is fine for SEO when pages are server-rendered or prerendered. A client-only Vue SPA ships empty HTML, which hurts crawling by non-rendering bots and delays content for Google.
Do I need Nuxt for Vue SEO?
No, but it's the easiest route. Without Nuxt, use vue/server-renderer with a Vite SSR setup, or prerender routes with a tool like vite-ssg.
Does Google index client-side rendered Vue apps?
Googlebot can render JavaScript and often indexes CSR content, but rendering may be delayed. Other crawlers, including most AI crawlers, see only the empty shell.
How do I set meta tags per page in Vue?
Use Unhead (@unhead/vue) with useHead or useSeoMeta in each route component, and render it on the server so the tags are in the HTML.
What causes "Hydration completed but contains mismatches" in Vue?
The server HTML and the client's first render differ. Common causes are invalid HTML nesting (like <tr> without <tbody>), dates or random values, and browser-only APIs used during render.
The Takeaway
For Vue SEO, the rendering mode matters more than any tag: get your content into the HTML with SSR or prerendering, then confirm it with a BugViso rendered audit of your routes.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.