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:
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:
// 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:
<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:
// 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:
// 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:
// 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:
// 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:
// 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
onErrorCapturedbut 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.serverandprocess.clientbefore 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.errorHandlerdoesn't catch unhandled Promise rejections. Add a listener forunhandledrejectionevents. - Over-reporting to monitoring services. Batch errors before sending to avoid flooding your monitoring dashboard with duplicate reports.
Key takeaways
- Use
app.config.errorHandleras a global safety net for all unhandled errors in your Vue application. - Wrap risky components with
onErrorCapturedto 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.






Comments
Loading comments…