Technical SEO

Nuxt SEO: Rendering Modes, Route Rules and the Head Traps That Bite in Production

· · 11 min read

What is Nuxt SEO?

Nuxt SEO is technical SEO practised against Nuxt’s own defaults and APIs: shipping server-rendered HTML by default, choosing a rendering mode per route with route rules rather than one mode for the whole app, managing head tags through useSeoMeta and Unhead, and knowing which build command emits which files. Nuxt uses universal rendering by default, so the crawlable stage arrives almost for free — the work is not accidentally giving it away.

This is the Nuxt instalment of the SEO for engineers series. The pillar walks one arc — crawlable → fast → understandable → citable — and each stage gates the next. Most of that guide is framework-agnostic. This one is not: it is what the arc looks like when the stack is Nuxt, written from the seat of someone editing nuxt.config.ts.

Most published Nuxt SEO material stops at installing a module and calling useSeoMeta. That is the easy half. The half that costs you is the rendering decision — which routes are prerendered, which are cached at the CDN, which are client-only — plus a short list of documented behaviours that are easy to miss and expensive to discover in production.

Written against Nuxt v4.5.2. These behaviours change between majors, so check the version you are on.

TL;DR — Key takeaways

  • Nuxt server-renders by default. The common failure is turning that off for a route that needed it, not failing to turn it on.
  • Rendering mode is a per-route decision in Nuxt, made with route rules — not one global setting.
  • isr caches at the CDN; swr caches at the server or reverse proxy. They are not interchangeable.
  • definePageMeta is a build-time macro, so it cannot carry a dynamic title. This is the single most common “why won’t my title update” bug.
  • nuxt generate emits 200.html and 404.html; a plain nuxt build does not. If your host is serving a blank 404, this is why.
  • Hybrid rendering does not work under nuxt generate at all.
  • None of this decides your canonicals, your sitemap completeness or your schema. Nuxt solves delivery.

Rendering mode is an SEO decision, and Nuxt makes it per route

Universal rendering means Nuxt runs your Vue code on the server, returns complete HTML, then re-runs the same code in the browser to attach interactivity — hydration. The crawler gets a finished document on the first response. That is the whole SEO argument for it, and it is a good one.

The opposite posture is client-side rendering, and Nuxt’s own documentation is blunt about the cost: search engine crawlers will not wait for the interface to be fully rendered on their first try to index the page. Not “may not” — will not. That is the framework authors describing their own escape hatch, and it is worth more than any third-party claim about JavaScript indexing, because they have no incentive to overstate it. The general version of this problem is in JavaScript SEO; this is Nuxt’s specific statement of it.

What makes Nuxt genuinely different from a single-mode framework is that you do not have to choose once. Route rules let you set behaviour per URL pattern, and the vocabulary is small enough to memorise: prerender, isr, swr, ssr, redirect, headers, cors, noScripts and appMiddleware.

Two of those are routinely confused, and the difference is operational:

For SEO the practical difference is where the crawler’s request terminates. An isr route can be answered from an edge cache without touching your origin at all; an swr route still reaches your infrastructure. If your origin is the flaky part of your stack, that distinction is the difference between a crawler getting HTML and a crawler getting a 5xx.

One more detail worth knowing before you reach for ssr: false as an optimisation: a route covered by it is excluded from the server bundle, but Nuxt states plainly that this is a build-time optimisation only and does not change what the server sends to the browser. It makes your build smaller. It does not make the page faster for a user or a crawler.

Head management: useSeoMeta and the traps around it

Nuxt’s head layer is powered by Unhead, and there are three places to write tags — nuxt.config, composables, and components. They are not equivalent.

app.head in nuxt.config is static. The docs say it does not allow you to provide reactive data and recommend useHead() in app.vue when you need reactivity. Use nuxt.config for the things that never change: default title, lang, favicon.

useSeoMeta is the one to reach for. It takes a flat, typed object, and the docs are explicit that this helps you avoid typos and common mistakes, such as using name instead of property. That specific mistake is worth dwelling on: og:title declared with name instead of property is invalid Open Graph, renders no error, and fails silently in every scraper. A typed API that makes it unrepresentable is doing real work.

Then the traps.

definePageMeta cannot carry a dynamic title. It is extracted at build time via a macro, so it cannot be set dynamically. If you are setting a page title from fetched data through definePageMeta and wondering why it never updates, that is the answer — and it is the most common Nuxt head bug I see. Fetched data goes through useHead or useSeoMeta, not the macro.

A titleTemplate function cannot live in nuxt.config. The string form with %s works there. The function form cannot be set in your nuxt.config — Nuxt recommends app.vue instead, where it applies to every route.

The cdnURL favicon trap. If you serve assets from a CDN via app.cdnURL, note that a static app.head link such as href: '/favicon.ico' is a literal path and is not resolved against cdnURL. It will point at your origin while everything around it points at the CDN. Build the href from runtime config in app.vue instead. This one is documented and still catches people, because nothing fails loudly — the favicon just resolves somewhere you did not intend.

Once the head is right, the JSON-LD you emit alongside it is a separate job with separate failure modes, and worth treating as such.

What nuxt generate actually emits

This section exists because static Nuxt deployments fail in a specific, silent way.

nuxt generate and nuxt build --prerender write index.html, 200.html and 404.html into .output/public/. A plain nuxt build without prerender does not. Those two extra files are SPA fallbacks, and they do different jobs:

  • 200.html — serve this for unmatched paths when you want the client router to handle the URL.
  • 404.html — serve this when the host should keep a 404 status and still load your app.

