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
      lazy: true      // Enable lazy loading globally
    }
  }
})
You can combine options! Use { lazy: true } 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 { mutate, pending, error: mutationError, reset } = useConvexMutation(api.tasks.create)
const taskText = ref('')

async function handleSubmit() {
  if (!taskText.value.trim()) return
  // Execute mutation
  await mutate({ 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.

Authentication

Secure your app with Better Auth and protect your queries.

Caching & Reuse

Reuse query data between pages for instant navigation.

Optimistic UI

Make your app feel instant by predicting mutation results.

Data Transforms

Transform and compute data before it reaches your component.