App SEO

Vue SEO: The Complete Developer Guide for 2026

May 15, 2026 | by Ian Adair

Vue.js SEO Developer Setup






Vue SEO: The Complete Developer Guide for 2026


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.

Developer at a workstation with Vue.js code and SEO meta tags visible on dual monitors
Vue’s reactivity model is powerful, but client-side rendering creates real crawlability challenges that require server-side solutions.

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.

Diagram comparing CSR client-side rendering, SSR server-side rendering, and SSG static site generation for Vue.js SEO
SSR and SSG both solve the Googlebot crawlability problem, but the right choice depends on your content update frequency.
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 to onMounted() 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() or setTimeout(fn, 0)
  • Use shallowRef and shallowReactive for large data structures to avoid deep reactivity overhead
  • Virtualize long lists with vue-virtual-scroller instead of rendering thousands of nodes
  • Debounce input handlers and use v-memo on 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() inside onMounted() (runs only client-side)
  • Using document.title = '...' directly (client-only)
  • Async data not awaited before useHead() reads it
  • Schema generated from ref values 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.


RELATED POSTS

View all

view all