Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 084 / 100

Error handling in Vue apps: a practical Vue & Nuxt guide

Catch and report errors.

Expona Admin10 min read

The cost of unhandled errors

An error bubbles up in production, the UI freezes, and users see a white screen. They refresh the page, refresh again, then leave. Without proper error handling in Vue apps: a practical Vue & Nuxt guide, your team never hears about it. Error handling isn't about perfection—it's about visibility, recovery, and keeping your users in the loop when things break.

In this guide, we'll walk through how to catch errors at different layers, show users something better than a crash, and send error signals to your monitoring tools. Whether you're working on a single-page application or a full Nuxt project, these patterns protect both your application and your reputation.

Global error handling with app.config.errorHandler

The first line of defense is a global error handler at the application level. In Vue 3, app.config.errorHandler catches any unhandled error thrown during component rendering, lifecycle hooks, event handlers, and watchers. This is your safety net for errors you didn't anticipate.

In a Vue 3 application, set it up in your main.ts or main.js:

typescript
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

app.config.errorHandler = (err, instance, info) => {
  console.error('Global error:', err)
  console.error('Error info:', info)
  console.error('Component instance:', instance)

  // Send to monitoring service
  if (typeof window !== 'undefined') {
    window.__errorQueue = window.__errorQueue || []
    window.__errorQueue.push({
      message: err instanceof Error ? err.message : String(err),
      stack: err instanceof Error ? err.stack : '',
      info,
      timestamp: new Date().toISOString()
    })
  }
}

app.mount('#app')

For Nuxt, create a plugin instead. Nuxt's server-side rendering context needs careful handling:

typescript
// plugins/error-handler.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.config.errorHandler = (err, instance, info) => {
    console.error('Nuxt global error:', err)
    
    // Don't send server-side errors during SSR to client
    if (process.server) {
      console.error('Server error:', info)
      return
    }

    // Client-side error tracking
    const errorData = {
      message: err instanceof Error ? err.message : String(err),
      stack: err instanceof Error ? err.stack : '',
      info,
      url: typeof window !== 'undefined' ? window.location.href : '',
      timestamp: new Date().toISOString()
    }

    // Queue for batch sending
    const queue = (window as any).__errorQueue || []
    queue.push(errorData)
    ;(window as any).__errorQueue = queue
  }
})

The global handler catches many errors, but not all. It won't catch Promise rejections or errors in async callbacks that happen outside Vue's reactivity system. That's why we also need component-level error boundaries.

Component-level error capture with onErrorCaptured

Use onErrorCaptured to catch errors in the subtree of a component. This is useful for wrapping risky sections—like API calls, third-party embeds, or user-generated content. When an error is caught here, you can decide whether to handle it locally or let it propagate to the global handler.

A practical scenario: you render a list of widgets, and one fails to load. Without component-level error capture, the whole list crashes. With it, you show an error state for just that widget:

typescript
<template>
  <div class="widgets">
    <div v-for="widget in widgets" :key="widget.id" class="widget-container">
      <WidgetErrorBoundary>
        <WidgetCard :widget="widget" />
      </WidgetErrorBoundary>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import WidgetErrorBoundary from './WidgetErrorBoundary.vue'
import WidgetCard from './WidgetCard.vue'

const widgets = ref([
  { id: 1, name: 'Chart' },
  { id: 2, name: 'Stats' },
  { id: 3, name: 'Report' }
])
</script>

Now the error boundary component itself:

typescript
// WidgetErrorBoundary.vue
<template>
  <div v-if="hasError" class="error-state">
    <p>Widget failed to load</p>
    <button @click="reset">Retry</button>
  </div>
  <div v-else>
    <slot />
  </div>
</template>

<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'

const hasError = ref(false)
const errorMessage = ref('')

onErrorCaptured((err, instance, info) => {
  hasError.value = true
  errorMessage.value = err instanceof Error ? err.message : String(err)
  
  console.warn(`Error in ${info}:`, err)

  // Returning false prevents error from propagating further
  return false
})

const reset = () => {
  hasError.value = false
  errorMessage.value = ''
}
</script>

<style scoped>
.error-state {
  padding: 1rem;
  border: 1px solid #f5222d;
  border-radius: 4px;
  background-color: #fff1f0;
  color: #f5222d;
}

button {
  margin-top: 0.5rem;
  padding: 0.5rem 1rem;
  background-color: #f5222d;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background-color: #d9001b;
}
</style>

Return false from onErrorCaptured to prevent the error from reaching the global handler. This is valuable when you want to handle a specific error locally and don't need it logged globally.

Showing friendly fallback UI

Raw error messages confuse users. "Cannot read property 'data' of undefined" means nothing to someone trying to complete a task. Instead, show a clear, actionable fallback UI that tells them what happened and what they can do.

Build a reusable error state component:

typescript
// ErrorFallback.vue
<template>
  <div class="error-fallback">
    <div class="error-icon">⚠️</div>
    <h3>{{ title }}</h3>
    <p>{{ message }}</p>
    <div class="actions">
      <button @click="$emit('retry')" class="btn-primary">
        Try again
      </button>
      <button @click="goHome" class="btn-secondary">
        Go to home
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'

