Guide

Basics

Master SSR, mutations, and real-time subscriptions.
Prerequisite: Complete the Getting Started guide first. You should have a working Nuxt + Convex app with a tasks query.

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.

Setup Navigation

To demonstrate the loading states effectively, we need a multi-page app. Let's restructure our application slightly.

app/pages/query.vue
<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:

  1. Refresh the page on /query. Data loads instantly - no loading spinner!
  2. Navigate to / (Home) and back to /query. Navigation waits for data.

Understanding SSR (Server-Side Rendering)

By default, useConvexQuery runs on the server during SSR - just like Nuxt's useFetch. This means:

  • No loading spinners on initial page load
  • Better SEO (data is in the HTML)
  • Slightly increased Time To First Byte (TTFB)

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.

Disable SSR for Specific Queries

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
  },
)

Configure Global Defaults

To change the default behavior for all queries in your app:

nuxt.config.ts
export default defineNuxtConfig({
  convex: {
    url: process.env.CONVEX_URL,
    defaults: {
      server: false, // Disable SSR globally
    },
  },
})
You can combine options! Use useConvexQuery to render on the server for the first visit, but show a loading spinner immediately on client-side navigation instead of blocking.

Add a Mutation

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:

convex/tasks.ts
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,
    })
  },
})

Connect Mutation to UI

Update app/pages/query.vue to include a form. We will use useConvexMutation to handle the interaction.

app/pages/query.vue
<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.

Manual Refresh (Opt-out of Real-time)

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.

Next Steps

You have mastered the basics! Continue with authentication or explore advanced topics.

::card

title: Authentication icon: i-lucide-shield-check to: /docs/guide/auth


Secure your app with Better Auth and protect your queries.

::card

title: Caching & Reuse icon: i-lucide-database to: /docs/data-fetching/caching-reuse


Reuse query data between pages for instant navigation. ::

::card

title: Optimistic UI icon: i-lucide-zap to: /docs/mutations/optimistic-updates


Make your app feel instant by predicting mutation results. ::

::card

title: Data Transforms icon: i-lucide-wand-2 to: /docs/data-fetching/queries#transform-data


Transform and compute data before it reaches your component. ::

::