Tutorial

Let's build a small music app with Zero from scratch. It's a nice way to get a feel for how Zero works and takes about 15 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.

Setup

Create a Project

Start with a TypeScript frontend framework:

npx @tanstack/cli@latest create zero-music \
  --package-manager npm --yes --no-intent
cd zero-music

Set Up Your Database

You'll need a Postgres database with logical replication enabled.

# 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=logical

Then, create some music-themed tables and seed them with data (this step uses psql if you don't already have it).

# Creates new albums, artists, fans,
# and favorites tables with sample music data
curl -L https://raw.githubusercontent.com/rocicorp/zero-music/1-install/migrations/0000_seed_music.sql \
  | psql postgres://postgres:pass@localhost:5432/zero

Create a .env file so your app server and zero-cache-dev use the same Postgres connection:

ZERO_UPSTREAM_DB="postgres://postgres:pass@localhost:5432/zero"

Install and Run Zero-Cache

Add Zero and the dependencies used in this tutorial with your preferred package manager:

npm install @rocicorp/zero zod pg
npm install -D @types/pg

This tutorial uses Zod; any Standard Schema-compatible validator works.

Start the development zero-cache:

npx zero-cache-dev

Zero will start listening on port 4848 and continuously replicate your upstream database into a SQLite replica, which is created by default at zero.db.

The replica is an implementation detail and you will not interact with it directly, but you can inspect the replica with zero-sqlite3 in another terminal to see how zero-cache syncs data:

npx @rocicorp/zero-sqlite3 ./zero.db "SELECT title FROM albums ORDER BY id;"
# Abbey Road
# Kind of Blue
# Random Access Memories
# 21
# Revolver

Or try reading from zero.db while connected to Postgres at postgres://postgres:pass@localhost:5432/zero. If you change something in Postgres, you'll see it immediately show up in the replica:

# Uses watch, e.g.: brew install watch
watch -n 0.5 "npx @rocicorp/zero-sqlite3 ./zero.db \
  'SELECT * FROM albums ORDER BY created_at;'"

Integrate Zero

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 src/zero
curl https://raw.githubusercontent.com/rocicorp/zero-music/1-install/packages/zero/src/schema.ts \
  -o src/zero/schema.ts

Set 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.

// src/routes/__root.tsx
import {ZeroProvider} from '@rocicorp/zero/react'
import type {ZeroOptions} from '@rocicorp/zero'
import {
  HeadContent,
  Scripts,
  createRootRoute
} from '@tanstack/react-router'
import type {ReactNode} from 'react'
import {schema} from '../zero/schema'
 
const opts: ZeroOptions = {
  cacheURL: 'http://localhost:4848',
  schema
}
 
export const Route = createRootRoute({
  shellComponent: RootDocument
})
 
function RootDocument({children}: {children: ReactNode}) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <ZeroProvider {...opts}>{children}</ZeroProvider>
        <Scripts />
      </body>
    </html>
  )
}

Sync Data

Define Query

Let's add a way to sync albums by artist. In Zero, shared reads live in queries.ts.

// src/zero/queries.ts
import {defineQueries, defineQuery} from '@rocicorp/zero'
import {z} from 'zod'
import {zql} from './schema'
 
export const queries = defineQueries({
  albums: {
    byArtist: defineQuery(
      z.object({artistId: z.string()}),
      ({args: {artistId}}) =>
        zql.albums
          .where('artistId', artistId)
          .orderBy('releaseYear', 'desc')
          .limit(10)
          .related('artist', q => q.one())
    )
  }
})

These are defined using Zero Query Language (ZQL) - it allows you to build queries with filters, sorts, relationships, and more:

See Reading Data for more on how ZQL works.

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. This prevents clients from reading arbitrary data and is the basis of permissions.

// src/routes/api/query.ts
import {createFileRoute} from '@tanstack/react-router'
import {handleQueryRequest} from '@rocicorp/zero/server'
import {mustGetQuery} from '@rocicorp/zero'
import {queries} from '../../zero/queries'
import {schema} from '../../zero/schema'
 
