Skip to content

SEOPart of: JavaScript SEO

Nuxt SEO and Vue SEO: rendering, meta tags and sitemaps

Nuxt SEO starts from a good default, because Nuxt renders on the server out of the box; plain Vue renders in the browser. How to keep content in the server HTML, set meta tags with useSeoMeta, mix render modes with routeRules, and add a sitemap and robots.txt.

getReport teamUpdated 26 Sept 20268 min read

Nuxt SEO starts from a good default: Nuxt renders every page on the server unless you turn that off, so crawlers receive complete HTML. The work is keeping it that way (fetching content with useFetch or useAsyncData, not in the browser), setting per-page tags with useSeoMeta, choosing render modes per route with routeRules, and adding a sitemap, robots.txt, canonicals and real 404 responses. A plain Vue app is different: it renders in the browser, and the usual fix is to move its public pages to Nuxt or prerender them. This guide covers both, for Nuxt 3 and 4. It is part of our wider guide to JavaScript SEO for framework-built sites.

Quick answer

  • Plain Vue (createApp().mount()) renders in the browser. Crawlers that do not run JavaScript see an empty <div id="app">.
  • Nuxt renders on the server by default (ssr: true). Do not switch it off globally.
  • Fetch indexable data with useFetch or useAsyncData, which run on the server, not in onMounted.
  • Set tags with useSeoMeta (title, description, Open Graph) and useHead (canonical, lang).
  • Mix render modes with routeRules: prerender: true for static pages, swr or isr for large catalogues, ssr: false only for areas behind a login.
  • Throw createError({ status: 404 }) for missing records so the server answers 404.
  • Check a page with the free JavaScript SEO check: the raw HTML and rendered page should match.

Vue without Nuxt

A Vue app created with npm create vue@latest and mounted with createApp(App).mount('#app') is a client-side single-page application. The server sends:

HTML
<body>
  <div id="app"></div>
  <script type="module" src="/assets/index-a1b2c3d4.js"></script>
</body>

Google renders it later, in a separate step, and most AI crawlers and link previews never do. Head tags set with document.title or a head library exist only after the scripts run. The requirements for making any SPA indexable (History API routing with createWebHistory, not createWebHashHistory; real links with <RouterLink>; per-route tags; honest status codes) are in our guide to SEO in single page applications.

For public pages, you have three options:

  1. Move them to Nuxt, which uses the same Vue components with server rendering and file-based routing.
  2. Prerender them at build time with a static generation plugin for Vite, so each route becomes its own HTML file.
  3. Server-render with Vue's own SSR API (createSSRApp and renderToString from vue/server-renderer), which Vue's documentation describes but which means building the server, routing and data loading yourself.

For most teams, option 1 is the least work. A dashboard behind a login can stay a client-side Vue app.

How Nuxt renders pages

Nuxt calls its default mode universal rendering: the server runs the Vue app, produces HTML, and the browser hydrates it. With ssr: true (the default), a Nuxt page reaches crawlers with its content, links and head tags in place.

Three things move content back into the browser:

  • ssr: false in nuxt.config.ts, which turns the whole site into a client-side SPA.
  • Data fetched in onMounted, or with useFetch(url, { server: false }), which skips the server fetch.
  • <ClientOnly> around main content. It is meant for widgets that cannot render on the server, such as a map or a chart.

useFetch and useAsyncData fetch on the server during rendering and pass the result to the browser in the payload, so the page does not fetch the same data twice during hydration:

vue
<script setup lang="ts">
const route = useRoute();
const { data: product } = await useFetch(`/api/products/${route.params.slug}`);

if (!product.value) {
  throw createError({ status: 404, statusText: 'Product not found', fatal: true });
}
</script>

<template>
  <main>
    <h1>{{ product.name }}</h1>
    <p>{{ product.description }}</p>
  </main>
</template>

Meta tags with useSeoMeta and useHead

Nuxt's head management is powered by Unhead. Use app.head in nuxt.config.ts for site-wide defaults that never change, such as the lang attribute and favicon:

TypeScript
// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      htmlAttrs: { lang: 'en' },
      link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
    },
  },
});

Then set per-page tags with useSeoMeta, which Nuxt recommends for meta tags because it is typed and XSS-safe. For reactive values, pass getter functions:

vue
<script setup lang="ts">
const { data: product } = await useFetch(`/api/products/${useRoute().params.slug}`);

useSeoMeta({
  title: () => `${product.value?.name} | Example Shop`,
  description: () => product.value?.summary,
  ogTitle: () => product.value?.name,
  ogImage: () => product.value?.image,
  twitterCard: 'summary_large_image',
});

