Tutorial
This guide builds a small music app with Zero from scratch. It's a nice way to get a feel for how Zero works and takes about 20 minutes to complete.
You will seed a Postgres database with artists and albums, run zero-cache, add a query and a mutator, and watch data sync across clients in realtime.
If you want to wire Zero into your own app, see Installation. Or, to skip to the finished music app, check out the onboarding sample.
Integrate Zero
Set Up Your Database
You'll need a Postgres database. If you don't have a preferred method, we recommend using Docker:
# IMPORTANT: logical WAL level is required for Zero
# to sync data to its SQLite replica
docker run -d --name zero-postgres \
-e POSTGRES_DB="zero" \
-e POSTGRES_PASSWORD="pass" \
-p 5432:5432 \
postgres:18 \
postgres -c wal_level=logicalThen, create some music-themed tables and seed them with data:
# Creates new albums, artists, fans,
# and favorites tables with sample music data
curl -L https://raw.githubusercontent.com/rocicorp/onboarding/1-install/migrations/0000_seed_music.sql \
| psql postgresql://postgres:pass@localhost:5432/zeroInstall and Run Zero-Cache
Add Zero with your preferred package manager:
npm install @rocicorp/zeroStart the development zero-cache:
ZERO_UPSTREAM_DB="postgres://postgres:pass@localhost:5432/zero" \
npx zero-cache-devZero will start listening on port 4848 and continuously replicate your upstream database into a SQLite replica, which is created by default at zero.db.
Inspect the replica in another terminal:
npx @rocicorp/zero-sqlite3 ./zero.db "SELECT title FROM albums;"
# Abbey Road
# Kind of Blue
# Random Access Memories
# 21
# RevolverOr try reading from zero.db while connected to Postgres. If you change something in Postgres, you'll see it immediately show up in the replica:
Set Up Your Zero Schema
Zero uses a schema.ts file to provide a type-safe query API on the client.
Download the music-app schema:
mkdir -p zero
curl https://raw.githubusercontent.com/rocicorp/onboarding/1-install/packages/zero/src/schema.ts \
-o zero/schema.tsSet Up the Zero Client
Zero has first-class support for React and SolidJS. There is also a low-level API you can use in any TypeScript-based project.
// root.tsx
import {ZeroProvider} from '@rocicorp/zero/react'
import type {ZeroOptions} from '@rocicorp/zero'
import {schema} from './zero/schema.ts'
const opts: ZeroOptions = {
cacheURL: 'http://localhost:4848',
schema
}
function Root() {
return (
<ZeroProvider {...opts}>
<App />
</ZeroProvider>
)
}
// mycomponent.tsx
import {useZero} from '@rocicorp/zero/react'
function MyComponent() {
const zero = useZero()
console.log('clientID', zero.clientID)
}Sync Data
Define Query
Let's add a way to sync albums by artist. In Zero, shared reads live in queries.ts.
// zero/queries.ts
import {defineQueries, defineQuery} from '@rocicorp/zero'
import {z} from 'zod'
import {zql} from './schema.ts'
export const queries = defineQueries({
albums: {
byArtist: defineQuery(
z.object({artistId: z.string()}),
({args: {artistId}}) =>
zql.albums
.where('artistId', artistId)
.orderBy('createdAt', 'asc')
.limit(10)
.related('artist', q => q.one())
)
}
})ZQL is quite powerful and allows you to build queries with filters, sorts, relationships, and more:
Add Query Endpoint
Zero doesn't allow clients to send arbitrary ZQL to zero-cache.
Instead, Zero sends the query name and arguments to the query endpoint on your server, which responds to zero-cache with the authoritative ZQL.
// app/api/query/route.ts
import {handleQueryRequest} from '@rocicorp/zero/server'
import {mustGetQuery} from '@rocicorp/zero'
import {queries} from '../../zero/queries.ts'
import {schema} from '../../zero/schema.ts'
export async function POST(req: Request) {
const result = await handleQueryRequest(
(name, args) => {
const query = mustGetQuery(queries, name)
return query.fn({args})
},
schema,
req
)
return Response.json(result)
}Restart zero-cache with ZERO_QUERY_URL so it knows about the new query endpoint:
ZERO_UPSTREAM_DB="postgres://postgres:pass@localhost:5432/zero" \
ZERO_QUERY_URL="http://localhost:3000/api/query" \
npx zero-cache-devInvoke Query
Use the seeded data to fetch albums for The Beatles under artist_1.
// mycomponent.tsx
import {useQuery} from '@rocicorp/zero/react'
import {queries} from './zero/queries.ts'
function MyComponent() {
const [albums] = useQuery(
queries.albums.byArtist({artistId: 'artist_1'})
)
return albums.map(album => (
<div key={album.id}>{album.title}</div>
))
}This query will run against the zero-cache replica and return Abbey Road and Revolver. The client will update its local datastore with these new albums, and future queries will run optimistically against the local data.
Also, Zero queries are reactive, so if you edit data in Postgres directly, you will see it replicate to the Zero replica and the UI:
Mutate Data
Define Mutators
Now let's add a write path that inserts a new album:
// zero/mutators.ts
import {defineMutators, defineMutator} from '@rocicorp/zero'
import {z} from 'zod'
export const mutators = defineMutators({
albums: {
create: defineMutator(
z.object({
id: z.string(),
artistId: z.string(),
title: z.string(),
releaseYear: z.number()
}),
async ({args, tx}) => {
await tx.mutate.albums.insert({
...args,
createdAt: Date.now()
})
}
)
}
})You can use the CRUD-style API with tx.mutate.<table>.<method>() to write data. You can also use tx.run(zql.<table>.<method>) to run queries within your mutator.
Register the mutators where you create the Zero client:
import type {ZeroOptions} from '@rocicorp/zero'
import {mutators} from './zero/mutators.ts'
import {schema} from './zero/schema.ts'
const opts: ZeroOptions = {
cacheURL: 'http://localhost:4848',
schema,
mutators
}Add Mutate Endpoint
Zero requires a mutate endpoint that runs on your server and connects directly to Postgres.
First, create a dbProvider with the Postgres adapter that matches your stack:
// src/db-provider.ts
import {zeroDrizzle} from '@rocicorp/zero/server/adapters/drizzle'
import {drizzle} from 'drizzle-orm/node-postgres'
import {Pool} from 'pg'
import {schema} from '../../zero/schema.ts'
import * as drizzleSchema from '../../drizzle/schema.ts'
const pool = new Pool({
connectionString: process.env.ZERO_UPSTREAM_DB!
})
export const drizzleClient = drizzle(pool, {
schema: drizzleSchema
})
export const dbProvider = zeroDrizzle(schema, drizzleClient)
declare module '@rocicorp/zero' {
interface DefaultTypes {
dbProvider: typeof dbProvider
}
}Add the mutate endpoint itself:
// app/api/mutate/route.ts
import {handleMutateRequest} from '@rocicorp/zero/server'
import {mustGetMutator} from '@rocicorp/zero'
import {mutators} from '../../zero/mutators.ts'
import {dbProvider} from '../../db-provider.ts'
export async function POST(req: Request) {
const result = await handleMutateRequest(
dbProvider,
transact =>
transact((tx, name, args) => {
const mutator = mustGetMutator(mutators, name)
return mutator.fn({args, tx})
}),
req
)
return Response.json(result)
}Restart zero-cache with ZERO_MUTATE_URL configured:
ZERO_UPSTREAM_DB="postgres://postgres:pass@localhost:5432/zero" \
ZERO_QUERY_URL="http://localhost:3000/api/query" \
ZERO_MUTATE_URL="http://localhost:3000/api/mutate" \
npx zero-cache-devInvoke Mutators
We can now add a simple button to add an album for The Beatles:
// mycomponent.tsx
import {useZero} from '@rocicorp/zero/react'
import {mutators} from './zero/mutators.ts'
function MyComponent() {
const zero = useZero()
const onClick = async () => {
const client = await zero.mutate(
mutators.albums.create({
id: crypto.randomUUID(),
artistId: 'artist_1',
title: 'Please Please Me',
releaseYear: 1963
})
).client
if (client.type === 'success') {
console.log('Album created!')
}
}
return <button onClick={onClick}>Create Album</button>
}When you run the mutator, Zero writes to the local database, updates queries optimistically, and then syncs in the background to your mutate endpoint.
Your mutate endpoint writes to Postgres and zero-cache will instantly replicate those changes to other clients:
That's it! You now have a simple, Zero-powered music app. Try opening multiple browser windows to see the realtime sync in action!