writing/tutorial/2026/06
TutorialJun 4, 2026·32 min read

Build a Local-First App with Rocicorp Zero and Next.js (2026)

Learn how to build an instant, local-first issue tracker with Rocicorp Zero and Next.js. This hands-on tutorial covers the Zero schema, relationships, synced queries with permissions, custom mutators, zero-cache, and reactive UI with the useQuery hook backed by PostgreSQL.

Rocicorp Zero turns the network into an implementation detail. Instead of writing fetch calls, loading spinners, and cache-invalidation logic, you write queries that read directly from a local datastore. Zero replicates a subset of your PostgreSQL database to each client based on the queries it runs, syncs writes back to the server in the background, and keeps every connected client up to date in real time. The result feels like a native app — reads are instant, writes are optimistic, and offline mostly just works.

In 2026 the local-first movement has moved from research demos to production tooling. Zero, from the team behind Replicache and Reflect, is one of the most polished options: a query-driven sync engine that replicates Postgres into SQLite on the server and ships exactly the rows each client needs. In this tutorial you will build a collaborative issue tracker from scratch and learn the full Zero data flow.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • A running PostgreSQL 15+ instance with logical replication enabled (we will use Docker)
  • Basic knowledge of React and Next.js App Router
  • Familiarity with TypeScript and async/await
  • A code editor (VS Code recommended)

You do not need prior experience with sync engines — we will explain every concept as we go.

What You'll Build

A multi-user issue tracker where:

  • Issues and comments load instantly from a local store, with no spinners
  • Edits appear immediately (optimistic) and reconcile with the server automatically
  • Every connected browser sees changes in real time without manual refetching
  • Permissions are enforced server-side: users only sync issues they own or that are shared with them

The entire data layer is just schema, queries, and mutators — no REST endpoints, no GraphQL resolvers, no WebSocket plumbing.

How Zero Works (the mental model)

Zero has three moving parts:

  1. zero-cache — a server process that connects to your Postgres (the "upstream"), replicates it into a local SQLite copy, and serves data to clients over WebSockets.
  2. The client — your Next.js app holds a local datastore. Queries read from it synchronously; the local store is hydrated and kept fresh by zero-cache.
  3. Synced queries and mutators — TypeScript functions that define what a client can read and how it can write. They run on both the client (for instant feedback) and the server (as the source of truth).

When you call a query, Zero registers it with zero-cache, which streams the matching rows down and then pushes every future change. When you call a mutator, Zero applies it locally first, then sends it to the server to be validated and persisted.

Step 1: Project Setup

Create a fresh Next.js project and start a Postgres instance.

npx create-next-app@latest zero-tracker --typescript --app --tailwind
cd zero-tracker

Spin up Postgres with logical replication using Docker Compose. Zero needs wal_level=logical to stream changes.

# docker-compose.yml
services:
  postgres:
    image: postgres:16
    command: postgres -c wal_level=logical
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: zero
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

Start it:

docker compose up -d

Create the tables Zero will replicate. Save this as db/init.sql and run it against the database.

CREATE TABLE "user" (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL
);
 
CREATE TABLE issue (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  status TEXT NOT NULL DEFAULT 'open',
  "creatorID" TEXT NOT NULL REFERENCES "user"(id),
  "createdAt" BIGINT NOT NULL
);
 
CREATE TABLE comment (
  id TEXT PRIMARY KEY,
  body TEXT NOT NULL,
  "issueID" TEXT NOT NULL REFERENCES issue(id),
  "creatorID" TEXT NOT NULL REFERENCES "user"(id),
  "createdAt" BIGINT NOT NULL
);
docker compose exec -T postgres psql -U postgres -d zero < db/init.sql

Step 2: Install Zero and Define the Schema

Install the Zero package:

npm install @rocicorp/zero zod

The schema is the heart of a Zero app. It mirrors your Postgres tables in TypeScript and gives you a fully typed query builder. Create zero/schema.ts:

// zero/schema.ts
import {
  createSchema,
  createBuilder,
  table,
  string,
  number,
} from '@rocicorp/zero'
 
const user = table('user')
  .columns({
    id: string(),
    name: string(),
  })
  .primaryKey('id')
 
