Vue SEO: The Complete Developer Guide for 2026
Vue apps are invisible to half the web by default. Unless you render on the server, your routes ship as a near-empty HTML shell with a single <div id="app"> and a JavaScript bundle. Googlebot can usually handle that. ChatGPT, Perplexity, Claude’s search crawler, and most social previewers cannot. If you care about rankings, AI citations, or rich link previews, the rendering decision is the entire game.

This guide walks through the practical SEO setup for Vue 3 and Nuxt 3 in 2026: when to use SSR vs SSG vs prerendering, how to wire up useHead() for per-route metadata, how to inject JSON-LD that crawlers actually see, and the Core Web Vitals optimizations specific to Vue’s reactivity model.
How Do You Improve SEO for a Vue.js App?
Featured answer: The single most impactful change is moving from client-side rendering to server-side rendering with Nuxt 3 or static generation. After that, add per-route meta tags with useHead(), inject JSON-LD structured data into the document head, generate a sitemap with the Nuxt Sitemap module, and optimize Core Web Vitals using the Nuxt Image module and lazy hydration.
The Core Problem: Why Vue CSR Apps Have Poor SEO
A default Vue 3 app created with npm create vue@latest uses client-side rendering. The server returns this:
<!DOCTYPE html>
<html>
<head>
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Everything else (the heading, paragraphs, product data, prices) gets built in the browser after the JavaScript runs. Here is what each crawler sees when it hits that URL:
- Googlebot: Renders the page in a headless Chromium instance and indexes the result. Usually works, but queued for rendering with delays of hours to days. Heavy pages can fail rendering silently.
- Bingbot: Renders JavaScript but with stricter timeouts and lower priority. Mixed results in practice.
- GPTBot, PerplexityBot, ClaudeBot, OAI-SearchBot: Do not execute JavaScript at all. They see the empty shell. Your content does not exist as far as AI search is concerned.
- Facebook, LinkedIn, X, Slack, Discord crawlers: Fetch a static snapshot for link previews. No JavaScript. Your Open Graph image, title, and description must be in the initial HTML or the preview is blank.
The cost of CSR is not theoretical. A SaaS dashboard might not care because nobody is sharing the /billing page on LinkedIn. A marketing site, ecommerce store, blog, or documentation portal absolutely cares. AI-driven referral traffic is growing faster than organic Google traffic for many categories, and if ChatGPT cannot read your product comparison page, your competitor wins the citation.
Vue Rendering Options Compared
Four rendering strategies exist for Vue. Pick based on content volatility and SEO requirements, not on what sounds modern.

