Use the error ref from useConvexQuery:
<script setup lang="ts">
const { data, status, error, refresh } = await useConvexQuery(api.posts.list, {})
</script>
<template>
<div v-if="status === 'error'" class="error-state">
<p>Failed to load posts: {{ error?.message }}</p>
<button @click="refresh">Try Again</button>
</div>
<div v-else-if="status === 'success'">
<PostCard v-for="post in data" :key="post._id" :post="post" />
</div>
</template>
<script setup lang="ts">
const { data, status, error, refresh } = await useConvexQuery(api.posts.list, {})
const retryCount = ref(0)
const maxRetries = 3
async function retryWithBackoff() {
if (retryCount.value >= maxRetries) return
retryCount.value++
// Exponential backoff: 1s, 2s, 4s
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, retryCount.value - 1)))
await refresh()
}
// Reset retry count on success
watch(status, (newStatus) => {
if (newStatus === 'success') {
retryCount.value = 0
}
})
</script>
<template>
<div v-if="status === 'error'">
<p>{{ error?.message }}</p>
<button
v-if="retryCount < maxRetries"
@click="retryWithBackoff"
>
Retry ({{ maxRetries - retryCount }} left)
</button>
<p v-else>Please try again later.</p>
</div>
</template>
Mutations expose errors via both the error ref and thrown exceptions:
<script setup lang="ts">
const { mutate, pending, error, reset } = useConvexMutation(api.posts.create)
const title = ref('')
const formError = ref<string | null>(null)
async function handleSubmit() {
formError.value = null
try {
await mutate({ title: title.value })
title.value = ''
navigateTo('/posts')
} catch (e) {
// Error is also available in error.value
formError.value = formatError(e)
}
}
function formatError(e: unknown): string {
if (e instanceof Error) {
// Handle specific Convex errors
if (e.message.includes('Unauthorized')) {
return 'You must be logged in to create posts'
}
if (e.message.includes('Forbidden')) {
return 'You don\'t have permission to create posts'
}
return e.message
}
return 'An unexpected error occurred'
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input
v-model="title"
:disabled="pending"
@input="reset"
/>
<p v-if="formError" class="text-red-500">
{{ formError }}
</p>
<button :disabled="pending">
{{ pending ? 'Creating...' : 'Create Post' }}
</button>
</form>
</template>
Use Nuxt's <NuxtErrorBoundary> to catch errors in component subtrees.
<template>
<NuxtErrorBoundary @error="handleError">
<!-- Content that might error -->
<PostListContent />
<!-- Fallback UI when error occurs -->
<template #error="{ error, clearError }">
<div class="error-card">
<h3>Failed to load posts</h3>
<p>{{ error.message }}</p>
<button @click="clearError">Dismiss</button>
<button @click="refresh">Retry</button>
</div>
</template>
</NuxtErrorBoundary>
</template>
<script setup lang="ts">
function handleError(error: Error) {
// Log to error tracking service
console.error('PostList error:', error)
// Optionally report to Sentry, etc.
// Sentry.captureException(error)
}
</script>
Wrap your main content to catch all errors:
<template>
<div class="layout">
<AppHeader />
<NuxtErrorBoundary>
<main>
<slot />
</main>
<template #error="{ error, clearError }">
<div class="error-page">
<h1>Something went wrong</h1>
<p>{{ error.message }}</p>
<div class="actions">
<button @click="clearError">Try Again</button>
<NuxtLink to="/">Go Home</NuxtLink>
</div>
</div>
</template>
</NuxtErrorBoundary>
<AppFooter />
</div>
</template>
Create error.vue in your app root for unhandled errors:
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{
error: NuxtError
}>()
const handleError = () => clearError({ redirect: '/' })
// Map error codes to user-friendly messages
const errorMessages: Record<number, string> = {
401: 'Please sign in to continue',
403: 'You don\'t have access to this page',
404: 'Page not found',
500: 'Something went wrong on our end',
}
const message = computed(() =>
errorMessages[props.error.statusCode ?? 500] ?? props.error.message
)
</script>
<template>
<div class="error-page">
<h1>{{ error.statusCode }}</h1>
<p>{{ message }}</p>
<div class="actions">
<button @click="handleError">Go Home</button>
<NuxtLink
v-if="error.statusCode === 401"
to="/auth/signin"
>
Sign In
</NuxtLink>
</div>
</div>
</template>
Redirect to login on authentication errors:
export default defineNuxtRouteMiddleware(async (to) => {
// Skip for auth pages
if (to.path.startsWith('/auth')) return
const { isAuthenticated } = useConvexAuth()
// Check if page requires auth (you can customize this logic)
const requiresAuth = to.meta.auth !== false
if (requiresAuth && !isAuthenticated.value) {
return navigateTo({
path: '/auth/signin',
query: { redirect: to.fullPath },
})
}
})
Create a plugin to intercept Convex errors:
export default defineNuxtPlugin(() => {
const router = useRouter()
// Listen for Convex errors globally
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason
if (error?.message?.includes('Unauthorized')) {
event.preventDefault()
router.push('/auth/signin')
}
})
})
Use connection state to show appropriate UI during network issues:
<script setup lang="ts">
const { state, isConnected, hasPendingMutations } = useConvexConnectionState()
</script>
<template>
<!-- Offline banner -->
<div v-if="!isConnected" class="offline-banner">
<span v-if="state === 'Reconnecting'">
Reconnecting...
</span>
<span v-else>
You're offline. Changes will sync when you're back online.
</span>
</div>
<!-- Pending mutations indicator -->
<div v-if="hasPendingMutations" class="pending-badge">
Saving...
</div>
</template>
Enable module logging in your nuxt.config.ts to see all errors:
export default defineNuxtConfig({
convex: {
url: process.env.CONVEX_URL,
logging: {
enabled: true, // Logs all operations including errors
},
},
})
Error events include full context:
[better-convex-nuxt] ▷ query ✗ api.posts.list 45ms
└─ error: ConvexError "Unauthorized", retriable
See Logging for full configuration options.
Integrate with services like Sentry:
import * as Sentry from '@sentry/vue'
export default defineNuxtPlugin((nuxtApp) => {
Sentry.init({
app: nuxtApp.vueApp,
dsn: 'your-sentry-dsn',
// ... config
})
// Capture Vue errors
nuxtApp.vueApp.config.errorHandler = (error, instance, info) => {
Sentry.captureException(error, {
extra: { componentInfo: info },
})
}
})
<script setup lang="ts">
const { data, status, error, refresh } = await useConvexQuery(api.posts.list, {})
const isTimeout = computed(() =>
error.value?.message?.includes('timeout') ||
error.value?.message?.includes('network')
)
</script>
<template>
<div v-if="status === 'error'">
<p v-if="isTimeout">
Connection timed out. Please check your internet connection.
</p>
<p v-else>{{ error?.message }}</p>
<button @click="refresh">Retry</button>
</div>
</template>
<script setup lang="ts">
const { mutate, error } = useConvexMutation(api.posts.delete)
const isForbidden = computed(() =>
error.value?.message?.includes('Forbidden')
)
</script>
<template>
<div v-if="error">
<p v-if="isForbidden">
You don't have permission to delete this post.
</p>
<p v-else>{{ error.message }}</p>
</div>
</template>
import { mutation } from './_generated/server'
import { v } from 'convex/values'
export const create = mutation({
args: {
title: v.string(),
content: v.string(),
},
handler: async (ctx, args) => {
// Validation
if (args.title.length < 3) {
throw new Error('Validation: Title must be at least 3 characters')
}
if (args.title.length > 100) {
throw new Error('Validation: Title must be less than 100 characters')
}
// ... create post
},
})
<script setup lang="ts">
const { mutate, error } = useConvexMutation(api.posts.create)
const validationError = computed(() => {
if (error.value?.message?.startsWith('Validation:')) {
return error.value.message.replace('Validation: ', '')
}
return null
})
</script>
<template>
<p v-if="validationError" class="text-amber-500">
{{ validationError }}
</p>
</template>