Encited logo
PricingTalk to sales
Vue SEO Guide: How to Make a Vue.js App Search-Friendly With SSR, Pre-rendering and Meta Tags

Vue SEO Guide: How to Make a Vue.js App Search-Friendly With SSR, Pre-rendering and Meta Tags

Oct 16, 2026October 16, 2026·Updated Oct 29, 2026October 29, 2026·by Aki from Encited

Is Vue SEO friendly? It depends on rendering. What a Vue SPA sends crawlers, when to use Nuxt, vite-ssg or pre-rendering, and meta tags with useHead.

If you shipped a Vue SPA and Search Console shows pages left out of the index, or every shared link shows the same title, the usual cause is the first response your server sends. A Vue single-page app built with Vite hands crawlers an empty <div id="app">. Google has to run your JavaScript before it sees any content, and the crawlers behind ChatGPT and Claude never run it.

Vue SEO comes down to where each page becomes HTML. A Vue app rendered on a server or at build time hands crawlers the full page. The fixes for Vue 3 are Nuxt, vite-ssg, Inertia SSR on Laravel, or a pre-render layer in front of the app you already run. Which one fits depends on how much of the app you can change. The framework-neutral side of these problems is in JavaScript SEO, and the single-page app pitfalls that apply before any rendering choice are in SPA SEO.

TL;DR

  • A client-side Vue SPA sends an empty #app for every route. Google renders it from a queue; AI crawlers and link previews don't render it.
  • New or rewritable app: Nuxt with SSR. Mostly static pages on Vite: vite-ssg. Laravel with Inertia: Inertia SSR.
  • A live SPA you can't rewrite: a pre-render layer serves crawlers a rendered snapshot with no code changes.
  • Set meta tags with useSeoMeta and useHead from @unhead/vue. vue-meta is for Vue 2 and no longer maintained.
  • Use createWebHistory() and <RouterLink>, and give unknown URLs a real 404.

Is Vue SEO friendly?

Vue's SSR guide says Google and Bing "can index synchronous JavaScript applications just fine", but if your app starts with a loading spinner and fetches content with Ajax, "the crawler will not wait for you to finish." A Vue SPA that loads products, posts or listings from an API after it mounts is that second case.

Vue ships what SEO needs: server rendering through vue/server-renderer, and HTML5 history mode in Vue Router for clean URLs. Head tags come from Unhead, a separate library. Vue 3 SEO works the way Vue 2 SEO did. The tools changed: Unhead replaced vue-meta, and createWebHistory() replaced mode: 'history'.

What a Vue SPA sends crawlers

Every route of a client-side Vue app returns the same index.html. Before JavaScript runs, a crawler that asks for /products/blue-kettle gets:

  • the <title> from index.html, the same on every route
  • no description, canonical or Open Graph tags for that page, because useHead hasn't run
  • no text and no <a href> links, because no component has mounted
  • a 200 status, even for URLs that don't exist

The shell itself, and a table of which crawlers render it, are in dynamic rendering for React, Vue and Angular. Google puts the page in a render queue, and its docs say a page "may stay on this queue for a few seconds, but it can take longer than that." The AI crawlers from OpenAI, Anthropic and Perplexity stop at the raw HTML, according to Vercel's crawler study. The full answer is in do AI crawlers render JavaScript.

For a Vue SPA that means late indexing in Google, one generic preview for every link on Slack and LinkedIn, and nothing for ChatGPT search to quote.

Vue SSR SEO and the other rendering options

The options differ in how much of the app you rewrite and what you run in production. SSR vs SSG vs pre-rendering compares the approaches for any stack.

Nuxt with server-side rendering

Nuxt renders every route on the server by default, so the first response holds the content, real links, and the tags from its built-in useHead and useSeoMeta. routeRules sets a different mode per path:

ts
CopyDownload
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true },
'/products/**': { swr: 3600 },
'/admin/**': { ssr: false },
},
})

The homepage becomes a static file at build time, product pages are server-rendered and cached for up to an hour, and the admin area stays client-side. Pick Nuxt for a new public site, or a public section with many pages that change often. The cost is a server or edge runtime, and a move to Nuxt's file-based routing.

Nuxt fixes the empty first response. Content that loads later is still missing from it: data fetched with useFetch(url, { server: false }), anything inside <ClientOnly>, and tabs that load on click. Sitemaps, canonicals and 404s stay your job; throw createError({ status: 404, statusText: 'Page Not Found' }) in a page returns a real 404.

To find those gaps on a live page, fetch it with the ChatGPT crawler simulator and look for the text from your <ClientOnly> blocks and click-loaded tabs.

Nuxt SPA SEO with ssr: false

ssr: false in nuxt.config.ts turns Nuxt into a client-side SPA with the same empty first response as a plain Vue app. Nuxt's docs name the cost: "Indexing and updating the content delivered via client-side rendering takes more time".

