Getting Started

Quick Start

Build your first real-time feature with Convex and Nuxt.

Your First Query

Queries fetch data from Convex. They automatically subscribe to changes and update in real-time.

pages/todos.vue
<script setup lang="ts">
import { api } from '~/convex/_generated/api'

// Fetch todos with real-time updates
const { data: todos, status, error } = useConvexQuery(api.todos.list, {})
</script>

<template>
  <div class="todos">
    <!-- Loading state -->
    <div v-if="status === 'pending'">Loading todos...</div>

    <!-- Error state -->
    <div v-else-if="status === 'error'" class="error">Failed to load: {{ error?.message }}</div>

    <!-- Empty state -->
    <div v-else-if="status === 'success' && !todos?.length">No todos yet. Add one below!</div>

    <!-- Data -->
    <ul v-else>
      <li v-for="todo in todos" :key="todo._id">
        {{ todo.text }}
      </li>
    </ul>
  </div>
</template>

What's happening:

  1. useConvexQuery fetches data during SSR for fast initial load
  2. On the client, it subscribes via WebSocket for real-time updates
  3. When any user adds a todo, your UI updates automatically

Your First Mutation

Mutations modify data on the server. They return a promise and track loading/error state.

pages/todos.vue
<script setup lang="ts">
import { api } from '~/convex/_generated/api'

// Query for listing
const { data: todos, status } = useConvexQuery(api.todos.list, {})

// Mutation for creating
const { mutate: createTodo, pending, error: createError } = useConvexMutation(api.todos.create)

// Form state
const newTodoText = ref('')

async function handleSubmit() {
  if (!newTodoText.value.trim()) return

  try {
    await createTodo({ text: newTodoText.value })
    newTodoText.value = '' // Clear input on success
  } catch {
    // Error is automatically captured in createError
  }
}
</script>

<template>
  <div class="todos">
    <!-- Create form -->
    <form @submit.prevent="handleSubmit">
      <input v-model="newTodoText" placeholder="What needs to be done?" :disabled="pending" />
      <button type="submit" :disabled="pending || !newTodoText.trim()">
        {{ pending ? 'Adding...' : 'Add Todo' }}
      </button>
    </form>

    <p v-if="createError" class="error">
      {{ createError.message }}
    </p>

    <!-- Todo list -->
    <ul v-if="status === 'success'">
      <li v-for="todo in todos" :key="todo._id">
        {{ todo.text }}
      </li>
    </ul>
  </div>
</template>

What's happening:

  1. useConvexMutation returns a mutate function and tracks pending/error state
  2. When you call mutate(), it sends the data to Convex
  3. After the mutation completes, all subscribed queries update automatically
  4. No manual refetching needed!

Toggle and Delete

Let's add toggle and delete functionality:

pages/todos.vue
<script setup lang="ts">
import { api } from '~/convex/_generated/api'

const { data: todos, status } = useConvexQuery(api.todos.list, {})
const { mutate: createTodo, pending: isCreating } = useConvexMutation(api.todos.create)
const { mutate: toggleTodo } = useConvexMutation(api.todos.toggle)
const { mutate: deleteTodo } = useConvexMutation(api.todos.remove)

const newTodoText = ref('')

async function handleSubmit() {
  if (!newTodoText.value.trim()) return
  await createTodo({ text: newTodoText.value })
  newTodoText.value = ''
}
</script>

<template>
  <div class="todos">
    <form @submit.prevent="handleSubmit">
      <input v-model="newTodoText" :disabled="isCreating" />
      <button :disabled="isCreating">Add</button>
    </form>

    <ul v-if="status === 'success'">
      <li v-for="todo in todos" :key="todo._id">
        <input type="checkbox" :checked="todo.completed" @change="toggleTodo({ id: todo._id })" />
        <span :class="{ completed: todo.completed }">
          {{ todo.text }}
        </span>
        <button @click="deleteTodo({ id: todo._id })">Delete</button>
      </li>
    </ul>
  </div>
</template>

<style scoped>
.completed {
  text-decoration: line-through;
  opacity: 0.6;
}
</style>

Convex Backend

For reference, here's what the Convex functions look like:

convex/todos.ts
import { v } from 'convex/values'
import { query, mutation } from './_generated/server'

export const list = query({
  handler: async (ctx) => {
    return await ctx.db.query('todos').collect()
  },
})

export const create = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db.insert('todos', {
      text: args.text,
      completed: false,
    })
  },
})

export const toggle = mutation({
  args: { id: v.id('todos') },
  handler: async (ctx, args) => {
    const todo = await ctx.db.get(args.id)
    if (!todo) throw new Error('Todo not found')
    await ctx.db.patch(args.id, { completed: !todo.completed })
  },
})

export const remove = mutation({
  args: { id: v.id('todos') },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.id)
  },
})

Key Concepts

ConceptDescription
QueriesRead data, automatically subscribe to changes
MutationsWrite data, return promises
Status'pending' | 'success' | 'error' | 'idle'
Real-timeChanges sync automatically across all clients
SSRQueries run on server for fast initial load

Next Steps

Fetching Data

Deep dive into query patterns

Mutations

Learn about mutations and optimistic updates

Authentication

Add user authentication

SSR & Hydration

Server-side rendering best practices