const issue = table('issue')
  .columns({
    id: string(),
    title: string(),
    description: string(),
    status: string(),
    creatorID: string(),
    createdAt: number(),
  })
  .primaryKey('id')
 
const comment = table('comment')
  .columns({
    id: string(),
    body: string(),
    issueID: string(),
    creatorID: string(),
    createdAt: number(),
  })
  .primaryKey('id')
 
export const schema = createSchema({
  tables: [user, issue, comment],
})
 
// A typed query builder used everywhere we read data.
export const zql = createBuilder(schema)
 
declare module '@rocicorp/zero' {
  interface DefaultTypes {
    schema: typeof schema
  }
}

Notice the column types (string(), number()) describe the client-side shape. Zero maps Postgres BIGINT timestamps to JavaScript numbers for you.

Step 3: Define Relationships

Relationships let you traverse from an issue to its comments and creator in a single query. Add them to zero/schema.ts:

import {relationships} from '@rocicorp/zero'
 
const issueRelationships = relationships(issue, ({one, many}) => ({
  creator: one({
    sourceField: ['creatorID'],
    destField: ['id'],
    destSchema: user,
  }),
  comments: many({
    sourceField: ['id'],
    destField: ['issueID'],
    destSchema: comment,
  }),
}))
 
const commentRelationships = relationships(comment, ({one}) => ({
  creator: one({
    sourceField: ['creatorID'],
    destField: ['id'],
    destSchema: user,
  }),
}))

Then register them in createSchema:

export const schema = createSchema({
  tables: [user, issue, comment],
  relationships: [issueRelationships, commentRelationships],
})

A one relationship returns a single related row (an issue's creator), while many returns an array (an issue's comments).

Step 4: Write Synced Queries with Permissions

In modern Zero, synced queries are how you define both what data is readable and who can read it. Each query is a TypeScript function that receives a ctx argument carrying the authenticated user. Because the client cannot tamper with ctx, filtering by it is your read-permission layer.

Create zero/queries.ts:

// zero/queries.ts
import {defineQueries, defineQuery} from '@rocicorp/zero'
import {zql} from './schema'
 
export const queries = defineQueries({
  // All issues the current user created, newest first,
  // with their creator and comment count available.
  myIssues: defineQuery(({ctx}) =>
    zql.issue
      .where('creatorID', ctx.userID)
      .related('creator')
      .related('comments', q => q.related('creator'))
      .orderBy('createdAt', 'desc'),
  ),
 
  // A single issue plus its full comment thread.
  issueDetail: defineQuery((id: string, {ctx}) =>
    zql.issue
      .where('id', id)
      .where('creatorID', ctx.userID)
      .related('comments', q =>
        q.related('creator').orderBy('createdAt', 'asc'),
      )
      .one(),
  ),
})

A few things to call out:

  • ctx.userID is server-controlled. Even though the query also runs on the client for instant results, the server re-runs it as the authority, so a malicious client cannot read another user's issues.
  • .related() accepts a sub-query callback, letting you order or further constrain related rows.
  • .one() returns a single row or undefined instead of an array.

This is a big shift from REST: there is no endpoint per view. You describe the shape of data a screen needs, and Zero keeps it live.

Step 5: Define Mutators

Writes go through mutators — typed functions validated with Zod. A mutator runs optimistically on the client and authoritatively on the server. Create zero/mutators.ts:

// zero/mutators.ts
import {defineMutators, defineMutator} from '@rocicorp/zero'
import {z} from 'zod'
 
export const mutators = defineMutators({
  createIssue: defineMutator(
    z.object({
      id: z.string(),
      title: z.string().min(1).max(120),
      description: z.string().default(''),
    }),
    async ({tx, ctx, args}) => {
      await tx.mutate.issue.insert({
        id: args.id,
        title: args.title,
        description: args.description,
        status: 'open',
        creatorID: ctx.userID,
        createdAt: Date.now(),
      })
    },
  ),
 
  addComment: defineMutator(
    z.object({
      id: z.string(),
      issueID: z.string(),
      body: z.string().min(1),
    }),
    async ({tx, ctx, args}) => {
      await tx.mutate.comment.insert({
        id: args.id,
        issueID: args.issueID,
        body: args.body,
        creatorID: ctx.userID,
        createdAt: Date.now(),
      })
    },
  ),
 
  closeIssue: defineMutator(
    z.object({id: z.string()}),
    async ({tx, args}) => {
      await tx.mutate.issue.update({id: args.id, status: 'closed'})
    },
  ),
})

