4 min read
On this page

Error Handling & SEO

Nuxt 3 provides built-in mechanisms for handling errors gracefully and managing per-page SEO metadata. The error system catches both client-side and server-side failures and renders a customizable error page. The head management system lets each page declare its own title, meta tags, and Open Graph data, which is critical for search engine visibility and social media sharing.

Error Pages

When an unhandled error occurs -- a 404 from a missing page, a 500 from a failed API call, or a manually thrown error -- Nuxt renders error.vue from the project root.

<!-- error.vue (project root, NOT in pages/) -->
<script setup lang="ts">
import type { NuxtError } from '#app'

const props = defineProps<{
  error: NuxtError
}>()

const errorTitle = computed(() => {
  if (props.error.statusCode === 404) return 'Page Not Found'
  if (props.error.statusCode === 403) return 'Access Denied'
  return 'Something Went Wrong'
})

function handleGoHome() {
  clearError({ redirect: '/' })
}
</script>

<template>
  <div class="error-page">
    <h1>{{ props.error.statusCode }}</h1>
    <h2>{{ errorTitle }}</h2>
    <p>{{ props.error.message }}</p>
    <button @click="handleGoHome">Go Home</button>
  </div>
</template>

Key details about error.vue:

  • It lives in the project root, not inside pages/. It is not a route.
  • It receives the error object as a prop with statusCode, message, and optional data.
  • clearError() dismisses the error and optionally redirects. Without calling it, the error state persists.

Creating & Showing Errors

createError

createError throws a fatal error that triggers the error page. It works on both server and client:

// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const user = await db.users.findById(id)

  if (!user) {
    throw createError({
      statusCode: 404,
      statusMessage: 'User Not Found',
      message: `No user exists with ID ${id}`
    })
  }

  return user
})

On the client, createError can be used in pages and composables:

<script setup lang="ts">
const route = useRoute()
const { data: product } = await useFetch(`/api/products/${route.params.id}`)

if (!product.value) {
  throw createError({
    statusCode: 404,
    message: 'This product does not exist'
  })
}
</script>

showError

showError displays the error page without throwing an exception. This is useful when you want to show the error page from an event handler or after a user action:

<script setup lang="ts">
async function deleteAccount() {
  try {
    await $fetch('/api/account', { method: 'DELETE' })
    navigateTo('/')
  } catch (err) {
    showError({
      statusCode: 500,
      message: 'Failed to delete your account. Please try again.'
    })
  }
}
</script>

Non-Fatal Errors with NuxtErrorBoundary

For errors that should not replace the entire page, use <NuxtErrorBoundary> to catch errors in a specific component subtree:

<template>
  <div>
    <h1>Dashboard</h1>

    <NuxtErrorBoundary>
      <WidgetThatMightFail />

      <template #error="{ error, clearError }">
        <div class="widget-error">
          <p>Widget failed to load: {{ error.message }}</p>
          <button @click="clearError">Retry</button>
        </div>
      </template>
    </NuxtErrorBoundary>

    <AnotherWidget />
  </div>
</template>

The error boundary isolates the failure. AnotherWidget keeps working even if WidgetThatMightFail throws.

Per-Page SEO with useHead

useHead sets the <head> content for the current page. It supports reactive values, so the head updates when data changes.

<script setup lang="ts">
const { data: article } = await useFetch('/api/articles/vue-3-guide')

useHead({
  title: article.value?.title ?? 'Loading...',
  meta: [
    { name: 'description', content: article.value?.excerpt ?? '' },
    { property: 'og:title', content: article.value?.title ?? '' },
    { property: 'og:description', content: article.value?.excerpt ?? '' },
    { property: 'og:image', content: article.value?.coverImage ?? '' },
    { property: 'og:type', content: 'article' },
    { name: 'twitter:card', content: 'summary_large_image' },
  ],
  link: [
    { rel: 'canonical', href: `https://example.com/articles/${article.value?.slug}` }
  ]
})
</script>

<template>
  <article>
    <h1>{{ article?.title }}</h1>
    <div v-html="article?.content" />
  </article>
</template>

Reactive Head

Because useHead accepts computed values, the title updates automatically when data loads:

<script setup lang="ts">
const count = ref(0)

useHead({
  title: computed(() => `Notifications (${count.value})`)
})
</script>

Typed SEO with useSeoMeta

useSeoMeta provides a flat, typed API for SEO meta tags. It is less verbose than useHead for meta-heavy pages and catches typos at compile time:

<script setup lang="ts">
const { data: product } = await useFetch('/api/products/widget-pro')