Getting this wrong is a genuine SEO problem rather than a cosmetic one. Point your host at 200.html for everything and every missing URL returns HTTP 200 with an app shell. Google’s own crawling documentation is direct about both halves of why that hurts: an HTTP 2xx status code doesn’t guarantee indexing, and if the content suggests an error — an empty page or an error message — Search Console will show a soft 404. Serving a 200 shell for every missing path manufactures that condition across your entire URL space. The related state, where a URL is served happily but never makes it into the index, is covered in crawled, currently not indexed.

There is a second trap on top. By default 404.html is an empty shell, so your error.vue — and its layout, and its data — only appear once the client app has booted. A crawler that fetches the URL and reads the first response sees nothing. Nuxt offers experimental.prerenderErrorPages to render error.vue at build time instead.

That flag comes with a constraint the docs state clearly and which you must design around: because a single file is served for every missing path, the error page cannot depend on the request. useRoute() and useRequestURL() will hold build-time values in the prerendered HTML and only be corrected on hydration. Wrap request-specific markup in <ClientOnly> and skip request-specific fetching with import.meta.prerender.

And the one that invalidates whole architectures if you miss it: hybrid rendering is not available when using nuxt generate. If your plan is “prerender the marketing pages, ISR the blog, client-render the dashboard”, nuxt generate cannot deliver it. You need nuxt build with a server. Discovering this after the architecture is signed off is expensive.

A route-rule policy that holds up

Think per template, not per app. A typical SaaS marketing site plus product resolves to something like this:

export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/pricing': { prerender: true },
    '/blog': { isr: 3600 },
    '/blog/**': { isr: true },
    '/docs/**': { isr: true },
    '/app/**': { ssr: false },
    '/api/**': { cors: true },
  },
})

The reasoning behind each line, rather than the lines themselves, is the transferable part:

  • Prerender anything editorial and stable. Fastest possible response, no origin dependency, no rendering ambiguity. If a crawler can be given a static file, give it one.
  • isr for content that changes on an editorial cadence. You get static-equivalent delivery without a redeploy per edit. isr: true persists until the next deploy; isr: 3600 revalidates hourly.
  • ssr: false only for surfaces that must never rank. Authenticated dashboards, account settings, internal tools. Never for anything you want indexed — and note that this is also where you should have a spa-loading-template.html, because otherwise users get a blank frame while the bundle loads.

One side effect to know about: routes using isr or swr also generate _payload.json files alongside HTML, which client-side navigation loads instead of re-fetching. Useful, and occasionally surprising when you are auditing what a deploy actually shipped.

The same per-template discipline, applied to a headless CMS stack rather than to Nuxt’s own config, is the subject of technical SEO for headless architecture. And if you are weighing Nuxt against its closest neighbour, the Next.js instalment covers the same decisions in that framework’s vocabulary — the concepts map almost one to one, the APIs do not. At the other end of the spectrum, Astro starts from zero JavaScript rather than from hydration, which is a different set of trade-offs entirely.

Where Nuxt does not help you

Framework guides tend to end on a note implying the framework solved SEO. It solved delivery. That is genuinely most of the hard part in a JavaScript stack, and it is not the whole job.

Canonicals are still a decision. No route rule tells Nuxt which of two URLs is the canonical one. Parameterised routes, filter combinations and pagination all generate URL variants that need an explicit answer, and choosing between canonical and noindex is a judgement about intent, not configuration.

Sitemap completeness is not automatic. A module can generate a sitemap from your routes. Whether that sitemap matches the set of pages you actually want indexed — and excludes the ones you do not — is a thing to verify rather than assume, particularly once route rules mean different URLs are produced by different mechanisms.

A server-rendered page can still be thin. Perfect delivery of a page with nothing on it ranks exactly as well as it deserves to. Hydration cost is also real: shipping and executing the JavaScript that rehydrates the page is work the browser does after the HTML arrives, and it shows up in Core Web Vitals whether or not the initial response was fast.

A module is not a strategy. Installing something that emits meta tags gets you tags. It does not get you the rendering decision, the canonical policy, or anything to say.

Frequently asked questions

Is Nuxt good for SEO?

Yes, in the sense that its default posture is server-rendered HTML, which is the posture you want. Nuxt uses universal rendering by default, so a crawler gets a complete document on the first response without you configuring anything. The risk in a Nuxt project is not failing to enable server rendering — it is disabling it for a route that needed it.

Do I need the Nuxt SEO module?

Not for the fundamentals. Titles, meta descriptions, Open Graph and canonicals are all reachable through useSeoMeta and useHead, which are part of Nuxt itself. Modules save boilerplate on sitemaps and robots handling. Decide based on how much boilerplate you want to own, not on the assumption that a module is required.

Should I use nuxt generate or nuxt build?

Use nuxt build if you need hybrid rendering, because hybrid rendering is not available under nuxt generate. Use nuxt generate when the whole site is genuinely static and you want no server at all. Mixed requirements — some prerendered pages, some cached, some client-only — mean nuxt build.

Is ssr:false ever acceptable?

For surfaces that should never rank, yes: authenticated dashboards, admin tools, account pages. For anything you want indexed, no. Nuxt’s own documentation says crawlers will not wait for the interface to render on their first attempt at indexing.

Should I use isr or swr?

isr if you are on Netlify or Vercel and want the response cached at the CDN. swr if you want it cached at your server or reverse proxy. The SEO-relevant difference is whether a crawler’s request has to reach your origin.

Why is my page title not updating?

Most often because it is being set through definePageMeta, which is extracted at build time via a macro and cannot be dynamic. Move anything derived from fetched data into useHead or useSeoMeta.

Does Nuxt handle canonical tags for me?

No. Nuxt gives you a link array to put one in; deciding which URL is canonical is yours. This matters most on routes with query parameters, filters or pagination, where the framework has no way to know which variant you want indexed.