export const Route = createFileRoute('/api/query')({
  server: {
    handlers: {
      POST: async ({request}) => {
        const result = await handleQueryRequest({
          handler: (name, args) => {
            const query = mustGetQuery(queries, name)
            return query.fn({args})
          },
          schema,
          request,
          userID: null
        })
 
        return Response.json(result)
      }
    }
  }
})

Start your app server in another terminal so zero-cache can reach the query endpoint:

npm run dev

Restart zero-cache with ZERO_QUERY_URL so it knows about the new query endpoint:

# Update localhost:3000 with your local server
ZERO_QUERY_URL="http://localhost:3000/api/query" \
  npx zero-cache-dev

Invoke Query

Use the seeded data to fetch albums for The Beatles under artist_1.

// src/routes/index.tsx
import {createFileRoute} from '@tanstack/react-router'
import {useQuery} from '@rocicorp/zero/react'
import {queries} from '../zero/queries'
 
export const Route = createFileRoute('/')({
  component: Home
})
 
function Home() {
  const [albums] = useQuery(
    queries.albums.byArtist({artistId: 'artist_1'})
  )
 
  return (
    <main>
      <ul>
        {albums.map(album => (
          <li key={album.id}>{album.title}</li>
        ))}
      </ul>
    </main>
  )
}

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:

// src/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:

// src/routes/__root.tsx
import type {ZeroOptions} from '@rocicorp/zero'
import {mutators} from '../zero/mutators'
import {schema} from '../zero/schema'
 
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 node-postgres:

// src/zero/db-provider.ts
import {zeroNodePg} from '@rocicorp/zero/server/adapters/pg'
import {Pool} from 'pg'
import {schema} from './schema'
 
const connectionString = process.env.ZERO_UPSTREAM_DB
if (!connectionString) {
  throw new Error('ZERO_UPSTREAM_DB is not set')
}
 
const pool = new Pool({
  connectionString
})
export const dbProvider = zeroNodePg(schema, pool)
 
// Register global types for mutators on the server
declare module '@rocicorp/zero' {
  interface DefaultTypes {
    dbProvider: typeof dbProvider
  }
}

Add the mutate endpoint itself:

// src/routes/api/mutate.ts
import {createFileRoute} from '@tanstack/react-router'
import {handleMutateRequest} from '@rocicorp/zero/server'
import {mustGetMutator} from '@rocicorp/zero'
import {mutators} from '../../zero/mutators'
import {dbProvider} from '../../zero/db-provider'
 
export const Route = createFileRoute('/api/mutate')({
  server: {
    handlers: {
      POST: async ({request}) => {
        const result = await handleMutateRequest({
          dbProvider,
          handler: transact =>
            transact((tx, name, args) => {
              const mutator = mustGetMutator(mutators, name)
              return mutator.fn({args, tx})
            }),
          request,
          userID: null
        })
 
        return Response.json(result)
      }
    }
  }
})

Restart zero-cache with ZERO_MUTATE_URL configured:

ZERO_QUERY_URL="http://localhost:3000/api/query" \
  ZERO_MUTATE_URL="http://localhost:3000/api/mutate" \
  npx zero-cache-dev

Invoke Mutators

Now add a button to create an album:

// src/routes/index.tsx
import {createFileRoute} from '@tanstack/react-router'
import {useQuery, useZero} from '@rocicorp/zero/react'
import {mutators} from '../zero/mutators'
import {queries} from '../zero/queries'
 
export const Route = createFileRoute('/')({
  component: Home
})
 
function Home() {
  const zero = useZero()
  const [albums] = useQuery(
    queries.albums.byArtist({artistId: 'artist_1'})
  )
 
  const onClick = () => {
    zero.mutate(
      mutators.albums.create({
        id: crypto.randomUUID(),
        artistId: 'artist_1',
        title: 'Please Please Me',
        releaseYear: 1963
      })
    )
  }
 
  return (
    <main>
      <button onClick={onClick}>Create Album</button>
      <ul>
        {albums.map(album => (
          <li key={album.id}>{album.title}</li>
        ))}
      </ul>
    </main>
  )
}

When you run the mutator, Zero writes to the local database, updates queries optimistically, and then syncs in the background to your mutate endpoint. Please Please Me will appear in the album list immediately.

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!

Next Steps