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:
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:
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:
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:
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:
Open http://localhost:5173 (or your dev server’s URL) and try:
Add a todo - It appears instantly
Open a second browser tab - Changes sync in real-time
Toggle completion - All clients update immediately
Go offline - The app still works with optimistic updates
How It Works
Initial Sync
When the app loads, zero-client connects to zero-cache and syncs all relevant data to local IndexedDB.
Instant Queries
useQuery runs against the local IndexedDB replica, returning results in ~0ms through Incremental View Maintenance.
Optimistic Mutations
When you call zero.mutate, the change is applied locally first (optimistic update), then sent to the server.
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:
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:
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.