Because the same mutator code runs on both sides, your write-permission and validation logic lives in exactly one place. Setting creatorID from ctx.userID rather than from args means a client can never forge ownership.

Step 6: Run zero-cache

zero-cache is the engine that replicates Postgres and serves clients. Configure it through environment variables. Create .env:

ZERO_UPSTREAM_DB="postgres://postgres:pass@localhost:5432/zero"
ZERO_REPLICA_FILE="/tmp/zero-tracker.db"
ZERO_QUERY_URL="http://localhost:3000/api/zero/query"
ZERO_MUTATE_URL="http://localhost:3000/api/zero/mutate"
NEXT_PUBLIC_ZERO_CACHE_URL="http://localhost:4848"

Add a script to package.json and start it:

{
  "scripts": {
    "zero": "zero-cache-dev"
  }
}
npm run zero

The first run replicates your tables into the SQLite replica file. Keep this process running in its own terminal alongside next dev.

Step 7: Wire Up the ZeroProvider

The provider creates the client-side Zero instance and makes it available to hooks. In a real app the userID and auth token come from your session provider; here we keep it simple. Create app/providers.tsx:

// app/providers.tsx
'use client'
 
import {ZeroProvider} from '@rocicorp/zero/react'
import {schema} from '@/zero/schema'
import {mutators} from '@/zero/mutators'
 
const cacheURL = process.env.NEXT_PUBLIC_ZERO_CACHE_URL!
 
export function Providers({children}: {children: React.ReactNode}) {
  // Replace this with your real authenticated user + JWT.
  const userID = 'user-1'
  const auth = 'demo-token'
 
  return (
    <ZeroProvider
      userID={userID}
      auth={auth}
      context={{userID}}
      cacheURL={cacheURL}
      schema={schema}
      mutators={mutators}
    >
      {children}
    </ZeroProvider>
  )
}

The context prop becomes the ctx that your synced queries and mutators receive on the client. Mount the provider in app/layout.tsx by wrapping children with the Providers component.

Step 8: Build the Reactive UI

Now the payoff. The useQuery hook reads from the local store and re-renders automatically whenever the data changes — whether the change came from this user, another user, or the background sync. Create app/page.tsx:

// app/page.tsx
'use client'
 
import {useQuery} from '@rocicorp/zero/react'
import {useZero} from '@rocicorp/zero/react'
import {queries} from '@/zero/queries'
import {mutators} from '@/zero/mutators'
import {useState} from 'react'
 
export default function Home() {
  const z = useZero<typeof mutators>()
  const [issues] = useQuery(queries.myIssues())
  const [title, setTitle] = useState('')
 
  async function handleCreate() {
    if (!title.trim()) return
    await z.mutate.createIssue({
      id: crypto.randomUUID(),
      title,
      description: '',
    })
    setTitle('')
  }
 
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-6 text-2xl font-bold">My Issues</h1>
 
      <div className="mb-8 flex gap-2">
        <input
          value={title}
          onChange={e => setTitle(e.target.value)}
          placeholder="New issue title"
          className="flex-1 rounded border px-3 py-2"
        />
        <button
          onClick={handleCreate}
          className="rounded bg-violet-600 px-4 py-2 text-white"
        >
          Add
        </button>
      </div>
 
      <ul className="space-y-3">
        {issues.map(issue => (
          <li key={issue.id} className="rounded border p-4">
            <div className="flex items-center justify-between">
              <span className="font-medium">{issue.title}</span>
              <span className="text-sm text-gray-500">
                {issue.comments.length} comments
              </span>
            </div>
            <button
              onClick={() => z.mutate.closeIssue({id: issue.id})}
              className="mt-2 text-sm text-gray-400 hover:text-red-500"
            >
              Close
            </button>
          </li>
        ))}
      </ul>
    </main>
  )
}

