Advanced

Error Handling

Handle errors gracefully across your Convex + Nuxt application.

Query Errors

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>

Retry Pattern

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

Mutation Errors

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>

Error Boundaries

Use Nuxt's <NuxtErrorBoundary> to catch errors in component subtrees.

Component-Level Error Boundary

components/PostList.vue
<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>

Layout-Level Error Boundary

Wrap your main content to catch all errors:

layouts/default.vue
<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>

Global Error Page

Create error.vue in your app root for unhandled errors:

error.vue
<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>

Auth Error Middleware

Redirect to login on authentication errors:

middleware/auth-error.global.ts
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 },
    })
  }
})

Handle 401 from Convex

Create a plugin to intercept Convex errors:

plugins/convex-error-handler.client.ts
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')
    }
  })
})

Connection State Awareness

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>

Error Logging

Console Logging

Enable module logging in your nuxt.config.ts to see all errors:

nuxt.config.ts
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.

Error Tracking Integration

Integrate with services like Sentry:

plugins/sentry.client.ts
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 },
    })
  }
})

Common Error Patterns

Network Timeout

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

Permission Denied

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

Validation Errors

convex/posts.ts
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>