interface Props {
  title?: string
  message?: string
}

withDefaults(defineProps<Props>(), {
  title: 'Something went wrong',
  message: 'We encountered an unexpected error. Please try again or return home.'
})

defineEmits<{
  retry: []
}>()

const router = useRouter()

const goHome = () => {
  router.push('/')
}
</script>

<style scoped>
.error-fallback {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 2rem;
  text-align: center;
  min-height: 300px;
}

.error-icon {
  font-size: 3rem;
  margin-bottom: 1rem;
}

h3 {
  font-size: 1.5rem;
  margin-bottom: 0.5rem;
  color: #333;
}

p {
  color: #666;
  margin-bottom: 1.5rem;
  max-width: 400px;
}

.actions {
  display: flex;
  gap: 1rem;
}

button {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

.btn-primary {
  background-color: #1890ff;
  color: white;
}

.btn-primary:hover {
  background-color: #0050b3;
}

.btn-secondary {
  background-color: #f0f0f0;
  color: #333;
}

.btn-secondary:hover {
  background-color: #e0e0e0;
}
</style>

Use this component in your error boundaries or in route-level error states. For Nuxt, you can create a custom error layout that displays when an uncaught error occurs during SSR or hydration:

typescript
// layouts/error.vue (Nuxt)
<template>
  <div class="error-layout">
    <ErrorFallback 
      title="Page not found or an error occurred"
      message="The page you're looking for doesn't exist, or something went wrong on our end."
      @retry="$fetch()"
    />
  </div>
</template>

<script setup lang="ts">
import ErrorFallback from '~/components/ErrorFallback.vue'
</script>

Sending errors to monitoring services

Catching errors locally is half the battle. The other half is getting them to a monitoring service so your team knows about production issues before users complain. Services like Sentry, Rollbar, or Datadog accept error payloads via HTTP POST.

Create a monitoring service wrapper:

typescript
// services/errorMonitoring.ts
interface ErrorPayload {
  message: string
  stack?: string
  url?: string
  info?: string
  timestamp: string
  userAgent?: string
  userId?: string
  severity: 'error' | 'warning'
}

const MONITORING_ENDPOINT = process.env.VITE_MONITORING_ENDPOINT || ''
const MONITORING_API_KEY = process.env.VITE_MONITORING_API_KEY || ''

export const reportError = async (payload: ErrorPayload): Promise<void> => {
  if (!MONITORING_ENDPOINT) {
    console.warn('Monitoring endpoint not configured')
    return
  }

  try {
    await fetch(MONITORING_ENDPOINT, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${MONITORING_API_KEY}`
      },
      body: JSON.stringify({
        ...payload,
        userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
        timestamp: new Date().toISOString()
      })
    })
  } catch (err) {
    // Log to console if reporting itself fails
    console.error('Failed to report error to monitoring service:', err)
  }
}

export const batchReportErrors = async (
  errors: ErrorPayload[]
): Promise<void> => {
  if (errors.length === 0) return

  for (const error of errors) {
    await reportError(error)
  }
}

Integrate this into your global error handler. Use setInterval to batch and send errors periodically:

typescript
// In your plugin or main.ts
import { reportError } from './services/errorMonitoring'

app.config.errorHandler = async (err, instance, info) => {
  const payload = {
    message: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack : '',
    info,
    severity: 'error' as const
  }

  // Report immediately for critical errors
  if (info?.includes('render') || info?.includes('setup')) {
    await reportError(payload)
  }
}

// Batch send errors every 10 seconds
if (typeof window !== 'undefined') {
  setInterval(() => {
    const queue = (window as any).__errorQueue || []
    if (queue.length > 0) {
      ;(window as any).__errorQueue = []
      // Send batch
      queue.forEach(async (err: any) => {
        await reportError(err)
      })
    }
  }, 10000)
}

Common mistakes

  • Swallowing errors silently. An error caught in onErrorCaptured but never logged or reported means your team will never know it happened. Always log at minimum.
  • Not distinguishing between client and server errors in Nuxt. Server-side errors during SSR shouldn't trigger client-side monitoring calls. Check process.server and process.client before reporting.
  • Showing technical error details to users. Stack traces and error codes confuse users. Translate errors into plain language or generic messages.
  • Forgetting to handle Promise rejections. app.config.errorHandler doesn't catch unhandled Promise rejections. Add a listener for unhandledrejection events.
  • Over-reporting to monitoring services. Batch errors before sending to avoid flooding your monitoring dashboard with duplicate reports.

Key takeaways

  • Use app.config.errorHandler as a global safety net for all unhandled errors in your Vue application.
  • Wrap risky components with onErrorCaptured to catch and handle errors locally without crashing the entire app.
  • Always show users a friendly fallback UI instead of raw error messages or white screens.
  • Send errors to a monitoring service so your team has visibility into production issues before they become major problems.
  • Batch errors before reporting and distinguish between client-side and server-side errors, especially in Nuxt applications.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles