Starter: 11-seo
πŸ“– Official Docs

SEO & Head Management

TL;DR

useHead gives full control over the document <head> β€” title, meta, links, scripts, htmlAttrs. useSeoMeta is a typed shortcut for SEO-specific meta tags (Open Graph, Twitter, description). Both are reactive, SSR-compatible, and automatically deduplicated. Title templates, canonical URLs, and JSON-LD structured data complete the SEO toolkit.

Mental Model

Head management is a merge tree. Each layer can add or override:

nuxt.config.ts (app.head)    β†’ global defaults
  └── app.vue (useHead)       β†’ app-wide settings
       └── layout (useHead)   β†’ layout-specific
            └── page (useHead/useSeoMeta)  β†’ page-specific
                 └── component (useHead)   β†’ component additions

Deduplication rules:

  • title β€” last one wins (only one title)
  • meta with same name or property β€” last one wins
  • link with same rel + href β€” deduplicated
  • script β€” accumulates (all scripts render) unless you add a key

This means a page can override the default title set in app.vue just by calling useHead({ title: 'My Page' }). No cleanup needed.

useHead

Full control over <head>:

<script setup lang="ts">
useHead({
  // Document title
  title: 'My Page Title',

  // Title template (set once, applies to all pages)
  titleTemplate: '%s | My Site',

  // Meta tags
  meta: [
    { name: 'description', content: 'Page description for search engines' },
    { property: 'og:title', content: 'Open Graph Title' },
    { property: 'og:image', content: 'https://mysite.com/og-image.png' },
    { name: 'robots', content: 'index, follow' },
  ],

  // Link tags
  link: [
    { rel: 'canonical', href: 'https://mysite.com/my-page' },
    { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
  ],

  // Scripts
  script: [
    { src: 'https://analytics.example.com/script.js', async: true },
    {
      type: 'application/ld+json',
      innerHTML: JSON.stringify({
        '@context': 'https://schema.org',
        '@type': 'WebPage',
        name: 'My Page',
      }),
    },
  ],

  // HTML attributes
  htmlAttrs: { lang: 'en', dir: 'ltr' },

  // Body attributes
  bodyAttrs: { class: 'dark-mode' },
})
</script>

Reactive values β€” use computed or refs for dynamic titles:

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

useHead({
  title: computed(() => post.value?.title ?? 'Loading...'),
  meta: [
    {
      property: 'og:title',
      content: computed(() => post.value?.title ?? ''),
    },
  ],
})
</script>

useSeoMeta

Typed shortcut that covers 90% of SEO meta needs with a flat object API:

<script setup lang="ts">
useSeoMeta({
  // Basic SEO
  title: 'My Amazing Post',
  description: 'A comprehensive guide to Nuxt 4 data fetching patterns',

  // Open Graph (Facebook, LinkedIn, etc.)
  ogTitle: 'My Amazing Post',
  ogDescription: 'A comprehensive guide to Nuxt 4 data fetching patterns',
  ogImage: 'https://mysite.com/images/post-cover.png',
  ogUrl: 'https://mysite.com/posts/my-amazing-post',
  ogType: 'article',
  ogSiteName: 'My Site',

  // Twitter Card
  twitterCard: 'summary_large_image',
  twitterTitle: 'My Amazing Post',
  twitterDescription: 'A comprehensive guide to Nuxt 4 data fetching patterns',
  twitterImage: 'https://mysite.com/images/post-cover.png',
  twitterSite: '@mysite',

  // Robots
  robots: 'index, follow',
})
</script>

Why useSeoMeta over useHead for meta tags?

  • Fully typed β€” autocomplete for all standard meta properties
  • No meta: [{ property: ..., content: ... }] boilerplate
  • Less error-prone (no typos in property names)
  • XSS-safe β€” values are automatically escaped

Limitation: useSeoMeta only handles <meta> tags. For <link> (canonical), <script> (JSON-LD), or htmlAttrs, still use useHead.

Title Templates

Set once, applies to every page:

<!-- app.vue -->
<script setup lang="ts">
useHead({
  titleTemplate: '%s | NuxtLearn',
  // or as a function for more control:
  titleTemplate: (title) => {
    return title ? `${title} | NuxtLearn` : 'NuxtLearn'
  },
})
</script>

Pages set their title, the template wraps it:

<!-- pages/about.vue β€” renders: "About Us | NuxtLearn" -->
<script setup lang="ts">
useHead({ title: 'About Us' })
</script>

To show ONLY the template (no page title): set title to empty string or use the function form to handle the null case.

Override the template for a specific page:

<!-- pages/index.vue β€” renders: "NuxtLearn - The Dense Syllabus" (no template) -->
<script setup lang="ts">
useHead({
  title: 'NuxtLearn - The Dense Syllabus',
  titleTemplate: '',  // Disable template for this page
})
</script>

Canonical URLs

Every page should have a canonical URL to prevent duplicate content issues:

<!-- composables/useCanonical.ts -->
export function useCanonical() {
  const route = useRoute()
  const config = useRuntimeConfig()

  useHead({
    link: [
      {
        rel: 'canonical',
        href: computed(() => `${config.public.siteUrl}${route.path}`),
      },
    ],
  })
}
<!-- pages/[...slug].vue -->
<script setup lang="ts">
useCanonical() // Sets canonical based on current path
</script>

Handling trailing slashes: Be consistent. If your site uses /about/ (with trailing slash), the canonical must match. Nuxt doesn’t normalize by default β€” configure this in your router options or use a middleware to enforce consistency.

Open Graph Images

For static OG images, just point to the URL:

<script setup lang="ts">
useSeoMeta({
  ogImage: 'https://mysite.com/images/default-og.png',
})
</script>

For dynamic OG images (generated per page), use the nuxt-og-image module:

npx nuxi module add nuxt-og-image
<!-- pages/posts/[slug].vue -->
<script setup lang="ts">
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)

defineOgImage({
  component: 'BlogPost',  // ~/components/OgImage/BlogPost.vue
  title: post.value?.title,
  description: post.value?.excerpt,
})
</script>

Create the OG image template component:

<!-- components/OgImage/BlogPost.vue -->
<template>
  <div class="og-image">
    <h1>{{ title }}</h1>
    <p>{{ description }}</p>
    <span>mysite.com</span>
  </div>
</template>

<script setup lang="ts">
defineProps<{ title: string; description: string }>()
</script>

The module renders this component to an image at /api/__og_image__/... and injects the correct og:image meta tag.

Structured Data (JSON-LD)

Search engines use JSON-LD for rich snippets (recipes, articles, products, FAQs):

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

useHead({
  script: [
    {
      type: 'application/ld+json',
      innerHTML: computed(() => JSON.stringify({
        '@context': 'https://schema.org',
        '@type': 'Article',
        headline: post.value?.title,
        description: post.value?.excerpt,
        image: post.value?.coverImage,
        datePublished: post.value?.publishedAt,
        dateModified: post.value?.updatedAt,
        author: {
          '@type': 'Person',
          name: post.value?.author.name,
        },
        publisher: {
          '@type': 'Organization',
          name: 'My Site',
          logo: { '@type': 'ImageObject', url: 'https://mysite.com/logo.png' },
        },
      })),
    },
  ],
})
</script>

Product page example:

useHead({
  script: [{
    type: 'application/ld+json',
    innerHTML: JSON.stringify({
      '@context': 'https://schema.org',
      '@type': 'Product',
      name: product.value.name,
      image: product.value.images,
      description: product.value.description,
      offers: {
        '@type': 'Offer',
        price: product.value.price,
        priceCurrency: 'USD',
        availability: 'https://schema.org/InStock',
      },
    }),
  }],
})

Validate with Google’s Rich Results Test.

Sitemap

Install the sitemap module:

npx nuxi module add @nuxtjs/sitemap
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/sitemap'],
  site: {
    url: 'https://mysite.com',
  },
  sitemap: {
    // Dynamic routes from API
    sources: ['/api/__sitemap__/urls'],
  },
})
// server/api/__sitemap__/urls.ts
export default defineSitemapEventHandler(async () => {
  const posts = await $fetch('/api/posts')
  return posts.map(post => ({
    loc: `/posts/${post.slug}`,
    lastmod: post.updatedAt,
    changefreq: 'weekly',
    priority: 0.8,
  }))
})

The module auto-generates /sitemap.xml at runtime. Prerendered routes are included automatically.

robots.txt

// nuxt.config.ts (with @nuxtjs/robots or manual)
export default defineNuxtConfig({
  routeRules: {
    '/admin/**': { robots: 'noindex, nofollow' },
    '/api/**': { robots: false },  // No robots meta
  },
})

Or create public/robots.txt manually:

User-agent: *
Disallow: /admin/
Disallow: /api/
Sitemap: https://mysite.com/sitemap.xml

Walkthrough

  1. Set global title template in app.vue:
    <script setup>
    useHead({ titleTemplate: '%s | My Site' })
    </script>
    
  2. Add per-page SEO:
    <!-- pages/about.vue -->
    <script setup>
    useSeoMeta({
      title: 'About Us',
      description: 'Learn about our team and mission',
      ogTitle: 'About Us | My Site',
      ogImage: '/images/about-og.png',
    })
    </script>
    
  3. Add canonical URL composable. Create composables/useCanonical.ts and call from pages.

  4. Add JSON-LD structured data to a blog post page.

  5. Install and configure @nuxtjs/sitemap. Visit /sitemap.xml to verify.

Gotchas

  • useHead in a component runs on every render. Use computed() for dynamic values β€” don’t put API calls inside useHead options.
  • useSeoMeta doesn’t support <link> tags. Canonical URLs, favicons, and preload links need useHead.
  • OG images must be absolute URLs. Social crawlers (Facebook, Twitter) cannot resolve relative paths. Always include the full https://... URL.
  • Title template applies AFTER the page title. If a page sets title: '' (empty), only the template content shows. If no title is set, the template receives null.
  • robots meta vs robots.txt are different. Meta controls per-page indexing. robots.txt controls crawl access. You often need both.
  • Duplicate meta tags from nested components. If a child component sets og:title and the parent also does, both render. Use key on meta to force deduplication: { property: 'og:title', content: '...', key: 'og:title' }.
  • SSR matters for SEO. Meta tags set via useSeoMeta/useHead ARE in the initial HTML (SSR). But if you set them in a ClientOnly component or with lazy data, crawlers may not see them.
  • JSON-LD innerHTML with reactive data. Wrap in computed() or the script tag won’t update when data arrives. Without computed, it renders with null values on first pass.

Exercise

Build a blog with complete SEO:

  1. Global setup β€” title template %s | My Blog, default OG image, site description
  2. Blog list page (/posts) β€” title β€œBlog”, description, canonical
  3. Blog detail page (/posts/[slug]) β€” dynamic title from post data, OG tags (title, description, image, type: article), Twitter card, JSON-LD Article schema, canonical URL
  4. Sitemap β€” install @nuxtjs/sitemap, configure with dynamic routes from API
  5. Verify β€” view page source for each page. Check that:
    • Title follows template
    • OG tags have absolute URLs
    • JSON-LD is valid (paste into Google’s validator)
    • Sitemap XML lists all posts

Further Reading


nuxtlearn β€” Nuxt 4.4.8 syllabus - stradivary

This site uses Just the Docs, a documentation theme for Jekyll.