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 { execute, pending, error, reset } = useConvexMutation(api.posts.create)
const title = ref('')
const formError = ref<string | null>(null)
async function handleSubmit() {
formError.value = null
try {
await execute({ 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>
execute() vs executeSafe()Use execute() by default. Use executeSafe() when you explicitly need a non-throwing boundary.
| API | Best for | Error style |
|---|---|---|
execute(args) | Standard app/domain flows | Throws on transport/runtime errors; use try/catch if needed |
executeSafe(args) | Middleware/startup/background/legacy boundaries | Never throws; always returns CallResult<T> |
For UI flows where you want to avoid try/catch boilerplate, use executeSafe() (or the safe variants from useConvexAction / useConvexCall):
const { executeSafe, error } = useConvexMutation(api.posts.create)
const result = await executeSafe({ title: 'Hello' })
if (!result.ok) {
// `error.value` is also updated for template usage
console.error(result.error.code, result.error.message)
return
}
console.log(result.data.postId)
If your backend endpoint already returns an app-level CallResult<T>, then executeSafe() wraps that result:
// Endpoint return type: CallResult<User>
const fromExecute = await execute(args) // CallResult<User>
const fromSafe = await executeSafe(args) // CallResult<CallResult<User>>
This is expected. executeSafe() handles thrown/runtime failures; your inner CallResult handles domain/business outcomes.
CallResult<T> has a stable shape:
type CallResult<T> = { ok: true; data: T } | { ok: false; error: ConvexCallError }
interface ConvexCallError {
message: string
code?: string
status?: number
cause?: unknown
}
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 { execute, 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 { execute, 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>