Errors in a universal app come from five places (server startup, Nitro routes, server-side Vue rendering, client-side Vue, and chunk loading after a deploy) and each surfaces differently. Interviewers ask "how do you show a 404" and then follow up with "and if the error happens after hydration?", because the two answers are different and many candidates only know the first.
createError({ status, statusText, message, data, cause, fatal }) builds an error with HTTP metadata. These are the current field names; statusCode and statusMessage are the older h3 names, still accepted but marked deprecated on NuxtError in Nuxt 4.5, so read error.status in error.vue. statusText must be a short HTTP-style reason phrase; put detail in message or data. cause preserves a wrapped error and is shown on the dev error page. In server routes, throwing it sets the response status. In Vue code on the server, a thrown error renders the error page. In Vue code on the client, only fatal: true (or showError) triggers the full-page error; otherwise it is reported through vue:error and the page stays.error.vue at the app root renders every full-page error with the error as a prop; useError() reads it anywhere, clearError({ redirect }) leaves it. It is not a page, has no layout by default (wrap <NuxtLayout> yourself), and should stay dependency-light: an error in the error page is the worst outcome.useAsyncData/useFetch never throw. The failure lands in error (a FetchError with status, statusText, data). Check it and convert to a real HTTP status with throw createError({ status: 404, fatal: true }), or render inline.<NuxtErrorBoundary> catches errors from its default slot on the client and renders #error="{ error, clearError }" instead, keeping the rest of the page alive. It does not catch errors during server rendering; those become the error page.createError for expected failures (400 with data for validation issues, 401, 403, 404, 409) and let unexpected exceptions become 500. In production Nitro strips stack traces; statusText and data reach the client, so never put secrets in them.vue:error (Vue render/handler errors, server and client), app:error (fatal errors), app:chunkError (a lazy chunk failed to load), Nitro's error hook in server/plugins for route errors. These are where monitoring (Sentry's @sentry/nuxt, or a $fetch to your own endpoint) plugs in.experimental.emitRouteChunkError: 'automatic' (default) reloads the page; the app manifest detects the new build.200 with { error: true } breaks caching, monitoring and useFetch's error handling at once.A page that turns a missing resource into a proper 404 and keeps a non-critical widget's failure local:
<script setup lang="ts">
const { params } = useRoute()
const { data: product, error } = await useFetch(`/api/products/${params.slug}`)
if (error.value) {
// Anything else is unexpected and should become a 500 with the real cause logged server-side.
throw createError({
status: error.value.status === 404 ? 404 : 500,
statusText: error.value.status === 404 ? 'Product not found' : 'Could not load product',
fatal: true,
})
}
</script>
<template>
<article>
<h1>{{ product!.name }}</h1>
<!-- Reviews are optional: a failure here must not take the page down (client side). -->
<NuxtErrorBoundary @error="err => console.warn('reviews failed', err)">
<ProductReviews :product-id="product!.id" />
<template #error="{ clearError }">
<p>Reviews are unavailable. <button type="button" @click="clearError()">Retry</button></p>
</template>
</NuxtErrorBoundary>
</article>
</template>
The error page, minimal and layout-aware:
<script setup lang="ts">
import type { NuxtError } from '#app'
const { error } = defineProps<{ error: NuxtError }>()
const is404 = computed(() => error.status === 404)
useSeoMeta({ title: () => (is404.value ? 'Page not found' : 'Something went wrong') })
</script>
<template>
<NuxtLayout>
<main class="mx-auto max-w-xl py-24 text-center">
<h1 class="text-4xl font-bold">{{ error.status }}</h1>
<p class="mt-2 text-muted">{{ is404 ? 'That page does not exist.' : error.statusText || 'Unexpected error.' }}</p>
<UButton class="mt-6" @click="clearError({ redirect: '/' })">Back home</UButton>
</main>
</NuxtLayout>
</template>
A server route with expected and unexpected failures separated, and a Nitro plugin that reports the unexpected ones:
import { z } from 'zod'
const params = z.object({ id: z.string().uuid() })
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, params.parse) // 400 with the zod issues in `data`
const { user } = await requireUserSession(event) // 401
const order = await useDb().orders.find(id)
if (!order) throw createError({ status: 404, statusText: 'Order not found' })
if (order.userId !== user.id) throw createError({ status: 403, statusText: 'Not your order' })
return order // a thrown database error becomes a 500 without a stack trace in production
})
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (error, { event }) => {
const status = (error as { status?: number }).status ?? 500
if (status >= 500) console.error(`[${event?.path}]`, error) // or Sentry.captureException(error)
})
})
Client-side reporting for errors that do not take the page down:
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('vue:error', (error, _instance, info) => {
void $fetch('/api/telemetry/error', { method: 'POST', body: { message: String(error), info, path: useRoute().fullPath } })
})
})
Rendering "Not found" inside the page while the response says 200 OK teaches search engines the URL exists, caches the wrong thing at the CDN and hides the problem from monitoring. Throw createError({ status: 404, fatal: true }) so the server answers with the status.
On the client, throw createError(...) inside an event handler does not show the error page; it is reported through vue:error and swallowed. Use showError() or fatal: true when the whole page is unusable, and prefer inline handling when it is not.
The current list of error hooks, how NuxtErrorBoundary behaves during SSR in the latest 4.x, and the stabilised NUXT_* error codes shipped in 4.5.
curl -I; then remove fatal: true, reload in the browser, and note what changes on the client-side navigation path.<NuxtErrorBoundary>, trigger the error after hydration and confirm the rest of the page survives; then throw during SSR and see the error page instead.app:chunkError reload the page."I check the error ref from useFetch, because it never throws, and if the API said 404 I throw createError({ status: 404, fatal: true }) from setup. On the server that renders error.vue with a real 404 status, which matters for search engines and CDNs; on the client fatal triggers the same page. For a subtree that is optional, like reviews on a product page, I wrap it in NuxtErrorBoundary so a failure there renders a small fallback and the page survives, and I report through the vue:error hook so I still learn about it."
"Five places: Nitro route errors, which I shape with createError and observe with the Nitro error hook in a server plugin; server-side render errors, which become the error page; client render and handler errors, which fire vue:error and only take the page down when fatal; data fetching errors, which land in the error ref; and chunk load errors after a deploy, which Nuxt reloads automatically. Monitoring plugs into the hooks: vue:error and app:error on the client, the Nitro hook on the server, typically through Sentry's Nuxt SDK, with the release version attached so I can tie an error to a deploy."
Nothing yet. Notes filed here show up on this page and the section overview.
Plugins and auto-imports
Plugin order, dependsOn and parallel, providing typed helpers, app hooks, callOnce and onNuxtReady, and how components, composables and utils are scanned and named.
SEO, head and media
useSeoMeta and useHead on Unhead, titles and OG tags, canonical URLs, sitemaps and structured data with the Nuxt SEO modules, and the image, font and script modules that protect LCP and CLS.