Type a title, click Add, and the issue appears before the network round-trip completes. Open the same page in a second browser tab and watch new issues stream in live — no polling, no manual refetch.

Step 9: Optimistic Mutations in Practice

You already wrote optimistic code without realizing it. When z.mutate.createIssue(...) runs, Zero:

  1. Applies the insert to the local store synchronously, so useQuery re-renders instantly.
  2. Sends the mutation to the server to be validated and committed to Postgres.
  3. If the server rejects it (for example, a title longer than 120 characters fails Zod validation), Zero rolls back the local change automatically.

You never touch a loading state or an error-prone manual rollback. To surface server errors, wrap the call in try/catch and show a toast on failure.

Step 10: Server-Side Query and Mutate Handlers

For permissions to be authoritative, zero-cache calls back into your app to run synced queries and mutators with the real authenticated context. Create the two API routes referenced in your .env.

The query handler:

// app/api/zero/query/route.ts
import {handleQueryRequest} from '@rocicorp/zero/server'
import {mustGetQuery} from '@rocicorp/zero'
import {queries} from '@/zero/queries'
import {schema} from '@/zero/schema'
import {authenticate} from '@/lib/auth'
 
export async function POST(req: Request) {
  const session = await authenticate(req.headers.get('Cookie'))
 
  const result = await handleQueryRequest(
    (name, args) => {
      const query = mustGetQuery(queries, name)
      return query.fn({args, ctx: {userID: session.userID}})
    },
    schema,
    req,
  )
 
  return Response.json(result)
}

The mutate handler follows the same shape using handlePushRequest and your dbProvider (a Drizzle or raw-Postgres adapter). The key idea: the client's ctx is a hint for optimism, but the server derives ctx from the session cookie and re-runs every query and mutator as the source of truth. A tampered client simply gets correct, permission-filtered results.

Testing Your Implementation

Verify the full loop:

  1. Instant reads: reload the page — issues appear with no spinner.
  2. Real-time sync: open two tabs side by side; create an issue in one and confirm it appears in the other within a moment.
  3. Optimistic writes: throttle your network in DevTools to "Slow 3G" and add an issue — it should still appear immediately, then persist.
  4. Permissions: change the userID in Providers to user-2 and confirm you no longer see user-1's issues.
  5. Validation rollback: temporarily send a 200-character title and confirm the optimistic row disappears after the server rejects it.

Troubleshooting

zero-cache exits with a replication error. Your Postgres must run with wal_level=logical. Confirm with SHOW wal_level; — it should print logical. The Docker command flag above sets this.

Queries return empty arrays even though rows exist. Your synced query is filtering by ctx.userID, and the creatorID in your seed data does not match the provider's userID. Seed a row with creatorID = 'user-1'.

Types are any in the query builder. Make sure the declare module '@rocicorp/zero' block in schema.ts is present so the builder picks up your schema types.

Changes do not sync between tabs. Confirm NEXT_PUBLIC_ZERO_CACHE_URL points at the running zero-cache (default port 4848) and that both the cache and next dev are running.

Next Steps

  • Add sharing: introduce an issueShare table and extend myIssues with an or() condition so users see issues shared with them, mirroring the read-permission pattern.
  • Swap the demo auth for a real session using Better Auth or Auth.js v5.
  • Generate your Zero schema from an existing Drizzle ORM schema with drizzle-zero.
  • Compare approaches with our ElectricSQL and TanStack DB tutorials to choose the right sync engine for your stack.

Conclusion

You built a real-time, local-first issue tracker without writing a single fetch call, loading spinner, or cache-invalidation hook. Zero's model — a typed schema, synced queries that double as read permissions, and validated mutators that run on both client and server — collapses the usual data-fetching stack into a handful of pure functions. Reads are instant because they hit a local store; writes feel instant because they are optimistic; and every client stays consistent because zero-cache streams changes from Postgres in real time.

The deeper lesson is architectural: when sync is a primitive rather than something you assemble from REST, WebSockets, and a client cache, entire categories of bugs — stale data, race conditions, forgotten invalidations — simply disappear. As local-first tooling matures through 2026, Zero is a strong default for collaborative apps that need to feel instant.