Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

Nuxt 3 SEO: The Complete Guide to Meta Tags, Sitemaps, and OG Images (2026)

Nuxt 3 SEO trips up even experienced Vue developers. Your pages render server-side, so you assume Google sees everything — then your links show up on X with no preview image, your blog posts are missing from the sitemap, and every page shares the same generic title. This guide fixes all of that with the modern Nuxt SEO stack: the @nuxtjs/seo module bundle, the useSeoMeta composable, automatic sitemaps, generated OG images, robots.txt, and Schema.org structured data. Every command and API here was checked against the official Nuxt and Nuxt SEO docs on 3 October 2026.

Why Nuxt 3 SEO still needs a manual setup

Nuxt 3 ships with server-side rendering, which means crawlers get fully rendered HTML instead of an empty div. That’s a head start — but SSR only guarantees that your content can be read. It doesn’t write your <title>, meta description, canonical URL, Open Graph tags, sitemap, or robots.txt for you. Skip those and you get a technically indexable site that ranks badly and looks broken whenever someone shares a link.

The ecosystem answer is Nuxt SEO, a set of official modules maintained alongside Nuxt. Instead of hand-rolling each piece, you install one bundle and configure one shared site config. Six modules, one source of truth.

Step 1: Install the @nuxtjs/seo module bundle

The @nuxtjs/seo package is a meta-package. One install command pulls in six modules at once: @nuxtjs/sitemap, nuxt-og-image, nuxt-schema-org, nuxt-seo-utils, nuxt-link-checker, and @nuxtjs/robots. They all read from the same site config, so your URL, site name, and description stay consistent everywhere.

npx nuxi module add @nuxtjs/seo

Now add your site details to nuxt.config.ts. This is the single most important step — without it, the modules can’t build absolute URLs for sitemaps, canonicals, or OG images:

export default defineNuxtConfig({
  modules: ['@nuxtjs/seo'],

  site: {
    url: 'https://example.com',        // required: production URL, no trailing slash
    name: 'My Site',                   // used for og:site_name and Schema.org
    description: 'A short description of my site.',
    defaultLocale: 'en',               // only needed if you use i18n
  },
})

Two things worth knowing:

  • site.name is required. In Nuxt SEO v5 it is no longer inferred from your package.json — set it explicitly or your site name will be blank in structured data.
  • Use an environment variable per environment. Set NUXT_PUBLIC_SITE_URL=https://staging.example.com on staging and the modules pick it up automatically, since site config is exposed under useRuntimeConfig().public.site. Never hardcode different URLs per environment.

Step 2: Set page meta tags with useSeoMeta

useSeoMeta is a Nuxt core composable — auto-imported, no setup needed — and it’s the recommended way to write meta tags. It takes a flat object with full TypeScript support for over 100 tags, so you can’t typo name where property belongs, and it’s XSS-safe by design.

<script setup lang="ts">
useSeoMeta({
  title: 'My Amazing Site',
  ogTitle: 'My Amazing Site',
  description: 'This is my amazing site, let me tell you all about it.',
  ogDescription: 'This is my amazing site, let me tell you all about it.',
  ogImage: 'https://example.com/image.png',
  twitterCard: 'summary_large_image',
})
</script>

For dynamic pages like a blog post, pass getters so the tags react to your data. This is the pattern you’ll use on almost every content page:

<script setup lang="ts">
const route = useRoute()
const { data: post } = await useAsyncData(`post-${route.params.slug}`, () =>
  $fetch(`/api/posts/${route.params.slug}`)
)

useSeoMeta({
  title: () => `${post.value.title} | My Blog`,
  ogTitle: () => `${post.value.title} | My Blog`,
  description: () => post.value.description,
  ogDescription: () => post.value.description,
  // crawlers only understand absolute URLs — build it from site.url
  ogImage: () => `https://example.com/images/${post.value.cover}`,
  twitterCard: 'summary_large_image',
})
</script>

One performance tip from the Nuxt docs: crawlers only read the initial HTML, so tags that never change don’t need to be reactive. Wrap static tags in a server-only block and they get skipped on the client entirely:

<script setup lang="ts">
if (import.meta.server) {
  // rendered during SSR only — zero client cost
  useSeoMeta({
    robots: 'index, follow',
    description: 'A static description that never changes',
  })
}
</script>

Rule of thumb: useSeoMeta() for every SEO-relevant tag. Reserve useHead() for non-meta elements like favicons, scripts, and link tags.

Step 3: How Nuxt 3 SEO handles sitemaps automatically

The moment you install @nuxtjs/sitemap (included in the bundle), your app serves a sitemap at /sitemap.xml, generated from your actual app routes. Static and prerendered pages are picked up with no extra work.

Dynamic routes — blog posts, products, user profiles — need a data source. You have two options:

  • Build-time URLs with the urls option for content that changes rarely:
export default defineNuxtConfig({
  sitemap: {
    urls: ['/about', '/contact', '/pricing'],
  },
})
  • Runtime sources with the sources array for content that must always be fresh. Each source is an endpoint returning a JSON array or an XML sitemap. The module caches the resolved sitemap in production (10 minutes by default), so your API isn’t hammered on every crawler request:
