Skip to main content
This guide will walk you through building a simple real-time todo application with Zero. You’ll learn how to define schemas, query data, and perform mutations—all with automatic real-time sync.

What We’re Building

A collaborative todo list where:
  • Multiple users see the same todos in real-time
  • Changes sync instantly across all connected clients
  • Queries return in zero milliseconds after initial sync
  • Works offline with optimistic updates
This guide assumes you’ve already installed Zero and have PostgreSQL running.

Step 1: Define Your Schema

Create a file called schema.ts to define your data model:
schema.ts
import {
  table,
  string,
  boolean,
  number,
  createSchema,
  createBuilder,
} from '@rocicorp/zero';

const todo = table('todo')
  .columns({
    id: string(),
    text: string(),
    completed: boolean(),
    created: number(),
  })
  .primaryKey('id');

export const schema = createSchema({
  tables: [todo],
  relationships: [],
});

export const builder = createBuilder(schema);

// Make schema available to TypeScript
declare module '@rocicorp/zero' {
  interface DefaultTypes {
    schema: typeof schema;
  }
}
The createBuilder function creates a type-safe query builder for your schema. The declare module block enables full TypeScript autocomplete.

Step 2: Create PostgreSQL Tables

Create the corresponding PostgreSQL table. You can use Drizzle, Prisma, or raw SQL:
migrations/001_create_todos.sql
CREATE TABLE todo (
  id TEXT PRIMARY KEY,
  text TEXT NOT NULL,
  completed BOOLEAN NOT NULL DEFAULT false,
  created BIGINT NOT NULL
);
Run the migration:
psql $ZERO_UPSTREAM_DB -f migrations/001_create_todos.sql

Step 3: Set Up the Client

Create a React app with Zero’s provider:
App.tsx
import { ZeroProvider } from '@rocicorp/zero/react';
import { schema } from './schema';
import { TodoList } from './TodoList';

function App() {
  return (
    <ZeroProvider
      schema={schema}
      cacheURL="http://localhost:4848"
      userID="user-1"
    >
      <TodoList />
    </ZeroProvider>
  );
}

export default App;

Step 4: Query Data with useQuery

Create a component that queries and displays todos:
TodoList.tsx
import { useQuery, useZero } from '@rocicorp/zero/react';
import { builder } from './schema';
import { nanoid } from 'nanoid';

export function TodoList() {
  const zero = useZero();

  // This query runs locally in ~0ms after initial sync
  const [todos] = useQuery(
    builder.todo
      .orderBy('created', 'desc')
  );

  const addTodo = async (text: string) => {
    await zero.mutate.todo.insert({
      id: nanoid(),
      text,
      completed: false,
      created: Date.now(),
    });
  };

  const toggleTodo = async (id: string, completed: boolean) => {
    await zero.mutate.todo.update({
      id,
      completed: !completed,
    });
  };

  const deleteTodo = async (id: string) => {
    await zero.mutate.todo.delete({ id });
  };

  return (
    <div>
      <h1>Todos ({todos.length})</h1>
      
      <AddTodoForm onAdd={addTodo} />
      
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id, todo.completed)}
            />
            <span style={{ 
              textDecoration: todo.completed ? 'line-through' : 'none' 
            }}>
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

function AddTodoForm({ onAdd }: { onAdd: (text: string) => void }) {
  const [text, setText] = React.useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (text.trim()) {
      onAdd(text);
      setText('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={e => setText(e.target.value)}
        placeholder="What needs to be done?"
      />
      <button type="submit">Add</button>
    </form>
  );
}

Step 5: Start the Zero Cache Server

The zero-cache server syncs data between PostgreSQL and your clients:
npx zero-cache
You should see output like:
🚀 Zero cache server starting...
✓ Connected to PostgreSQL
✓ Replica database initialized
✓ Listening on http://localhost:4848

Step 6: Run Your Application

Start your dev server:
npm run dev
Open http://localhost:5173 (or your dev server’s URL) and try:
  1. Add a todo - It appears instantly
  2. Open a second browser tab - Changes sync in real-time
  3. Toggle completion - All clients update immediately
  4. Go offline - The app still works with optimistic updates

How It Works

1

Initial Sync

When the app loads, zero-client connects to zero-cache and syncs all relevant data to local IndexedDB.
2

Instant Queries

useQuery runs against the local IndexedDB replica, returning results in ~0ms through Incremental View Maintenance.
3

Optimistic Mutations

When you call zero.mutate, the change is applied locally first (optimistic update), then sent to the server.
4

Real-Time Updates

When mutations commit on the server, zero-cache pushes updates to all connected clients via WebSocket.

Advanced Queries

Zero’s query builder supports powerful filtering and relationships:

Filtering

// Get incomplete todos
const [incompleteTodos] = useQuery(
  builder.todo
    .where('completed', false)
    .orderBy('created', 'desc')
);

// Complex conditions
const [recentTodos] = useQuery(
  builder.todo
    .where(({ cmpLit }) => 
      cmpLit(Date.now() - 86400000, '<', 'created')
    )
);

// Multiple filters
const [filteredTodos] = useQuery(
  builder.todo
    .where('completed', false)
    .where(({ cmpLit }) => 
      cmpLit(Date.now() - 86400000, '<', 'created')
    )
);

Relationships

Extend your schema with relationships (example from zbugs):
import { relationships } from '@rocicorp/zero';

const user = table('user')
  .columns({
    id: string(),
    name: string(),
  })
  .primaryKey('id');

const todo = table('todo')
  .columns({
    id: string(),
    text: string(),
    completed: boolean(),
    creatorID: string(),
  })
  .primaryKey('id');

const todoRelationships = relationships(todo, ({ one }) => ({
  creator: one({
    sourceField: ['creatorID'],
    destField: ['id'],
    destSchema: user,
  }),
}));

export const schema = createSchema({
  tables: [user, todo],
  relationships: [todoRelationships],
});
Query with relationships:
const [todosWithCreators] = useQuery(
  builder.todo
    .related('creator')
);

todosWithCreators.forEach(todo => {
  console.log(`${todo.text} by ${todo.creator.name}`);
});

Server-Side Mutations

For complex business logic, define server-side mutations:
server-mutations.ts
import { defineMutator } from '@rocicorp/zero/server';

export const customMutators = {
  archiveCompletedTodos: defineMutator(async (tx) => {
    const completed = await tx.scan({
      prefix: 'todo/',
    });
    
    for (const [key, todo] of completed) {
      if (todo.completed) {
        await tx.put(`archive/${key}`, todo);
        await tx.del(key);
      }
    }
  }),
};
Handle them in your API route:
api/mutate.ts
import { handleMutateRequest } from '@rocicorp/zero/server';
import { customMutators } from '../server-mutations';
import { dbProvider } from '../db';

export default async function handler(req, res) {
  await handleMutateRequest({
    request: req,
    reply: res,
    db: dbProvider,
    mutators: customMutators,
  });
}

Next Steps

Schema Definition

Deep dive into tables, columns, and relationships

Querying Data

Master the query builder API

Mutations

Learn about CRUD operations and custom mutators

Authentication

Secure your application with auth
Check out the zbugs source code for a complete real-world example with authentication, permissions, and complex queries.

Build docs developers (and LLMs) love