# 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.

Updated 2026-09-26 · Technical SEO · HTML version: https://getreport.app/guides/vue-nuxt-seo

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](https://getreport.app/guides/javascript-seo).

## 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](https://getreport.app/tools/js-rendering-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="https://getreport.app/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](https://getreport.app/guides/single-page-application-seo).

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:

```ts
// 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`:

```ts
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { prerender: true },
    '/products/**': { swr: 3600 },
    '/account/**': { ssr: false },
    '/old-sale': { redirect: { to: '/products/', statusCode: 301 } },
  },
});
```

| Rule | What it does | Use it for |
| --- | --- | --- |
| `prerender: true` | Renders the route to HTML at build time | Home, landing pages, blog posts |
| `swr: 3600` | Renders on demand, caches, revalidates in the background | Product and category pages |
| `isr: true` / `isr: 3600` | Like `swr`, cached on the hosting CDN where supported | Large catalogues on platforms that support it |
| `ssr: false` | Renders in the browser only | Account areas, dashboards |
| `redirect` | Server-side redirect | Moved 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](https://getreport.app/guides/ssr-vs-csr-seo) 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](https://getreport.app/guides/301-redirects) 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](https://getreport.app/guides/xml-sitemap) 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

> **Free tool:** [JavaScript rendering checker: raw vs rendered HTML](https://getreport.app/tools/js-rendering-check): Free JavaScript rendering checker: compare raw HTML with the rendered page and see which text, links, tags and structured data appear only after scripts run.

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:

> **Check: The visible text is present without JavaScript.** Google renders JavaScript later and with a budget, so text that only appears after scripts run can be indexed late or not at all. Other search engines and link previews may never see it.
>
> 1. Serve the main content in the HTML (server-side rendering or static generation) and use JavaScript only to enhance it.
> 2. Check the difference in the technical detail; menus and widgets are fine, headlines and body copy are not.

> **Check: JavaScript leaves the title, canonical and robots tags alone.** Search engines read the HTML first and the rendered page later, if at all. A canonical or noindex that JavaScript changes sends two different instructions, and Google may act on either.
>
> 1. Put the final title, description, canonical and robots tags in the HTML the server sends.
> 2. In a single-page app, set them on the server for each route (Next.js metadata, Nuxt useHead with SSR, Angular Universal).
> 3. Never add noindex with JavaScript to a page you want indexed; Google may drop it before the script runs.

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.
