Technical SEO

Vue SEO: Rendering Modes, Routing, and the Metadata That Arrives Too Late

· · 15 min read

Vue is unusual among the big frameworks in how candidly its own documentation discusses SEO. It does not oversell server rendering, it names the exact case where client rendering breaks, and it tells you plainly which routing mode damages your search visibility. Most Vue SEO problems are therefore not mysteries — they are documented trade-offs that nobody on the team read. This is the Vue instalment of the SEO for engineers series, sitting under the general JavaScript SEO umbrella.

Vue SEO is the work of getting a Vue application’s content, titles and structured data into the HTML response the server sends — by pre-rendering routes at build time, server-rendering them per request, or leaving them client-rendered where nothing needs to rank — and then verifying that a crawler reading that response before any JavaScript runs actually receives the page.

Key takeaways

Is Vue bad for SEO?

No, and the useful version of that answer sits in Vue’s own documentation rather than in anything an SEO tool will tell you.

Vue draws the line at synchronous versus asynchronous content. Its SSR guide notes that Google and Bing index synchronous JavaScript applications adequately, and then adds the qualifier that matters: “Synchronous being the key word there. If your app starts with a loading spinner, then fetches content via Ajax, the crawler will not wait for you to finish.” That sentence describes the architecture of nearly every real Vue application. Components mount, an effect fires, data arrives, the page assembles. By the time it does, the crawler has moved on.

So the accurate framing is not “Vue apps do not rank.” It is that a Vue route whose content is fetched after mount is, from a crawler’s position, a page containing a spinner. Everything below is about moving the moment of assembly earlier — to build time, or to the server — for the routes where that matters.

It matters more than it used to, because the crawler population has changed. Googlebot renders JavaScript on a deferred second pass, so a client-rendered Vue route can still be indexed, late and sometimes partially. GPTBot, ClaudeBot and PerplexityBot do not render at all. For those engines a client-rendered route is not slow to appear — it does not exist.

The ten-second check

Before choosing a rendering strategy, find out what you currently ship. Fetch a route the way a crawler does, with no browser involved:

curl -s https://example.com/pricing | grep -iE "<h1|<title"

If your heading and a route-specific title come back, that route is already server-rendered or prerendered. If you get <div id="app"></div> and a script tag, you have found the problem.

Then run it again as an AI crawler, because the consequence differs:

curl -sA "GPTBot" https://example.com/pricing | grep -i "<h1"

Run this per route, not per site. Vue applications are routinely mixed — a prerendered marketing site bolted to a client-rendered product area — and a single spot check on the homepage will tell you nothing about the pages that actually need to rank.

Four rendering modes, and how to choose per route

Vue supports all of these, and the choice belongs to the route rather than to the application.

Static generation (SSG) — the default answer for content

Render each route once at build time and serve the resulting HTML as a file. Vue describes SSG as retaining the same performance characteristics of SSR apps while being cheaper and easier to deploy, because the output is static HTML and assets.

The constraint is in Vue’s wording: SSG “can only be applied to pages providing static data, i.e. data that is known at build time and can not change between requests. Every time the data changes, a new deployment is needed.”

For a marketing site, a documentation set, or a blog, that constraint costs nothing. Vue says so directly — if the goal is the SEO of a handful of marketing pages, SSG is the answer rather than SSR. This is the option most teams skip because it sounds too modest to fix a real problem.

Server-side rendering (SSR) — for per-request data

Render on the server per request, send complete HTML, then hydrate in the browser. Vue lists three benefits — faster time-to-content, a unified mental model, and better SEO, because search engine crawlers will directly see the fully rendered page.

It also lists three costs, and they are worth quoting because teams routinely discover them late: development constraints, since browser-specific code can only live in certain lifecycle hooks; a more involved build and deployment setup, because a server-rendered app needs an environment where Node.js can run; and more server-side load, since rendering a full app in Node is more CPU-intensive than serving static files.

Vue’s own recommendation about how to get there is unambiguous: it highly recommends using Vue frameworks if you need SSR, because they have built-in support. In practice that means Nuxt, and the rendering-mode and head-management specifics are in Nuxt SEO.

Selective prerendering — the retrofit

If migrating the whole application is not on the roadmap, prerender only the routes that need to rank and leave the rest client-rendered. This is the pattern covered in detail in single-page application SEO, and it is the right first move for a shipped product with a marketing surface attached to it.

Client-side rendering — for everything behind a login

The default, and entirely correct for the logged-in application. No crawler needs your dashboard. Choosing CSR deliberately for those routes is not a compromise; it is the reason the other three options can stay small.

SSGSSRSelective prerenderCSR
Data freshnessPer deployPer requestPer deployPer request, in browser
InfrastructureStatic hostNode serverBuild stepStatic host
Crawler sees contentYesYesOn chosen routesNo
AI crawlers see contentYesYesOn chosen routesNo
Right forMarketing, docs, blogPer-request dataRetrofitting a shipped SPAAuthenticated app