Turn server rendering back on and keep ssr: false only where crawlers never go, like /admin/** above, or leave the app as it is and put a pre-render layer in front of it. If you deploy with nuxt generate, have your host serve the generated 404.html for unknown paths, the file Nuxt's docs say to use when "the host should keep a 404 status and still load your app."

vite-ssg for a Vite and Vue Router app

vite-ssg renders each route of your existing Vite and Vue Router app to an HTML file at build time. The build script becomes vite-ssg build, and main.ts exports a createApp made with ViteSSG:

ts
CopyDownload
// main.ts
import { ViteSSG } from 'vite-ssg'
import App from './App.vue'
import { routes } from './routes'
export const createApp = ViteSSG(App, { routes })

It sets up @unhead/vue (version 2 in the current release), so your useHead tags land in each file. Dynamic routes are skipped by default: the build drops any path containing : or *, so /blog/:slug produces nothing until you list the real URLs with the includedRoutes hook in ssgOptions. Each route renders in Node first, so code that reads window or document during setup needs a guard.

Pick vite-ssg when your public pages are known at build time and change less often than you deploy: marketing pages, docs, a blog. VitePress does the same for docs sites, and the Vue docs run on it.

prerender-spa-plugin and "Unable to prerender all routes"

Older Vue CLI apps often pre-render with prerender-spa-plugin. It's a webpack plugin that opens each route in headless Chrome through Puppeteer at build time and saves the HTML. Its GitHub repository was archived in April 2023, and npm marks the package as no longer supported.

Its best-known failure is [prerender-spa-plugin] Unable to prerender all routes!. The plugin catches any error from the render step and prints only that line, so the real cause stays hidden. The issue tracker holds dozens of reports of it, from webpack 5 upgrades, CI servers and builds with about 100 routes. The plugin gets no more releases, so move to Vite and vite-ssg, or pre-render outside your build.

Inertia SSR for Laravel and Vue

With Inertia v3, server rendering is built in. Install @inertiajs/vite and add inertia() to the plugins in vite.config; the plugin reuses your app.js for the server build, so you don't need a separate ssr.js. Change the build script to vite build && vite build --ssr, and run php artisan inertia:start-ssr next to your app in production. Set titles and meta tags with the <Head> component from @inertiajs/vue3. With SSR on, Inertia's docs say <Head> "already provides these elements as part of the server-rendered response".

Without Inertia, a common setup is one Laravel catch-all route that returns a Blade view with <div id="app"> for every path. Laravel can fix two things before Vue loads: render each route's title, description, canonical and Open Graph tags in that Blade view from your database, and return a real 404 for slugs that don't exist. The body still renders in the browser, so add Inertia SSR or a pre-render layer for it.

A pre-render layer for an existing Vue SPA

Some Vue SPAs can't be rewritten: the app is large, it came out of an AI website builder, or the team has other work. For Vue.js prerender SEO on an app already in production, a pre-render layer sits in front of it. Crawlers get a snapshot of each page, taken after Vue finished rendering, and people keep getting the SPA.

Encited does this for Vue apps with a DNS change or a middleware snippet for your host, and nothing in your Vue code changes. Search engines and link previews get the finished HTML, and AI crawlers get HTML or Markdown depending on what they ask for. When a route loads data in several steps, set window.prerenderReady = false in index.html, then true once the data is in. The renderer waits up to 30 seconds for the flag to turn true before it saves the snapshot. Snapshots refresh daily, weekly, monthly or quarterly per path, and you can re-cache a page by hand after a deploy. The setup for Vue is on pre-rendering for Vue.

Google calls this approach dynamic rendering, describes it as a workaround, and doesn't treat it as cloaking when crawlers and people get similar content.

Which option fits your Vue app

Your Vue app Pick Why
New public site, content changes daily Nuxt with SSR Every response is complete, head tags included
Marketing pages, docs or a blog on Vite vite-ssg, or VitePress for docs Static HTML files, no server
Nuxt with ssr: false SSR for public routes in routeRules, or Encited (pre-render layer) Client rendering stays where crawlers never go
Laravel with Inertia Inertia SSR Built in: one extra build and one extra process
Laravel Blade catch-all with a Vue SPA Tags and 404s in Blade, plus Encited (pre-render layer) Fixes the head now and the body without a rewrite
Vue CLI with a failing prerender-spa-plugin vite-ssg after a move to Vite, or Encited (pre-render layer) The plugin is archived
Large live SPA with many dynamic routes Encited (pre-render layer) No code changes, covers every route
Logged-in dashboard Leave it client-rendered Crawlers can't sign in

Vue SEO meta tags and social sharing with useHead

vue-meta SEO on Vue 3: use Unhead

vue-meta was the head manager for Vue 2. Its Vue 3 version stopped at alpha (3.0.0-alpha.10 on npm), the GitHub repository was archived on October 1, 2025, and npm shows "vue-meta is no longer maintained." On Vue 3, use @unhead/vue (version 3.4 as of October 2026). Nuxt and vite-ssg include it already.

Per-route tags with useSeoMeta

Install @unhead/vue and register it in main.ts:

ts
CopyDownload
import { createApp } from 'vue'
import { createHead } from '@unhead/vue/client'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createHead())
app.use(router)
app.mount('#app')

On vite-ssg, skip this step: ViteSSG sets up the head itself, so don't call createHead, and keep @unhead/vue on 2.x so the app doesn't load two versions of Unhead.

Then set tags in each page component. useSeoMeta takes a flat object of SEO fields. Wrap values in computed so the tags update when the data arrives:

vue
CopyDownload
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useHead, useSeoMeta } from '@unhead/vue'
import { useProduct } from '@/composables/useProduct' // your data fetching
const route = useRoute()
const { product } = useProduct(route.params.slug as string)
useSeoMeta({
title: computed(() => `${product.value?.name} | Your Store`),
description: computed(() => product.value?.summary),
ogTitle: computed(() => product.value?.name),
ogDescription: computed(() => product.value?.summary),
ogImage: computed(() => product.value?.imageUrl),
twitterCard: 'summary_large_image',
})
useHead({
link: [{ rel: 'canonical', href: computed(() => `https://example.com${route.path}`) }],
})
</script>

Nuxt auto-imports the same two composables. Inertia apps use <Head>.

In a client-rendered Vue app, a few details decide whether these tags count:

  • Remove the canonical from index.html. A hard-coded canonical there points every route at the homepage before your per-route tag replaces it.
  • Keep noindex out of index.html. Google's rules for both tags are in noindex and canonical tags changed by JavaScript.
  • Use a full URL for ogImage, including https:// and your domain.
  • Previews read raw HTML only. Slack, LinkedIn, X and Facebook don't run JavaScript. Tags from useSeoMeta reach them only through SSR, SSG or a pre-render layer.

History mode. createWebHistory() gives every route a real path. Vue Router's docs say hash mode "does however have a bad impact in SEO." Your host has to serve index.html for those paths, and the per-router setup is in the framework rendering post.

Real links. <RouterLink to="/pricing"> renders an <a href>. Google's docs say Google "can only discover your links if they are <a> HTML elements with an href attribute." A button that calls router.push() works for people and gives crawlers no URL to follow.

Soft 404s. Vue Router's history mode docs carry this warning: "Your server will no longer report 404 errors as all not-found paths now serve up your index.html file." Google reports those 200-status error pages as soft 404s. The fix depends on your setup:

  • Nuxt with SSR: createError with status: 404, as above.
  • Laravel: return the 404 from the route before the Blade view.
  • Client-only SPA: Google's two client-side fixes, and its caveat on noindex added by JavaScript, are in JavaScript SEO.
  • With Encited in front of the SPA, the not-found view sets one meta tag and crawlers get a real 404 status. The details are in the framework rendering post.
vue
CopyDownload
<!-- NotFound.vue, matched by { path: '/:pathMatch(.*)*', component: NotFound } -->
<script setup lang="ts">
import { useHead } from '@unhead/vue'
useHead({
title: 'Page not found',
meta: [
{ name: 'robots', content: 'noindex' },
{ name: 'prerender-status-code', content: '404' },
],
})
</script>

Structured data and sitemaps in Vue

Structured data. Add JSON-LD through useHead. Unhead turns the object into JSON:

ts
CopyDownload
// in the same product component
useHead({
script: [
{
type: 'application/ld+json',
textContent: computed(() => ({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.value?.name,
description: product.value?.summary,
image: product.value?.imageUrl,
})),
},
],
})

@unhead/schema-org adds typed helpers through useSchemaOrg. Google reads JSON-LD added by JavaScript once it renders the page. Crawlers that don't run JavaScript never see it.

Sitemaps. A Vite and Vue SPA ships without a sitemap. Generate public/sitemap.xml at build time from your route list plus the slugs in your database or CMS, and reference it in robots.txt. Nuxt has the @nuxtjs/sitemap module. In Laravel, build it from the same data that renders your routes. List only URLs that return a 200 and are their own canonical.

robots.txt. Don't block your /assets/ folder or the API your pages call. Google renders the page with those files, and a blocked bundle renders as an empty page. If you want to show up in ChatGPT search, allow OAI-SearchBot.

Then run a product URL through the free SEO audit to check its robots.txt rules, schema and canonical in one pass.

Core Web Vitals and hydration in Vue

Core Web Vitals measure what people experience on the page, and Google uses them in ranking. A good page has LCP within 2.5 seconds and INP of 200 milliseconds or less, measured at the 75th percentile of page loads, per web.dev.

In a client-rendered Vue SPA, LCP waits for the bundle, then the API call, then the render. A pre-render layer doesn't change what people's browsers do, so split routes with lazy imports (component: () => import('./views/Product.vue')) and never lazy-load the LCP image.

In a server-rendered Vue app (Nuxt, Inertia, vite-ssg), the HTML arrives fast, then hydration runs the page's JavaScript. Clicks during that work wait on the main thread, which shows up as poor INP. Vue 3.5 added lazy hydration for async components:

ts
CopyDownload
import { defineAsyncComponent, hydrateOnVisible } from 'vue'
const Reviews = defineAsyncComponent({
loader: () => import('./Reviews.vue'),
hydrate: hydrateOnVisible(),
})

In Nuxt it's a prop on a lazy component: <LazyReviews hydrate-on-visible />. Field data for both metrics is in Search Console's Core Web Vitals report.

How to check what Google and AI crawlers get from your Vue app

Run these against one real product, post or listing URL:

  1. curl -s https://yoursite.com/products/blue-kettle | grep -i "<title>" should print that product's title. The title from index.html means crawlers get the shell.
  2. Fetch the same URL as GPTBot, and a URL that doesn't exist, with the curl commands in the JavaScript SEO audit. GPTBot should get a sentence of the body text and the <a href> links, and the made-up URL should return 404.
  3. In Google Search Console, open URL Inspection, click Test live URL, then View tested page to see the HTML Googlebot rendered. The Pages report lists soft 404s and Crawled - currently not indexed URLs.
  4. The crawler simulator shows Googlebot and AI crawler responses for a URL side by side.

Once crawlers hit the fixed pages, Encited's crawl logs show which bots fetched each URL and what they got back. To get Google back to the pages you fixed, use Request indexing in URL Inspection for a few key URLs, or Index Rush for the rest.

FAQ

Is Vue SEO friendly? Yes, when crawlers get rendered HTML through Nuxt, vite-ssg, Inertia SSR or a pre-render layer. A client-side Vue SPA gives them an empty #app until JavaScript runs.

Why do search engines struggle to index Vue.js single-page apps? Every route returns the same empty shell, and the content arrives later from JavaScript and API calls. Google renders it from a queue, so indexing is slower and breaks when scripts fail. AI crawlers never render it.

What is the difference between SSR and pre-rendering in Vue, and when should I use each? SSR builds the HTML on your server for every request. Pre-rendering builds it ahead of time, at build time with vite-ssg or Nuxt's prerender rule, or on a schedule by a layer in front of a live SPA. Use SSR for a new app with fast-changing content, and pre-rendering for static pages or an SPA you can't rewrite. A client-only Vue SPA needs one of the two to be read by AI crawlers.

How do I add meta tags in Vue 3? Install @unhead/vue, register createHead() from @unhead/vue/client in main.ts, and call useSeoMeta or useHead in each page component. In a client-rendered app, the tags reach crawlers only through SSR, SSG or pre-rendering.

Is vue-meta still maintained? No. Its repository was archived on October 1, 2025, and its Vue 3 version stopped at alpha. Use @unhead/vue.

Does choosing Nuxt over plain Vue fix SEO? With SSR on, it fixes the empty first response. You still own sitemaps, canonicals, real 404s, and content fetched only in the browser. With ssr: false, a Nuxt app has the same problems as any Vue SPA.

How do I fix "Unable to prerender all routes" in prerender-spa-plugin? The message hides the real error, and the plugin was archived in 2023. Move the build to Vite and use vite-ssg, or pre-render outside the build with a layer in front of the deployed app.

How do I do SEO for a Laravel and Vue SPA? With Inertia, turn on Inertia SSR and set tags with <Head>. With a Blade catch-all route, render each route's title, description and canonical in Blade, return real 404s from Laravel, and pre-render the page body for crawlers.

Is Vue better than React for SEO? Neither has an edge: both send an empty shell as SPAs and full HTML with server rendering. The React side is in React SEO.

Is Vue still relevant in 2026? Yes. Vue 3.5, Vue Router 5, Nuxt 4 and Unhead 3 all published releases in October 2026.

Google
Gemini
ChatGPT
Claude
Grok
Meta
Perplexity

Get discovered anywhere search happens

Readable, citable, outranking pages.

Talk to sales
Avatar
How can we help?
Get instant answers to your questions or leave a message for an engineer will reach out
Ask AI about Encited
See our docs
Contact support
Leave a message
We'll get back to you soon
Avatar
Ask AI about Encited
Product questions, troubleshooting and
Thinking
Preview
Drop an image to attach
Powered by ReplyMaven