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 optionaldata. 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.vueinsidepages/. The error page must be at the project root. Insidepages/, it becomes a regular route at/errorand will not handle errors. - Not calling
clearError. After an error page renders, the error state persists. Navigation vianavigateToalone may not clear it. Always callclearError({ redirect: '/' })to reset state. - Forgetting
titleTemplateinteraction. Ifnuxt.config.tssetstitleTemplate: '%s | My App'and a page setstitle: '', the result is " | My App". Settitle: 'My App'or usetitleTemplate: nullon pages that need a standalone title. - Duplicate meta tags. Setting the same meta tag at multiple levels without using
keyorhidcan produce duplicates.useSeoMetahandles deduplication automatically, but manualuseHeadcalls need care. - Missing
og:imagedimensions. Social platforms may not render preview images withoutog:image:widthandog: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.vueat the project root is the global error page. UsecreateErrorto throw errors andshowErrorto display them without throwing.clearErrorresets the error state.<NuxtErrorBoundary>isolates errors to a component subtree, preventing full-page error displays for non-critical failures.useHeadprovides full control over<head>content per page, including title, meta tags, and link elements, with reactive support.useSeoMetais 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/sitemapgenerates sitemaps automatically, with custom endpoints for dynamic content that the file system cannot discover.