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
useFetchoruseAsyncData, which run on the server, not inonMounted. - Set tags with
useSeoMeta(title, description, Open Graph) anduseHead(canonical,lang). - Mix render modes with
routeRules:prerender: truefor static pages,swrorisrfor large catalogues,ssr: falseonly for areas behind a login. - Throw
createError({ status: 404 })for missing records so the server answers404. - 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:
<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:
- Move them to Nuxt, which uses the same Vue components with server rendering and file-based routing.
- Prerender them at build time with a static generation plugin for Vite, so each route becomes its own HTML file.
- Server-render with Vue's own SSR API (
createSSRAppandrenderToStringfromvue/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: falseinnuxt.config.ts, which turns the whole site into a client-side SPA.- Data fetched in
onMounted, or withuseFetch(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:
<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:
// 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:
<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:
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 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: falsefor the whole site to silence hydration warnings.- Fetching page content in
onMountedor withserver: 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
200because the missing record was handled with av-ifinstead ofcreateError. - 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.