Routing: two decisions that determine whether your URLs exist

Rendering gets content into the response. Routing determines whether there is a distinct response to get it into.

Hash mode has a bad impact in SEO — vue-router says so

vue-router offers three history modes. createWebHistory() produces normal paths and is documented as the recommended mode. createWebHashHistory() puts the route after a #, which means, in the docs’ words, that “this section of the URL is never sent to the server, it doesn’t require any special treatment on the server level. It does however have a bad impact in SEO.

That is the framework’s own assessment, not an SEO opinion, and the mechanism behind it is simple: the fragment never reaches the server, so every hash route resolves to one underlying URL. Google’s URL guidance is correspondingly explicit that it generally does not support URL fragments to change page content. A public-facing Vue app on hash routing has, from a crawler’s position, one page.

The third mode, createMemoryHistory(), does not interact with the URL at all and exists for Node environments — it is what you use inside SSR, not instead of HTML5 mode.

The catch-all fallback creates soft 404s, and vue-router warns you

HTML5 mode requires a server fallback: any URL that does not match a static asset serves index.html. Every deployment target has its one-liner — try_files $uri $uri/ /index.html; on nginx, /* /index.html 200 in a Netlify _redirects file.

Then comes the caveat that produces a real and frequently unnoticed indexation problem. vue-router states it plainly: “Your server will no longer report 404 errors as all not-found paths now serve up your index.html file.”

Consider what that means to a crawler. Every typo’d URL, every dead link from an external site, every stale path from a previous URL structure now returns HTTP 200 with your application shell. Google calls this a soft 404, and it wastes crawl attention on pages that do not exist while giving you no signal in Search Console that anything is wrong.

vue-router’s fix is a catch-all route in the app:

const router = createRouter({
  history: createWebHistory(),
  routes: [{ path: '/:pathMatch(.*)', component: NotFoundComponent }],
})

That renders a 404 page for the human. It does not change the status code, because the status code was decided by the server before Vue ran. The docs point at the real solution for anyone running Node: use the router server-side to match the incoming URL and respond with a genuine 404 when nothing matches. If you are on a static host, the equivalent is a hosting-level 404 rule for paths your build did not generate. Which of your pages should return 404 rather than redirect or canonicalise is a separate decision, covered in canonical vs noindex.

Metadata that arrives too late

The pattern here is identical across every client-rendered framework, and it produces the same Search Console symptom: one title, repeated across the whole site.

A Vue app has a single index.html with one <title> and one meta description. Per-route metadata is applied by JavaScript after the router resolves. On a client-rendered route, the crawler reads the response before any of that runs, so what it indexes is whatever the shell contained — usually the app’s name.

Head-management libraries do not change this. They set the head correctly; the question is only whether they run before or after the response is sent. On a prerendered or server-rendered route they run during the build or the request, and the correct title ends up in the HTML. On a client-rendered route they run in the browser, too late for anyone but the user.

The same timing rule governs structured data. JSON-LD appended to the document inside onMounted is invisible to anything reading the raw response — and onMounted is one of the hooks Vue explicitly does not call during SSR (below). Emit schema into the server-produced HTML instead; the priority order for which types repay the effort is in structured data for AI search.

The SSR traps Vue documents, and teams still hit

Vue’s SSR guide has a section called Writing SSR-friendly Code. It is short, it is accurate, and reading it before a migration saves a week. The traps that recur most:

1. Lifecycle hooks that never fire. During SSR, mounted and updated are not called — only beforeCreate and created run on the server. Any data fetch living in onMounted will not run during server rendering, so the server emits the empty state. This is the single most common way an SSR migration ships successfully and changes nothing a crawler can see.

2. Browser globals at module scope. Vue notes that universal code cannot assume access to platform-specific APIs: window or document referenced outside a client-only hook will throw in Node. The refactor this implies is usually the real cost of an SSR migration, not the rendering itself.

3. Cross-request state pollution. A store declared at a module’s root scope becomes a singleton. In the browser that is fine — modules are reinitialised on every page visit. On a server, Vue warns, the same module instances are reused across multiple requests, so state specific to one user can leak into another user’s response. The documented solution is to create a new application instance, router and store per request. This is a correctness and privacy problem before it is an SEO one; it belongs on this list because it is what makes teams roll SSR back.

4. Hydration mismatches. When the server HTML does not match what the client renders, Vue reports a mismatch. The documented causes are invalid HTML nesting the browser silently corrects, randomly generated values that differ between the two runs, and server and client sitting in different time zones. Vue attempts to recover automatically, at a rendering-performance cost — which lands on the Core Web Vitals you are also being measured on. Vue 3.5 and later can suppress genuinely unavoidable mismatches with data-allow-mismatch; use it for the timestamp, not for the article body.

5. Teleported content that is not in the response. If your app teleports content, Vue notes that the teleported content will not be part of the rendered string unless you inject it deliberately. Fine for a modal. Not fine if a teleport is carrying anything you want indexed.

6. Custom directives, ignored. Most custom directives involve direct DOM manipulation and are skipped during SSR. If a directive is what adds an attribute you care about, implement getSSRProps so the attribute reaches the server output.

Verifying it actually worked

Vue SEO fixes are unusually easy to believe in without testing, because the application looks correct in the browser either way. Three checks, in this order:

The raw fetch. curl the route with no browser and read the response. Your H1, body copy, route-specific title, meta description and JSON-LD should all be there. This doubles as the AI-crawler check, since that is precisely what GPTBot receives.

The status-code sweep. Request a URL you know does not exist and read the status line, not the page:

curl -sI https://example.com/this-does-not-exist | head -1

A 200 here means the HTML5-mode fallback is manufacturing soft 404s. This is the check nobody runs, and on a Vue SPA it fails more often than not.

Google’s URL Inspection tool. Test the live URL in Search Console and open View Tested Page. That is Googlebot’s own renderer and the only ground truth for what Google sees after rendering. Anything present in your browser’s DOM but absent here is arriving too late.

Run all three after any change to rendering configuration. The failure mode of Vue SSR is silent — the app keeps working perfectly in the browser whether or not the server is producing useful HTML, and a route that quietly fell back to client rendering during a refactor will not announce itself. If pages are being fetched but not indexed after these changes, the diagnosis path continues in crawled – currently not indexed.

Where Vue SEO fits

This is one spoke of a series. The general mechanics — Google’s two-wave rendering, why AI crawlers behave differently, and the failure modes shared by every JavaScript framework — are in JavaScript SEO. The sibling instalments take the same decisions into other stacks: React SEO, Next.js SEO, Nuxt SEO for the Vue meta-framework, Angular SEO, and Astro SEO for the zero-JS end of the spectrum. Above them sits the SEO for engineers pillar.

If you would rather have the rendering strategy designed and shipped than retrofitted under deadline, that is part of what I do: GEO and technical SEO consulting covers the crawl and citation layers, and full-stack development covers building the decision into the codebase.

FAQ

Is Vue.js bad for SEO?

No, but Vue’s default output is. Vue’s documentation describes it as a framework for building client-side applications whose components produce DOM in the browser by default, and notes that while Google and Bing index synchronous JavaScript applications adequately, a crawler “will not wait for you to finish” when content is fetched asynchronously after a loading state. Since almost every real Vue app fetches asynchronously, the practical answer is that a Vue app nobody configured for search is bad for SEO, and the fix is a rendering-mode decision rather than a migration away from Vue.

Does Vue need SSR for SEO?

Often not. Vue’s own guidance is that if you are only investigating SSR to improve the SEO of a handful of marketing pages, you probably want static site generation instead — it produces the same crawler-visible HTML, deploys to any static host, and needs no Node server. Reserve SSR for routes whose content genuinely varies per request, such as search results or live pricing.

Is hash mode bad for SEO in vue-router?

Yes, and vue-router says so in its own documentation: of createWebHashHistory() it states, “It does however have a bad impact in SEO,” and documents HTML5 mode as the recommended mode. The reason is that the fragment is never sent to the server, so every hash route collapses to one URL — and Google’s URL guidance confirms it generally does not support URL fragments to change page content. If the application is public-facing, switch to HTML5 history mode before spending time on anything else.

Why do all my Vue pages show the same title in Google?

Because the per-route titles are being set in the browser after the crawler has read the response. The static <title> in index.html is what a crawler receives for every route unless that route is prerendered or server-rendered. Your head-management library is not at fault — it is running at the right moment for a user and the wrong moment for a crawler. Move the affected routes to SSG or SSR and the titles you already have will start appearing in the HTML.

Why does my Vue SPA return 200 for pages that do not exist?

Because HTML5 history mode needs a catch-all server rule that serves index.html for anything unmatched, and vue-router warns about the consequence: “Your server will no longer report 404 errors as all not-found paths now serve up your index.html file.” Adding a NotFoundComponent route fixes what the human sees but not the status code, which the server already decided. On Node, match the URL with the router server-side and return a real 404; on a static host, configure a hosting-level 404 for paths the build did not generate.

Will Googlebot render my Vue app anyway?

Usually, eventually. Googlebot runs an evergreen Chromium and renders JavaScript on a second pass, so a client-rendered Vue route can be indexed late or partially. Two things make relying on that a poor plan: rendering is queued rather than immediate, and AI crawlers such as GPTBot, ClaudeBot and PerplexityBot do not render at all — so a client-rendered route is permanently invisible to them, not merely delayed.

Do hydration mismatches hurt SEO?

Not directly — Google indexes the server HTML, and Vue recovers from mismatches automatically. The indirect cost is real, though: recovery discards incorrect nodes and mounts new ones, which is rendering work in the browser at exactly the moment your Core Web Vitals are being sampled. Treat mismatches as a performance defect with a search consequence, fix the documented causes — invalid HTML nesting, random values, timezone differences — and reserve data-allow-mismatch for the cases that are genuinely unavoidable.