Skip to main content
Zero’s server components process mutations and transform custom queries. This guide covers setting up your API server to work with Zero Cache.

Overview

Zero server architecture:
Client → Zero Cache → Your API Server → PostgreSQL
         (sync)        (mutations/queries)
  • Zero Cache: Manages sync and calls your API for mutations/queries
  • Your API Server: Handles business logic and database writes
  • PostgreSQL: Source of truth

Installation

Install the Zero server package:
npm install @rocicorp/zero

Database Setup

Choose a PostgreSQL Adapter

Zero supports multiple PostgreSQL clients:
  • postgres.js (recommended)
  • Drizzle ORM
  • Prisma
  • node-postgres (pg)

postgres.js Setup

import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs';
import postgres from 'postgres';
import {schema} from './schema.ts';

const sql = postgres(process.env.ZERO_UPSTREAM_DB!);

export const dbProvider = zeroPostgresJS(schema, sql);

Drizzle Setup

import {zeroDrizzle} from '@rocicorp/zero/server/adapters/drizzle';
import {drizzle} from 'drizzle-orm/node-postgres';
import {Pool} from 'pg';
import {schema} from './schema.ts';
import * as drizzleSchema from './drizzle-schema.ts';

const pool = new Pool({connectionString: process.env.ZERO_UPSTREAM_DB!});
const drizzleDb = drizzle(pool, {schema: drizzleSchema});

export const dbProvider = zeroDrizzle(schema, drizzleDb);

Prisma Setup

import {zeroPrisma} from '@rocicorp/zero/server/adapters/prisma';
import {PrismaClient} from '@prisma/client';
import {schema} from './schema.ts';

const prisma = new PrismaClient();

export const dbProvider = zeroPrisma(schema, prisma);

node-postgres Setup

import {zeroPg} from '@rocicorp/zero/server/adapters/pg';
import {Pool} from 'pg';
import {schema} from './schema.ts';

const pool = new Pool({connectionString: process.env.ZERO_UPSTREAM_DB!});

export const dbProvider = zeroPg(schema, pool);

API Server Setup

Create API endpoints for mutations and queries.

Fastify Example

import Fastify from 'fastify';
import {handleMutateRequest, handleQueryRequest} from '@rocicorp/zero/server';
import {dbProvider} from './db.ts';
import {mutators} from './mutators.ts';
import {queries} from './queries.ts';

const app = Fastify();

// Mutation endpoint
app.post('/mutate', async (request, reply) => {
  const response = await handleMutateRequest(
    dbProvider,
    async (transact, mutation) => {
      const mutator = mutators[mutation.name];
      if (!mutator) {
        throw new Error(`Unknown mutation: ${mutation.name}`);
      }
      return await transact(async (tx, name, args) => {
        await mutator(tx, args);
      });
    },
    request.raw
  );
  return response;
});

// Query endpoint
app.post('/query', async (request, reply) => {
  const response = await handleQueryRequest(
    (name, args) => {
      const queryFn = queries[name];
      if (!queryFn) {
        throw new Error(`Unknown query: ${name}`);
      }
      return queryFn(args);
    },
    schema,
    request.raw
  );
  return response;
});

app.listen({port: 3000});

Express Example

import express from 'express';
import {handleMutateRequest, handleQueryRequest} from '@rocicorp/zero/server';
import {dbProvider} from './db.ts';
import {mutators} from './mutators.ts';
import {queries} from './queries.ts';

const app = express();
app.use(express.json());

app.post('/mutate', async (req, res) => {
  const response = await handleMutateRequest(
    dbProvider,
    async (transact, mutation) => {
      const mutator = mutators[mutation.name];
      if (!mutator) {
        throw new Error(`Unknown mutation: ${mutation.name}`);
      }
      return await transact(async (tx, name, args) => {
        await mutator(tx, args);
      });
    },
    new Request(req.url, {
      method: req.method,
      headers: req.headers as HeadersInit,
      body: JSON.stringify(req.body),
    })
  );
  res.json(response);
});

