Skip to main content

First Real-Time Page

Render a Convex query during SSR and continue it as a live browser subscription.

Build a small public todo list. The page will render through Nuxt SSR, then stay live through Convex.

Define the schema

convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'

export default defineSchema({
  todos: defineTable({
    text: v.string(),
    completed: v.boolean(),
    createdAt: v.number(),
  }).index('by_created', ['createdAt']),
})

Add the query

convex/todos.ts
import { query } from './_generated/server'

export const list = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db.query('todos').withIndex('by_created').order('desc').take(50)
  },
})

Convex codegen adds api.todos.list to the generated API.

Render the page

app/pages/index.vue
<script setup lang="ts">
import { api } from '#convex/api'

const { data: todos, status, error } = await useConvexQuery(api.todos.list, {})
</script>

<template>
  <main>
    <h1>Todos</h1>

    <p v-if="status === 'pending'">Loading todos…</p>
    <p v-else-if="error">Could not load todos.</p>
    <p v-else-if="todos?.length === 0">No todos yet.</p>

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

Start Nuxt in another terminal:

bash
pnpm dev

Verify SSR

Open the page and inspect its HTML response or disable JavaScript temporarily. Existing todos should appear in the initial HTML.

The query lifecycle is:

SSR HTTP query → Nuxt payload → hydration → WebSocket subscription

Verify live updates

Open the Convex dashboard data view and insert a todo with:

json
{
  "text": "Live from Convex",
  "completed": false,
  "createdAt": 1
}

The open page updates without a refresh. No Pinia store or manual refetch is involved.

The page is read-only. Add an application write in Add a mutation.