Black Friday: 50% off all products for the first 50 customers. Only 10 left. Use code
40/50 claimed
See pricing →
November 7, 2026

Nuxt useSeoMeta, Sitemap and OG Images

Nuxt useSeoMeta setup for Nuxt 4: site-wide and per-page meta tags, canonical URLs, sitemap.xml and robots.txt server routes, and OG images that render.
Nuxt
SEO
Vue
Open Graph

Nuxt useSeoMeta is the composable that writes your page's meta tags: the description, the Open Graph tags that build link previews, and the Twitter card tags. It is one part of the SEO setup every Nuxt app needs. The others are a title template, a canonical URL on every page, a sitemap, a robots.txt and OG images that platforms can actually show. This post sets up all of them in a Nuxt 4 app, with the same patterns quasarui.com runs on.

Site-wide defaults: titleTemplate and app.vue

Start with the title. app.head in nuxt.config.ts sets static head tags for the whole app, including a default title and a template that every page title is placed into:

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      title: 'Acme',
      titleTemplate: '%s | Acme',
      htmlAttrs: { lang: 'en' },
    },
  },
})

%s is replaced by the page's title, so a page that sets title: 'Pricing' gets Pricing | Acme. The template can also be a function, but a function can't go in nuxt.config. Put it in useHead in app.vue instead.

The other defaults belong in app.vue, because app.head can't take reactive data and the defaults usually need the current URL:

<!-- app/app.vue -->
<script setup lang="ts">
const site = 'https://acme.dev'
const route = useRoute()
const canonical = computed(() => `${site}${route.path === '/' ? '' : route.path}`)

useSeoMeta({
  description: 'Acme tracks product events without a data team.',
  ogSiteName: 'Acme',
  ogType: 'website',
  ogUrl: canonical,
  ogImage: `${site}/og.jpg`,
  ogImageWidth: 1200,
  ogImageHeight: 630,
  ogImageAlt: 'The Acme dashboard',
  twitterCard: 'summary_large_image',
})

useHead({
  link: [{ rel: 'canonical', href: canonical }],
})
</script>

<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

Any page that sets one of these tags again replaces the default, so every page has a description and an image even if you forget one.

Nuxt useSeoMeta on each page

useSeoMeta takes a flat object of tags with TypeScript types for each key. The Nuxt docs recommend it for meta tags because it is XSS safe, and the types catch mistakes like writing name where Open Graph wants property. A typed key such as ogDescription writes the right attribute for you.

A static page:

<!-- app/pages/pricing.vue -->
<script setup lang="ts">
useSeoMeta({
  title: 'Pricing',
  description: 'Three plans, monthly or yearly. Every plan starts with a 14-day trial.',
  ogTitle: 'Acme pricing',
  ogDescription: 'Three plans, monthly or yearly. Every plan starts with a 14-day trial.',
})
</script>

A page whose tags depend on fetched data passes getters. The docs call this the computed getter syntax (() => value), and it keeps the tags in sync when the data changes:

<!-- app/pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)

useSeoMeta({
  title: () => post.value?.title,
  description: () => post.value?.summary,
  ogTitle: () => post.value?.title,
  ogDescription: () => post.value?.summary,
  ogImage: () => post.value?.coverUrl,
  ogType: 'article',
})
</script>

Search engine crawlers mostly read the first server render, so most tags don't need to be reactive at all. The docs suggest wrapping static tags in if (import.meta.server) { ... } so they are only added during server rendering. The older useServerSeoMeta composable did the same thing and is now deprecated in favor of that condition.

Two rules for the values:

  • Titles: keep them under about 60 characters including the template suffix, and put the words people search for first.
  • Descriptions: one or two sentences that say what the page answers. Search engines may rewrite them, so write one that matches what people search for.

Canonical URLs

A canonical link tells search engines which URL is the real one when the same page is reachable in several ways: with and without a trailing slash, with tracking parameters like ?utm_source=..., or on both www and the bare domain.

The app.vue above builds it from route.path, which has no query string, so ?utm_source=x never leaks into the canonical. Two details to get right:

  • Use one origin. Build the URL from configuration, not from the request's host header. A request on a preview domain would otherwise declare the preview as canonical. Put the origin in runtimeConfig and read it with useRuntimeConfig(), so each environment sets its own.
  • Match your redirects. If the bare domain redirects to www, the canonical should use www too, or the canonical URL points at a redirect.

A sitemap and robots.txt from server routes

Files in server/routes/ become routes without the /api prefix, so server/routes/sitemap.xml.ts answers /sitemap.xml. (The Nuxt 4 folder structure guide covers where these folders live.) A sitemap is a list of URLs, which a short handler can build:

