Building Nuxt apps

Error handling

Where errors happen in a Nuxt app and where they surface — createError and error.vue, fatal versus non-fatal, NuxtErrorBoundary, useAsyncData errors, server route errors, hooks and monitoring.

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.

Know

  • 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.
  • Server routes throw 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.
  • Hooks: 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.
  • Chunk errors after a deploy happen when a user with an old page navigates to a route whose chunk hash changed. experimental.emitRouteChunkError: 'automatic' (default) reloads the page; the app manifest detects the new build.
  • Status codes are semantics. 404 for a missing resource, 401 for "who are you", 403 for "not allowed", 410 for gone, 422 or 400 for invalid input, 5xx only for your own faults. A 200 with { error: true } breaks caching, monitoring and useFetch's error handling at once.
  • Nuxt 4.5 stabilised its internal error codes, which makes it possible to match framework errors programmatically in hooks (verify the list before quoting it).

How it works

A page that turns a missing resource into a proper 404 and keeps a non-critical widget's failure local:

app/pages/products/[slug].vue
<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:

app/error.vue
<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:

server/api/orders/[id].get.ts
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
})
server/plugins/errors.ts
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:

app/plugins/error-reporting.client.ts
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 } })
  })
})
Gotcha· A 404 that returns 200

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.

Gotcha· fatal on the client

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.

Verify before the interview:

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.

docs ↗

Exercise

Exercise
  • Throw a 404 from a page's setup and check the status with curl -I; then remove fatal: true, reload in the browser, and note what changes on the client-side navigation path.
  • Wrap a component that throws in <NuxtErrorBoundary>, trigger the error after hydration and confirm the rest of the page survives; then throw during SSR and see the error page instead.
  • Deploy a new build while a tab is open on the old one, navigate, and watch app:chunkError reload the page.

Be able to say

Be able to say· How do you handle a missing resource in a Nuxt 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."

Be able to say· Where do errors surface in a Nuxt app and how do you monitor them?

"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."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.