useSeoMeta({
  title: () => product.value?.name ?? 'Product',
  description: () => product.value?.description ?? '',
  ogTitle: () => product.value?.name ?? '',
  ogDescription: () => product.value?.description ?? '',
  ogImage: () => product.value?.imageUrl ?? '',
  ogType: 'product',
  twitterCard: 'summary_large_image',
  twitterTitle: () => product.value?.name ?? '',
  twitterDescription: () => product.value?.description ?? '',
})
</script>

<template>
  <div>
    <h1>{{ product?.name }}</h1>
    <p>{{ product?.description }}</p>
  </div>
</template>

The property names are camelCase versions of the standard meta attributes: ogTitle becomes <meta property="og:title">, twitterCard becomes <meta name="twitter:card">.

The Nuxt Head System: Layered Configuration

Head metadata can be set at multiple levels. More specific levels override less specific ones:

// nuxt.config.ts -- global defaults
export default defineNuxtConfig({
  app: {
    head: {
      titleTemplate: '%s | My App',
      meta: [
        { name: 'viewport', content: 'width=device-width, initial-scale=1' },
        { charset: 'utf-8' }
      ],
      link: [
        { rel: 'icon', href: '/favicon.ico' }
      ]
    }
  }
})
<!-- layouts/default.vue -- layout-level head -->
<script setup lang="ts">
useHead({
  meta: [
    { name: 'theme-color', content: '#10b981' }
  ]
})
</script>
<!-- pages/pricing.vue -- page-level head (highest priority) -->
<script setup lang="ts">
useHead({
  title: 'Pricing',
  meta: [
    { name: 'description', content: 'Simple, transparent pricing for all plans.' }
  ]
})
</script>

The resulting <head> merges all three levels. The page title "Pricing" is inserted into the template from nuxt.config.ts, producing "Pricing | My App".

Sitemap Generation

The @nuxtjs/sitemap module generates an XML sitemap automatically from your routes:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/sitemap'],

  site: {
    url: 'https://example.com'
  },

  sitemap: {
    exclude: ['/admin/**', '/login'],
    sources: ['/api/__sitemap__/urls']
  }
})

For dynamic routes that are not discoverable from the file system, provide a server endpoint:

// server/api/__sitemap__/urls.ts
export default defineEventHandler(async () => {
  const articles = await db.articles.findAll({ select: ['slug', 'updatedAt'] })

  return articles.map((article) => ({
    loc: `/articles/${article.slug}`,
    lastmod: article.updatedAt,
    changefreq: 'weekly',
    priority: 0.8
  }))
})

This produces a sitemap at https://example.com/sitemap.xml that includes both static pages from the file system and dynamic article URLs from the database.

Robots & Meta

Combine with robots meta to control indexing on specific pages:

<script setup lang="ts">
// Prevent search engines from indexing the staging environment
useSeoMeta({
  robots: 'noindex, nofollow'
})
</script>

Common Pitfalls

  • Placing error.vue inside pages/. The error page must be at the project root. Inside pages/, it becomes a regular route at /error and will not handle errors.
  • Not calling clearError. After an error page renders, the error state persists. Navigation via navigateTo alone may not clear it. Always call clearError({ redirect: '/' }) to reset state.
  • Forgetting titleTemplate interaction. If nuxt.config.ts sets titleTemplate: '%s | My App' and a page sets title: '', the result is " | My App". Set title: 'My App' or use titleTemplate: null on pages that need a standalone title.
  • Duplicate meta tags. Setting the same meta tag at multiple levels without using key or hid can produce duplicates. useSeoMeta handles deduplication automatically, but manual useHead calls need care.
  • Missing og:image dimensions. Social platforms may not render preview images without og:image:width and og:image:height. Always include dimensions for reliable previews.
  • Sitemap missing dynamic routes. The sitemap module only discovers file-based routes by default. Dynamic content (blog posts, product pages) requires a custom URL source endpoint.

Key Takeaways

  • error.vue at the project root is the global error page. Use createError to throw errors and showError to display them without throwing. clearError resets the error state.
  • <NuxtErrorBoundary> isolates errors to a component subtree, preventing full-page error displays for non-critical failures.
  • useHead provides full control over <head> content per page, including title, meta tags, and link elements, with reactive support.
  • useSeoMeta is a typed, flat alternative that simplifies SEO meta tag management and catches typos at compile time.
  • Head metadata is layered: global config, layout-level, and page-level, with page-level taking highest priority.
  • @nuxtjs/sitemap generates sitemaps automatically, with custom endpoints for dynamic content that the file system cannot discover.