// server/routes/sitemap.xml.ts
import { queryCollection } from '@nuxt/content/server'

const STATIC_PATHS = ['/', '/pricing', '/blog', '/docs']

export default defineEventHandler(async (event) => {
  const origin = useRuntimeConfig(event).public.siteUrl
  const posts = await queryCollection(event, 'blog').select('path').all()

  const urls = [...STATIC_PATHS, ...posts.map(post => post.path)]
    .map(path => `  <url><loc>${origin}${path === '/' ? '' : path}</loc></url>`)
    .join('\n')

  setHeader(event, 'content-type', 'application/xml; charset=utf-8')
  return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`
})

This version reads blog posts from Nuxt Content. Replace that query with your own data source, such as a database call or a list of products. Leave out pages that are noindex: sign-in, dashboards, checkout.

robots.txt is a route of its own and points crawlers at the sitemap:

// server/routes/robots.txt.ts
export default defineEventHandler((event) => {
  const origin = useRuntimeConfig(event).public.siteUrl
  setHeader(event, 'content-type', 'text/plain; charset=utf-8')
  return `User-agent: *\nDisallow:\n\nSitemap: ${origin}/sitemap.xml\n`
})

Both read siteUrl from public runtime config, declared as runtimeConfig: { public: { siteUrl: '' } } in nuxt.config.ts and set per environment with NUXT_PUBLIC_SITE_URL. The Nuxt docs recommend passing event to useRuntimeConfig in server routes, so values from environment variables apply at runtime.

Or use a module

If you'd rather not keep the path list in sync by hand, the @nuxtjs/sitemap module discovers your app's pages and serves /sitemap.xml for you. It needs a site URL set, and dynamic routes (from a CMS or a database) still need a source you configure. A hand-written route is around 20 lines and has no dependency. The module saves work when you have many dynamic sources or need a sitemap index.

OG images that render

The og:image tag picks the picture in a link preview on X, LinkedIn, Slack, Discord and Facebook. Most broken previews come down to three mistakes:

  1. A relative URL. og:image must be absolute: https://acme.dev/og.jpg, not /og.jpg.
  2. The wrong format. Use PNG or JPEG. Link previews don't show SVG images, and some surfaces drop WebP. quasarui.com's site-wide image is a JPEG for that reason. Its blog covers are SVG, so posts keep the site-wide image rather than pointing og:image at a file that won't show.
  3. The wrong size. 1200×630 is the common size for large previews. Set ogImageWidth and ogImageHeight so platforms can lay out the card before they download the image.

Pair it with twitterCard: 'summary_large_image' for the large card on X. Without it, X falls back to the smaller summary card.

For one image per page, store a 1200×630 PNG next to each post and set ogImage from the post data, as in the [slug].vue example. If you have hundreds of pages, modules such as nuxt-og-image render the images from a Vue component at build or request time. That is more setup, so start with static files until the page count makes it painful.

Structured data with useHead

Search engines also read JSON-LD. useHead can add it as a script tag:

useHead(() => ({
  script: [{
    key: 'article-schema',
    type: 'application/ld+json',
    innerHTML: JSON.stringify({
      '@context': 'https://schema.org',
      '@type': 'Article',
      'headline': post.value?.title,
      'datePublished': post.value?.date,
      'author': { '@type': 'Person', 'name': post.value?.author },
    }),
  }],
}))

The key keeps a second call from adding a duplicate tag. Only stringify data you control: this is raw HTML in the head.

Check the result

View the page source (not the DevTools elements panel) to see what the server sent, since that's what crawlers read. Or fetch it from a terminal:

curl -s https://acme.dev/pricing | grep -E '<title>|og:|canonical'
curl -s https://acme.dev/sitemap.xml | head

Then paste a URL into each platform's preview or post inspector. They cache previews, so check before the first share.

Common questions about Nuxt SEO

useHead or useSeoMeta? useSeoMeta for meta tags, useHead for everything else: links like the canonical, scripts, htmlAttrs.

Do I need SSR for SEO in Nuxt? The tags have to be in the HTML a crawler receives. Server rendering or prerendering does that. A client-only page only gets its tags after JavaScript runs.

Can I set the title in definePageMeta? You can, but it is read at build time by a macro, so it can't be dynamic. Use useSeoMeta for titles that depend on data.

Why does my preview show an old image? Platforms cache previews. Use their inspector to refresh it, or change the image URL.

Next step

If you're starting an admin app rather than a marketing site, the 4 QuasarUI Templates are full multi-page Quasar apps (Rambo, Avocado, Minimal Dashboard and Data-Focused Dashboard) with dark mode, delivered as a private GitHub repo. Start from one and spend your time on the product instead of the layout.