| Rendering Mode | SEO Impact | Best For | Vue Implementation |
|---|---|---|---|
| CSR (Client-Side Rendering) | Poor. AI crawlers see nothing. Googlebot delayed. | Internal tools, prototypes, authenticated apps behind login | Default Vue 3 (npm create vue@latest) |
| SSR (Server-Side Rendering) | Excellent. Full HTML on first request. All crawlers see content. | Content sites, ecommerce, marketing pages, news | Nuxt 3 (npx nuxi@latest init) |
| SSG (Static Generation) | Excellent. Pre-built HTML files served from CDN. | Docs, blogs, marketing sites, landing pages | Nuxt 3 static mode (nuxt generate) |
| Prerendering | Good. Renders SPA pages to static HTML at build time. | Small Vue SPAs you do not want to migrate to Nuxt | vue-cli-plugin-prerender-spa or Vite SSG |
Default to Nuxt 3. For pure marketing sites with infrequent updates, use SSG. For ecommerce or anything with user-specific server logic, use SSR with hybrid rendering rules. Reserve prerendering for legacy SPAs where a Nuxt migration is not feasible.
Building in other frameworks? See our guides on React SEO, Next.js SEO, and Angular SEO for the equivalent setup.
Setting Up SSR with Nuxt 3
Nuxt 3 is the official Vue meta-framework and the path of least resistance for SEO. SSR is on by default. Start a new project:
npx nuxi@latest init my-site
cd my-site
npm install
npm run dev
The nuxt.config.ts file controls rendering behavior. Here is a production-ready configuration:
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
app: {
head: {
htmlAttrs: { lang: 'en' },
titleTemplate: '%s | Acme Corp',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'theme-color', content: '#0066ff' }
],
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'canonical', href: 'https://acme.com' }
]
}
},
modules: [
'@nuxtjs/sitemap',
'@nuxtjs/robots',
'@nuxt/image'
],
site: {
url: 'https://acme.com',
name: 'Acme Corp'
},
nitro: {
prerender: {
crawlLinks: true,
routes: ['/']
}
}
})
That single config sets up SSR, registers the sitemap, robots, and image modules, and tells Nitro (Nuxt’s server engine) to prerender static routes at build time. For hybrid rendering where some routes are static and others are server-rendered on demand, use routeRules:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true },
'/blog/**': { isr: 3600 }, // Incremental static regen, 1hr cache
'/products/**': { swr: 600 }, // Stale-while-revalidate, 10min
'/dashboard/**': { ssr: false }, // Client-only for auth area
'/api/**': { cors: true }
}
})
This pattern matters because rendering strategy should match content type. A homepage with monthly updates prerenders to a CDN. A product catalog with thousands of SKUs uses ISR. A user dashboard skips SSR entirely because nobody is indexing it anyway. Read the official Nuxt 3 docs for the full rendering options reference.
Meta Tags and Open Graph in Vue
Per-route meta tags are non-negotiable. Search engines use the title tag as the primary ranking signal for what a page is about, and the meta description is what gets shown in SERPs. In Vue 3 with Nuxt, use the useHead() composable:
<!-- pages/products/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: product } = await useFetch(`/api/products/${route.params.slug}`)
useHead({
title: product.value?.name,
meta: [
{
name: 'description',
content: product.value?.shortDescription
},
{
property: 'og:title',
content: product.value?.name
},
{
property: 'og:description',
content: product.value?.shortDescription
},
{
property: 'og:image',
content: product.value?.heroImage
},
{
property: 'og:type',
content: 'product'
},
{
name: 'twitter:card',
content: 'summary_large_image'
}
],
link: [
{
rel: 'canonical',
href: `https://acme.com/products/${route.params.slug}`
}
]
})
</script>
For Vue 3 apps not using Nuxt, install @unhead/vue (the successor to @vueuse/head):
npm install @unhead/vue
// main.ts
import { createApp } from 'vue'
import { createHead } from '@unhead/vue/client'
import App from './App.vue'
const app = createApp(App)
const head = createHead()
app.use(head)
app.mount('#app')
Then call useHead() identically in any component. The @unhead library handles deduplication, server rendering, and reactive updates, which matters when product data loads asynchronously and the title needs to update once the API responds.
Structured Data and JSON-LD in Vue
Schema.org markup unlocks rich results (star ratings, FAQ accordions, product pricing) in SERPs and gives AI crawlers structured context they can quote with confidence. Inject it through useHead():
<script setup lang="ts">
const product = useProduct()
const jsonLd = computed(() => ({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.value.name,
description: product.value.description,
image: product.value.images,
sku: product.value.sku,
brand: {
'@type': 'Brand',
name: 'Acme'
},
offers: {
'@type': 'Offer',
url: `https://acme.com/products/${product.value.slug}`,
priceCurrency: 'USD',
price: product.value.price,
availability: product.value.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock'
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: product.value.rating,
reviewCount: product.value.reviewCount
}
}))
useHead({
script: [{
type: 'application/ld+json',
innerHTML: () => JSON.stringify(jsonLd.value)
}]
})
</script>
Critical detail: the schema must render server-side. If you inject it inside onMounted(), Googlebot’s renderer might pick it up but AI crawlers will not. useHead() in a <script setup> block runs during SSR, so the JSON-LD lands in the initial HTML payload where every crawler can read it.
Validate every schema type before shipping with Google’s Rich Results Test. Invalid schema is worse than no schema because Google penalizes pages that markup content that does not exist.
Core Web Vitals for Vue Apps
Core Web Vitals are ranking factors. For Vue apps, the three metrics break down predictably and each has Vue-specific fixes.
Largest Contentful Paint (LCP)
Target: under 2.5 seconds. The hero image or main heading is usually the LCP element. Three Vue-specific causes of slow LCP:
- Unoptimized images: Use the Nuxt Image module instead of raw
<img>tags. It serves WebP/AVIF, generates responsive srcsets, and lazy-loads below-the-fold images automatically. - Render-blocking JavaScript: Heavy
<script setup>work that runs synchronously delays paint. Move expensive operations toonMounted()or a deferred composable. - Hydration delays: Vue’s hydration mismatches force re-rendering. Use Nuxt’s lazy hydration directives (
hydrate-on-visible,hydrate-on-idle) for below-the-fold components.
<template>
<!-- Above the fold: hydrate immediately -->
<HeroSection />
<!-- Below the fold: defer until visible -->
<LazyTestimonials hydrate-on-visible />
<LazyFooter hydrate-on-idle />
</template>
Interaction to Next Paint (INP)
Target: under 200ms. INP replaced FID in March 2024 and measures responsiveness across all interactions, not just the first. Vue-specific INP fixes:
- Break long synchronous operations into chunks with
scheduler.yield()orsetTimeout(fn, 0) - Use
shallowRefandshallowReactivefor large data structures to avoid deep reactivity overhead - Virtualize long lists with
vue-virtual-scrollerinstead of rendering thousands of nodes - Debounce input handlers and use
v-memoon expensive components
Cumulative Layout Shift (CLS)
Target: under 0.1. Vue apps generate CLS from asynchronously loaded content. Reserve space with explicit dimensions:
<!-- Bad: layout shifts when product loads -->
<div v-if="product">
<img :src="product.image" />
</div>
<!-- Good: reserved space, no shift -->
<div class="aspect-square w-full">
<NuxtImg
v-if="product"
:src="product.image"
width="600"
height="600"
/>
<Skeleton v-else />
</div>
Google’s web.dev guide to Core Web Vitals covers the thresholds in detail. Measure with the Performance panel in Chrome DevTools and PageSpeed Insights for field data from real users.
Technical SEO Checklist for Vue Apps
The infrastructure layer matters as much as content. Every checklist item below has a Nuxt module that handles it without custom code.
| Item | Tool | Implementation |
|---|---|---|
| Sitemap generation | @nuxtjs/sitemap | Auto-generates from routes, supports dynamic URLs from CMS |
| robots.txt | @nuxtjs/robots | Configure per-environment rules in nuxt.config.ts |
| Canonical tags | useHead() link block |
Set rel="canonical" per route, especially paginated lists |
| Dynamic route metadata | useHead() per page |
Unique title and description for every [slug].vue route |
| Image optimization | @nuxt/image | Responsive srcsets, WebP/AVIF, lazy loading by default |
| Hreflang for i18n | @nuxtjs/i18n | Auto-generates hreflang alternates for translated routes |
| Internal linking | <NuxtLink> |
Prefetches on hover, preserves SPA navigation, full SSR support |
| Schema markup | useHead() script block |
JSON-LD injected server-side, validated with Rich Results Test |
Install the core modules in one command:
npx nuxi@latest module add sitemap robots image
Then configure the sitemap source if you have dynamic URLs from a headless CMS:
// nuxt.config.ts
export default defineNuxtConfig({
sitemap: {
sources: ['/api/sitemap-urls'],
autoLastmod: true,
xsl: false
}
})
// server/api/sitemap-urls.ts
export default defineEventHandler(async () => {
const products = await $fetch('https://cms.acme.com/products')
return products.map(p => ({
loc: `/products/${p.slug}`,
lastmod: p.updatedAt,
changefreq: 'weekly',
priority: 0.8
}))
})
Handling Dynamic Routes and Pagination
Most SEO failures in Vue apps happen on dynamic routes. The /blog/[slug].vue page works in dev but ships with stale metadata or no metadata in production because the developer set the title in the parent layout, not the page itself.
Rule: every dynamic route must call useHead() with data fetched from useAsyncData() or useFetch(). Both functions run on the server during SSR, so the metadata is in the HTML response. For pagination, set canonical tags pointing to page 1 and use rel="next" and rel="prev":
<script setup>
const route = useRoute()
const page = computed(() => Number(route.query.page) || 1)
useHead({
title: `Blog ${page.value > 1 ? `- Page ${page.value}` : ''}`,
link: [
{ rel: 'canonical', href: `https://acme.com/blog?page=${page.value}` },
page.value > 1 && { rel: 'prev', href: `https://acme.com/blog?page=${page.value - 1}` },
{ rel: 'next', href: `https://acme.com/blog?page=${page.value + 1}` }
].filter(Boolean)
})
</script>
Testing SEO in Vue Apps
Build the app and inspect the rendered HTML, not the dev server. The dev server shows hot-reloaded markup that does not reflect production behavior. Run:
npm run build
npm run preview
curl http://localhost:3000/your-route | grep -E '(title|description|og:|json-ld)'
If your title, description, OG tags, and JSON-LD are not in the curl output, no crawler will see them. Common issues:
- Calling
useHead()insideonMounted()(runs only client-side) - Using
document.title = '...'directly (client-only) - Async data not awaited before
useHead()reads it - Schema generated from
refvalues that resolve after render
For broader debugging, see our guide on SEO for web apps which covers JavaScript SEO patterns across frameworks. Google’s JavaScript SEO basics documentation covers Googlebot’s rendering behavior in detail.
Frequently Asked Questions
Is Vue.js bad for SEO?
Vue.js is not bad for SEO when configured correctly. The default client-side rendering setup is bad for SEO because crawlers see an empty HTML shell. Switching to Nuxt 3 with SSR or SSG eliminates the problem entirely. Vue itself is framework-agnostic on rendering, and Nuxt 3 makes server rendering the default with zero configuration.
What is the best way to handle SEO in Vue?
Use Nuxt 3 with server-side rendering or static generation, add per-route meta tags through the useHead() composable, inject JSON-LD structured data into the document head, install the @nuxtjs/sitemap and @nuxt/image modules, and validate output with curl against your production build. Avoid client-only rendering for any page you want indexed.
Does Nuxt.js improve Vue SEO?
Yes, significantly. Nuxt 3 provides SSR, SSG, file-based routing with automatic meta tag handling, the useHead() composable for per-route metadata, official modules for sitemaps and image optimization, and Nitro-powered edge deployment. A Nuxt 3 site has parity with Next.js for SEO capabilities and outperforms a default Vue 3 SPA by every measurable metric.
How do I add meta tags to Vue?
In Nuxt 3, call useHead({ title, meta, link }) inside <script setup> in any page component. In plain Vue 3, install @unhead/vue, register it with app.use(createHead()), then call useHead() the same way. The composable handles reactive updates, deduplication, and SSR injection automatically.
Can Google crawl Vue apps?
Google can crawl Vue SPA apps because Googlebot renders JavaScript in a headless Chromium instance, but rendering is queued and can take hours or days. Heavy bundles can fail rendering silently. Other crawlers (Bing, AI crawlers, social previewers) handle JavaScript poorly or not at all. Server rendering eliminates the uncertainty by shipping complete HTML on first request.
What is the difference between SSR and SSG for Vue SEO?
SSR (server-side rendering) generates HTML on each request, suitable for personalized or frequently changing content like ecommerce catalogs. SSG (static site generation) builds HTML at deploy time and serves files from a CDN, suitable for blogs, docs, and marketing sites with infrequent updates. Both deliver equivalent SEO benefits. SSG is faster and cheaper to serve; SSR handles dynamic data better. Nuxt 3 supports both and lets you mix per-route via routeRules.
Shipping SEO with Vue
The Vue 3 ecosystem in 2026 has every tool you need for first-class SEO. Nuxt 3 makes SSR the default, useHead() handles metadata cleanly, and the official modules cover sitemaps, robots, images, and structured data without writing custom plumbing. The only mistake is shipping a CSR-only app and hoping Googlebot figures it out, because half the crawlers that matter in 2026 do not run JavaScript at all.
Start every Vue project with npx nuxi@latest init, set up per-route metadata from day one, validate output with curl against production builds, and check Core Web Vitals before launch. The rendering decision is the leverage point. Everything else is execution. The Vue.js official SSR guide covers the underlying primitives if you need to build a custom SSR setup outside Nuxt.