Vue.js SEO: When You Need Nuxt (and When You Don't)

Vue.js has no built-in opinion about SEO, which is exactly why the question keeps surfacing. A default Vue single-page app, whether scaffolded with Vite or the older Vue CLI, ships the browser an almost-empty <div id="app"></div> and builds the entire page client-side with JavaScript. Whether that matters depends on one question: does this specific route need to rank in organic search, or does it only need to work for a user who is already signed in? Nuxt exists to answer the first case well. A plain Vue SPA is frequently the right, and cheaper, answer to the second.
This is not a general explainer on how Google renders JavaScript — that mechanic is the same for every framework. What's specific to Vue is how the decision plays out in practice: which parts of a Vue app can stay a client-rendered SPA, how Nuxt's SSR, static generation and hybrid route rules change what gets sent to a crawler, how to manage head tags correctly with useHead and useSeoMeta, and the concrete ways a Nuxt SEO setup breaks once it's in production.
What a Crawler Actually Receives From a Plain Vue SPA
Point curl, or any crawler that doesn't execute JavaScript, at a Vue SPA's URL and the response is the build's index.html: a script tag, a stylesheet link, and a container div with nothing inside it. Googlebot is the exception that makes this workable at all — it queues the page for a second pass through a headless Chromium (the Web Rendering Service), executes the JavaScript, and indexes what that render produces. That second pass isn't instant or guaranteed the same day as the crawl; on a large or frequently changing site, the gap between "crawled" and "rendered" is where content depending on a slow API call, a client-side redirect, or a failing script simply never gets counted.
Bing's crawler renders JavaScript far less reliably, and most things that are not a general-purpose search engine — link-unfurling bots in Slack or X, a lot of SEO audit tools, RSS readers — do not render it at all. If a page's title, meta description, Open Graph tags, or canonical link are only ever written by a mounted() hook, those consumers see whatever static string sits in the built index.html, which is usually a placeholder like "Vite App" or nothing at all.
- The title tag in the browser tab updates, but the snippet Google eventually shows can lag behind by days if the render queue is backed up.
- A CORS failure or a slow third-party script during the headless render can leave the page indexed with a loading skeleton instead of real content.
- Content gated behind a click — a "load more" button, an accordion that fetches on expand — is invisible to a renderer that doesn't click anything.
The Question That Actually Decides This: Does the Route Need to Rank?
Most Vue apps are not one thing. A dashboard behind a login wall, an internal admin panel, a settings screen — nobody is searching for these, and no rendering strategy changes that. For that part of the app, a plain client-rendered SPA isn't a compromise; it's the correct architecture, and moving it to server rendering only adds infrastructure (a Node process or edge runtime to keep alive) for zero organic-search benefit.
The pages that need attention are the ones a stranger reaches by searching: a marketing homepage, a blog, docs, a pricing page, a directory of products or listings. Those need their real content — not a loading state — present in whatever HTML response a crawler's first request receives, because you don't control how fast, how often, or how completely the second, JavaScript-executing pass runs. That's the actual dividing line, and it usually runs through the middle of a single app: a public shell that must be crawlable, and an authenticated app that doesn't need to be.
This is also why "just add Nuxt" isn't always the answer. A handful of static marketing pages can be prerendered at build time with a Vite plugin that runs the page in a headless browser and writes the HTML to disk — no server, just static files. That covers a landing page or two, but stops covering the case once there's paginated content, per-user variation, or more than a few dozen routes to keep in sync by hand, which is where Nuxt's rendering modes earn their added complexity.
Nuxt's Rendering Modes, and the Route Rules That Mix Them
Nuxt 3 doesn't force an app-wide choice between server rendering and static generation — it lets you set the rendering strategy per route through routeRules in nuxt.config.ts, and the three modes map directly onto the SEO decision above.
Server-side rendering (the default, ssr: true) renders full HTML on every request through Nitro, Nuxt's server engine. It needs a running Node process or an edge/serverless runtime, and it's right for pages whose content genuinely changes per request — search results, anything personalized, anything reading request headers or cookies to change the output.
Static generation — nuxi generate, the successor to the older nuxt generate command — crawls the app's routes at build time and writes plain HTML files for each one. There's no server to run at all; the output is static files a CDN serves directly, which is both the fastest possible response and the least that can go wrong at request time. It's the natural fit for a blog, docs, or a marketing site whose content doesn't change between visitors — the tradeoff is that updates need a rebuild, unless layered with Nitro's incremental static regeneration.
Incremental static regeneration sits between the two: a route is served from a cached, prerendered copy, and Nitro regenerates it in the background after a set interval, on hosting that supports it (Vercel and Netlify's Nuxt integrations both do). The route rules that set this up read like a short list of per-path exceptions rather than one global switch:
- '/': { prerender: true } forces the homepage into the static build even if the rest of the app is server-rendered.
- '/blog/**': { isr: 3600 } serves a cached copy of every blog post and regenerates it at most once an hour.
- '/app/**': { ssr: false } turns the authenticated section back into a client-rendered SPA, because nothing behind login needs a crawler.
- '/api/**': { cors: true } is a reminder that route rules aren't only about HTML; the same mechanism configures server routes.
Head Tags: useHead vs. useSeoMeta
Both composables sit on top of Unhead, the library Nuxt uses to manage title, meta, and link tags reactively. useHead() is the low-level version: pass an object with title, meta, link, and script arrays, controlling every attribute yourself, including which convention — name versus property — a tag uses. useSeoMeta() is the higher-level helper for the common case: pass flat, typed properties such as title, description, ogTitle, ogImage, and twitterCard, and it writes each to the attribute search engines and social platforms expect, so there's no need to remember that Open Graph uses property while Twitter's cards use name. Nuxt recommends useSeoMeta() for anything that maps to a standard tag, and useHead() for what it doesn't cover — a canonical link, a JSON-LD script block, a set of hreflang alternates.
Both are reactive: pass a computed value or a ref instead of a plain string, and the tag updates on client-side navigation without a full reload, which matters because a Nuxt app still behaves like an SPA between route changes after the first load.
The mistake that actually breaks SEO is calling either composable at the wrong time. They need to run during the component's setup — at the top level of script setup, or inside a composable that itself runs during setup — because that's the phase Nitro captures when building the response. Gate the call behind onMounted(), and on a server-rendered or prerendered route the tag simply isn't in the HTML sent out; it only appears once the client re-executes the component, which is exactly the pass a non-JS crawler or link-preview bot never runs.
Two smaller but common issues round this out: a layout component and the page it wraps both declaring ogTitle will produce a duplicate tag unless both use the same dedupe key, since Unhead dedupes by tag identity, not by intent. And a canonical link is never set automatically — Nuxt has no default opinion on it, so a page reachable at two URLs (with and without a trailing slash, or with a tracking parameter attached) will self-compete unless useHead is given an explicit link entry with rel: 'canonical' on every route that needs one.
The Nuxt SEO Failures That Actually Show Up in Production
Most Nuxt indexing problems aren't rendering-mode mistakes at all — they're one of a handful of specific implementation errors that recur across otherwise well-built apps.
- ClientOnly wrapped around body content, not just a widget. It's a common fix for a hydration mismatch, but it removes that content from the HTML for any consumer that doesn't run the client-side re-render, including some of Google's own rendering passes under crawl-budget pressure. Reserve it for genuinely client-only elements, such as a chart library that needs window, never for text a page is trying to rank on.
- Data fetched with useFetch() or useAsyncData() inside a v-if or an event handler instead of at the top level of setup. Called correctly, these ship their result inside the initial HTML; called from the wrong place, they only fire client-side, so the server-rendered page contains a loading state instead of the content it should show.
- Dynamic routes never linked with a real anchor tag. Nuxt's static crawler for nuxi generate discovers new routes by following links out of pages it has already rendered. A route only reachable through a JS search box or a router.push() call with no anchor tag never gets discovered and never gets a static file — it has to be added explicitly to the prerender route list, or driven from a sitemap the generator can read.
- A redirect fired only from client-side logic. Route middleware runs on both server and client, but if the decision depends on something only available client-side, the server response is a 200 with the wrong content, and the client silently swaps the URL after — a crawler indexes the original page, because it never received an actual redirect status code.
- Hydration mismatches from non-deterministic render output — a timestamp, a random id, or a value that differs between server and client. Vue detects the mismatch and discards the server-rendered DOM to rebuild it client-side, so the HTML a crawler fetched and the HTML the page settles into after hydration can genuinely differ; the console warnings that flag this are often dismissed as harmless when they're pointing at a real SEO discrepancy.
Checking What Actually Got Indexed
None of the above is trustworthy from reading the source code alone; verify what a crawler actually receives. Fetch the URL directly with no JavaScript execution and no headless browser, and read the raw HTML. If a page believed to be server-rendered or prerendered comes back with an empty shell, a route rule or a misconfigured deploy target is serving it as client-only.
In Search Console, the URL Inspection tool's crawled-page view shows both the original HTML Google fetched and the rendered HTML after its JavaScript pass; comparing the two against the page's own view-source output in a browser will surface exactly where content is arriving late or not at all. A quicker check during development — disabling JavaScript in a spare browser profile and browsing the site's key pages by hand — catches the same problem before it ever ships.
Finally, for a statically generated site, check the build output, or the sitemap if it's generated from one, against the list of URLs meant to be published. A route can render correctly for every visitor who reaches it through the app's own navigation while never existing as a static file, simply because nothing during the build ever crawled a link to it.
Frequently asked questions
Does every Vue app need Nuxt for SEO?
No. A plain Vue SPA is fine for anything that doesn't need to rank — a dashboard behind a login, an internal tool, a settings screen. Nuxt earns its cost on the public, searchable parts of a site: marketing pages, blog posts, docs, product or listing pages.
What's the actual difference between useHead and useSeoMeta in Nuxt?
useHead() is the low-level composable: you pass raw title, meta, link, and script entries and control every attribute yourself. useSeoMeta() is a typed helper for the common tags — title, description, Open Graph, Twitter card — that writes each one to the correct underlying attribute for you, so it's the default choice unless you need something useSeoMeta doesn't cover, like a canonical link or JSON-LD.
Can a plain Vue SPA with no Nuxt still be indexed?
Yes, within limits. Google's rendering pass can index client-rendered content given enough crawl budget and time, and a handful of static routes can be prerendered at build time with a Vite plugin. It stops scaling once there are many routes, paginated content, or per-request variation, which is where Nuxt's SSR and route rules take over.
Does Nuxt static generation (nuxi generate) need a running server?
No. Its output is plain HTML files that any CDN can serve. A server or edge runtime is only needed for routes using SSR at request time or ISR's background regeneration; a fully static build has neither.
Why does a Nuxt page's title show blank or wrong in Google's rendered HTML?
Almost always because the tag was set client-side only, typically inside onMounted() or behind data that only resolves after a client-side fetch, so the server-rendered or prerendered HTML never contained it in the first place.