export default defineNuxtConfig({
  sitemap: {
    sources: ['/api/__sitemap__/urls'],
  },
})

Create that endpoint with the typed defineSitemapEventHandler() helper in server/api/__sitemap__/urls.ts:

export default defineSitemapEventHandler(async () => {
  const posts = await $fetch('/api/posts')
  return posts.map(p => ({
    loc: `/blog/${p.slug}`,
    lastmod: p.updatedAt,
  }))
})

If you have tens of thousands of URLs, enable chunking so the module splits them into multiple sitemap files linked from /sitemap_index.xml:

export default defineNuxtConfig({
  sitemap: {
    sitemaps: {
      posts: {
        sources: ['/api/__sitemap__/urls'],
        chunks: true, // default 1000 URLs per file
      },
    },
  },
})

Step 4: Dynamic OG images with nuxt-og-image

Social previews run on the og:image tag, and every page sharing the same static image is a missed click. nuxt-og-image generates a unique image per page at build time or on demand from a Vue component template — no headless browser or third-party service required.

The simplest setup uses the built-in NuxtSeo template. It’s a server-only composable:

<script setup lang="ts">
defineOgImage('NuxtSeo', { title: 'My Blog Post Title' })
</script>

This renders a 1200×600 image and sets the og:image meta tag to its absolute URL automatically. For full control, point it at your own template component living in components/OgImage/:

<script setup lang="ts">
const route = useRoute()
const { data: post } = await useAsyncData(`post-${route.params.slug}`, () =>
  $fetch(`/api/posts/${route.params.slug}`)
)

defineOgImage({
  component: 'BlogPost',
  props: {
    title: post.value.title,
    category: post.value.category,
  },
})
</script>

The module requires site.url to be set — og:image must be an absolute URL, and without the site config the module can’t build one. If you’d rather use a pre-made static image on some pages, fall back to useSeoMeta({ ogImage: 'https://example.com/my-image.png' }) instead.

Step 5: robots.txt and crawl control

@nuxtjs/robots generates /robots.txt for you. Out of the box it allows all routes in production — and critically, it blocks indexing in non-production environments, so your staging site never leaks into Google results.

It also manages the <meta name="robots"> tag and the X-Robots-Tag HTTP header, so crawl rules stay consistent between your HTML and your headers. Verify it the same way users of the bundle verify everything: visit /robots.txt on your deployed site. On Nuxt SEO v5 you also get JSON debug endpoints — /__robots__/debug-production.json and /__sitemap__/debug-production.json — that show exactly what each module resolved.

Step 6: Schema.org structured data

nuxt-schema-org registers WebSite and WebPage JSON-LD nodes on every page by default, built straight from your site config. You don’t write anything to get baseline structured data.

Override or extend it per page with useSchemaOrg. A blog post gets an Article node like this:

<script setup lang="ts">
useSchemaOrg([
  defineWebSite({ name: 'My Blog' }),
  defineWebPage(),
  defineArticle({
    datePublished: new Date('2026-10-03'),
    dateModified: new Date('2026-10-03'),
    // title, description and image are inferred from your head tags
  }),
])
</script>

Set your site’s identity once in app.vue with defineOrganization() or definePerson(), and every page inherits it. If you’d rather manage Schema.org entirely by hand, opt out of the defaults in your config:

export default defineNuxtConfig({
  schemaOrg: {
    defaults: false,
  },
})

Verify everything before you ship

Run through this checklist on your deployed (or locally served production build) site:

  • Open /robots.txt — it should allow crawling in production and block it in staging.
  • Open /sitemap.xml — every public page should be listed, with lastmod where available.
  • curl a page and check the raw HTML (not DevTools — crawlers see the SSR output). You should find <title>, og:title, og:description, an absolute og:image URL, a canonical link, and a application/ld+json script block.
  • Paste a URL into X’s or LinkedIn’s link preview debugger to confirm the OG image renders.
  • Run a route with a typo’d slug and confirm the 404 page doesn’t emit indexable meta.

Common Nuxt 3 SEO mistakes to avoid

  • Relative OG image URLs. ogImage: '/og.png' breaks every social preview. Always absolute — this is the single most common bug.
  • Forgetting site.url. Without it, sitemap entries, canonicals, and OG images all resolve wrong. Set it first, before anything else.
  • The same description on every page. A duplicated meta description is worse than none — write a unique one per route, or generate it from your content data.
  • Client-only meta on data that crawlers need. Tags set after hydration never reach crawlers. If it matters for SEO, make sure it renders in the SSR HTML.
  • Hand-writing what the modules automate. Once @nuxtjs/seo is installed, don’t also hand-build sitemap routes or robots middleware — the modules handle those from your config, and duplicates cause conflicts.

Further Reading & References

All commands, composables, and module behaviour in this post were verified against the official Nuxt and Nuxt SEO documentation on 3 October 2026.

Leave a Comment