useHead({
  link: [
    { rel: 'canonical', href: () => `https://example-shop.hr/products/${product.value?.slug}/` },
  ],
});
</script>

With server rendering on, all of these tags are in the HTML the server sends. A titleTemplate in useHead in app.vue adds the brand suffix on every page. Keep noindex decisions on the server as well: useSeoMeta({ robots: 'noindex, follow' }) on the page itself, never a tag toggled after load.

Render modes per route with routeRules

Nuxt's hybrid rendering lets each part of a site use a different mode, set in nuxt.config.ts:

TypeScript
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { prerender: true },
    '/products/**': { swr: 3600 },
    '/account/**': { ssr: false },
    '/old-sale': { redirect: { to: '/products/', statusCode: 301 } },
  },
});
RuleWhat it doesUse it for
prerender: trueRenders the route to HTML at build timeHome, landing pages, blog posts
swr: 3600Renders on demand, caches, revalidates in the backgroundProduct and category pages
isr: true / isr: 3600Like swr, cached on the hosting CDN where supportedLarge catalogues on platforms that support it
ssr: falseRenders in the browser onlyAccount areas, dashboards
redirectServer-side redirectMoved sections

nuxi generate prerenders the whole site for static hosting, following links from the home page. Our comparison of client-side and server-side rendering explains the trade-offs behind each mode.

Status codes and redirects

Missing pages. throw createError({ status: 404, fatal: true }) during server rendering shows Nuxt's error page (error.vue) and sends 404. On older Nuxt 3 versions the property is statusCode. Check for the record right after fetching it, as in the example above.

Redirects. navigateTo('/new-path', { redirectCode: 301 }) in a page or route middleware sends a real 301 when it runs on the server; the default is 302. For whole sections, use a redirect rule in routeRules. Our 301 redirects guide covers when to use which code.

Sitemap, robots.txt and structured data

Nuxt does not generate a sitemap by default. You can serve one from a server route in server/routes/sitemap.xml.ts, or use a module. The community Nuxt SEO module (@nuxtjs/seo) bundles modules for a sitemap, robots.txt, Schema.org structured data, Open Graph images and link checking, all configured from one site URL. Each is also available on its own, such as @nuxtjs/sitemap and @nuxtjs/robots.

Whatever you use, check the output on production: the sitemap should list only canonical, indexable URLs that answer 200, and robots.txt must not block /_nuxt/, where the scripts and styles Google needs to render the page live. The XML sitemap guide covers what belongs in it.

For structured data, render JSON-LD on the server with useHead({ script: [{ type: 'application/ld+json', innerHTML: JSON.stringify(data) }] }) or the Schema.org module, so it is in the raw HTML.

How to check a Nuxt or Vue site

The check fetches the page as raw HTML, then renders it in Chromium, and compares title, description, canonical, robots, headings, links, word count and JSON-LD. On a server-rendered Nuxt page both columns should match. A plain Vue app shows an empty raw column; a Nuxt page with client-side fetching shows the layout but not the main text. The full report runs the same comparison:

Also request a missing URL with curl -I and confirm it answers 404, and look at /sitemap.xml and /robots.txt on the live site. In Google Search Console, URL Inspection → Test live URL shows the HTML Google rendered.

Common mistakes

  • ssr: false for the whole site to silence hydration warnings.
  • Fetching page content in onMounted or with server: false.
  • Setting the canonical once in app.vue, which gives every page the home page's canonical.
  • Hash history in a plain Vue app (createWebHashHistory) for public pages.
  • Error pages that answer 200 because the missing record was handled with a v-if instead of createError.
  • Blocking /_nuxt/ in robots.txt, which stops Google from rendering the page.

Questions people ask

Is Nuxt good for SEO?

Yes. Nuxt renders pages on the server by default, so crawlers get complete HTML with content, links and meta tags, and routeRules let you prerender static pages or cache dynamic ones. SEO problems in Nuxt sites come from switching server rendering off, fetching content in the browser, or setting one canonical for every page, not from the framework itself.

Is Vue.js bad for SEO?

Not by itself, but a plain Vue app renders in the browser, which delays indexing in Google and hides content from AI crawlers and link previews. For public pages, use Nuxt, prerender each route at build time, or server-render with Vue's SSR API. Keep client-side Vue for parts of the site behind a login, where indexing does not matter.

What is the difference between useHead and useSeoMeta in Nuxt?

useSeoMeta sets meta tags from a flat, typed object (title, description, ogImage, twitterCard) and is Nuxt's recommended way to add them. useHead manages everything else in the head: link tags such as the canonical, scripts such as JSON-LD, and attributes on html and body. Both render on the server when server rendering is on, and many pages use both.

Check your site before and after Check