Now that you have a basic query set up, let's explore what makes better-convex-nuxt special: Granular control over data loading.
We'll cover client-side loading, Server-Side Rendering (SSR), and mutations.
To demonstrate the loading states effectively, we need a multi-page app. Let's restructure our application slightly.
<script setup lang="ts">
import { api } from '~~/convex/_generated/api'
// Default behavior: SSR enabled (like Nuxt's useFetch)
const { data: tasks, error } = await useConvexQuery(api.tasks.get, {})
</script>
<template>
<NuxtLink to="/">Back Home</NuxtLink>
<div>
<p v-if="error">Error: {{ error.message }}</p>
<ul v-else>
<li v-for="task in tasks" :key="task._id">{{ task.text }}</li>
</ul>
</div>
</template>
Test it out:
/query. Data loads instantly - no loading spinner!/ (Home) and back to /query. Navigation waits for data.By default, useConvexQuery runs on the server during SSR - just like Nuxt's useFetch. This means:
Trade-off: SSR triggers two function calls to Convex: one HTTP fetch on the server, then a WebSocket subscription on the client. For high-traffic apps, consider the impact on your Convex bill.
For non-critical data or client-only interactions, disable SSR:
const { data, status } = await useConvexQuery(
api.tasks.get,
{},
{
server: false, // Client-only, shows loading state on refresh
},
)
To change the default behavior for all queries in your app:
export default defineNuxtConfig({
convex: {
url: process.env.CONVEX_URL,
defaults: {
server: false, // Disable SSR globally
},
},
})
useConvexQuery to render on the server for the first visit, but show a loading spinner immediately on client-side navigation instead of blocking.With SSR enabled by default, better-convex-nuxt hydrates the state and establishes a WebSocket connection on the client. This means your app remains real-time.
Let's add a mutation to create new tasks.
First, update your backend logic:
import { query, mutation } from './_generated/server'
import { v } from 'convex/values'
export const get = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query('tasks').collect()
},
})
// Add this mutation
export const create = mutation({
args: {
text: v.string(),
},
handler: async (ctx, args) => {
return await ctx.db.insert('tasks', {
text: args.text,
isCompleted: false,
})
},
})
Update app/pages/query.vue to include a form. We will use useConvexMutation to handle the interaction.
<script setup lang="ts">
import { api } from '~~/convex/_generated/api'
const { data: tasks, status, error } = await useConvexQuery(api.tasks.get, {})
// Setup mutation
const { execute, pending, error: mutationError, reset } = useConvexMutation(api.tasks.create)
const taskText = ref('')
async function handleSubmit() {
if (!taskText.value.trim()) return
// Execute mutation
await execute({ text: taskText.value })
taskText.value = ''
}
</script>
<template>
<NuxtLink to="/">Home</NuxtLink>
<div>
<!-- Form Section -->
<form @submit.prevent="handleSubmit">
<input v-model="taskText" type="text" placeholder="Enter task..." :disabled="pending" />
<button type="submit" :disabled="pending || !taskText.trim()">
{{ pending ? 'Creating...' : 'Create Task' }}
</button>
</form>
<div v-if="mutationError">
<p>Something went wrong.</p>
<button @click="reset">Dismiss</button>
</div>
<!-- List Section -->
<p v-if="status === 'pending'">Loading...</p>
<ul v-else>
<li v-for="task in tasks" :key="task._id">{{ task.text }}</li>
</ul>
</div>
</template>
Test it out: Add a task. It appears immediately! Open the app in a second browser window. When you add a task in one window, it instantly appears in the other.
Sometimes you don't want a WebSocket connection (e.g., for static blog posts or expensive queries). You can disable subscriptions and control updates manually.
Change your query options:
const { data, refresh } = await useConvexQuery(
api.tasks.get,
{},
{
subscribe: false, // Disable real-time updates
},
)
And add a refresh button to your template:
<button @click="() => refresh()">Refresh List</button>
Test it out: Add a task. The list will not update. Click "Refresh List" to fetch the new data manually.
You have mastered the basics! Continue with authentication or explore advanced topics.
title: Authentication icon: i-lucide-shield-check to: /docs/guide/auth
Secure your app with Better Auth and protect your queries.
title: Caching & Reuse icon: i-lucide-database to: /docs/data-fetching/caching-reuse
Reuse query data between pages for instant navigation. ::
title: Optimistic UI icon: i-lucide-zap to: /docs/mutations/optimistic-updates
Make your app feel instant by predicting mutation results. ::
title: Data Transforms icon: i-lucide-wand-2 to: /docs/data-fetching/queries#transform-data
Transform and compute data before it reaches your component. ::
::