app.post('/query', async (req, res) => {
  const response = await handleQueryRequest(
    (name, args) => {
      const queryFn = queries[name];
      if (!queryFn) {
        throw new Error(`Unknown query: ${name}`);
      }
      return queryFn(args);
    },
    schema,
    new Request(req.url, {
      method: req.method,
      headers: req.headers as HeadersInit,
      body: JSON.stringify(req.body),
    })
  );
  res.json(response);
});

app.listen(3000);

Environment Variables

Configure Zero Cache to use your API endpoints:
# PostgreSQL connection
ZERO_UPSTREAM_DB=postgresql://user:password@localhost:5432/mydb

# API endpoints
ZERO_MUTATE_URL=http://localhost:3000/mutate
ZERO_QUERY_URL=http://localhost:3000/query

# Optional: API authentication
ZERO_MUTATE_API_KEY=your-secret-key
ZERO_QUERY_API_KEY=your-secret-key

Schema Definition

Define your data model using Zero’s schema builder:
import {createSchema, table, string, number, relationships} from '@rocicorp/zero';

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

const issue = table('issue')
  .columns({
    id: string(),
    title: string(),
    description: string(),
    creatorID: string(),
    created: number(),
  })
  .primaryKey('id');

const userRelationships = relationships(user, ({many}) => ({
  issues: many({
    sourceField: ['id'],
    destField: ['creatorID'],
    destSchema: issue,
  }),
}));

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

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

TypeScript Configuration

Declare module types for better TypeScript support:
// db.ts
import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs';
import postgres from 'postgres';
import {schema} from './schema.ts';

export const sql = postgres(process.env.ZERO_UPSTREAM_DB!);
export const dbProvider = zeroPostgresJS(schema, sql);

declare module '@rocicorp/zero' {
  interface DefaultTypes {
    schema: typeof schema;
    dbProvider: typeof dbProvider;
  }
}

Authentication

Forward cookies from clients to your API:
ZERO_MUTATE_FORWARD_COOKIES=true
ZERO_QUERY_FORWARD_COOKIES=true
Access cookies in your API:
app.post('/mutate', async (request, reply) => {
  const sessionCookie = request.cookies.session;
  // Verify session...
  
  const response = await handleMutateRequest(/*...*/);
  return response;
});

API Key Authentication

Protect your API with a shared secret:
app.post('/mutate', async (request, reply) => {
  const apiKey = request.headers['x-api-key'];
  if (apiKey !== process.env.API_SECRET) {
    return reply.code(401).send({error: 'Unauthorized'});
  }
  
  const response = await handleMutateRequest(/*...*/);
  return response;
});
Configure Zero Cache:
ZERO_MUTATE_API_KEY=your-secret-key

Local Development

Start PostgreSQL

Using Docker Compose:
# docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
docker compose up -d

Run Services

  1. Start your API server:
node server.js
  1. Start Zero Cache:
zero-cache-dev
  1. Start your client app:
npm run dev

PostgreSQL Configuration

Enable Logical Replication

Edit postgresql.conf:
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
Restart PostgreSQL:
sudo systemctl restart postgresql

Create Publications

Zero Cache uses publications to determine what to replicate:
-- Replicate all tables in public schema (default)
CREATE PUBLICATION zero_public FOR TABLES IN SCHEMA public;

-- Or replicate specific tables
CREATE PUBLICATION zero_tables FOR TABLE users, issues, comments;
Configure publication:
ZERO_APP_PUBLICATIONS=zero_public

Error Handling

Application Errors

Return user-facing errors from mutators:
import {ApplicationError} from '@rocicorp/zero/server';

export async function createIssue(tx, args) {
  if (!args.title) {
    throw new ApplicationError('Title is required', {
      details: {field: 'title'},
    });
  }
  
  await tx.mutate.issue.insert({
    id: nanoid(),
    title: args.title,
    creatorID: tx.clientID,
    created: Date.now(),
  });
}

Transaction Errors

Database errors are automatically handled and retried if recoverable.

Next Steps

Build docs developers (and LLMs) love