Zero
Client-first sync with Zero and over-zero - instant UI, offline support, real-time updates
Takeout uses Zero for data sync, wrapped with over-zero for a streamlined developer experience. Zero is a sync engine that keeps a client-side replica of your data, enabling instant queries and optimistic mutations. The Zero documentation covers the core concepts in depth—this page focuses on how Takeout structures its data layer.
For database schema management and migrations, see the Database page.
How It Works
Zero maintains a partial slice of data on the client. Queries run against the local replica for 0 network latency. Mutations apply optimistically, then sync to Postgres. Changes from other clients stream in automatically.
Directory Structure
The data layer lives in src/data/:
src/data/
├── models/ # table schemas + write permissions + mutations
│ ├── post.ts
│ ├── user.ts
│ └── comment.ts
├── queries/ # query functions + read permissions
│ ├── post.ts
│ ├── user.ts
│ └── comment.ts
├── server/ # server-only code
│ ├── createServerActions.ts
│ └── actions/
├── generated/ # auto-generated (don’t edit)
│ ├── types.ts
│ ├── tables.ts
│ ├── models.ts
│ ├── groupedQueries.ts
│ └── syncedQueries.ts
├── relationships.ts # table relationships
├── schema.ts # schema assembly
└── types.ts # type exports
src/zero/
├── client.tsx # client setup
├── server.ts # server setup
└── types.ts # type augmentation
Models
Models define table schemas, write permissions, and mutations. Each table gets
its own file in src/data/models/:
The mutations() function takes the schema and permissions, then auto-generates
CRUD operations, this is an optional but helpful feature that over-zero
provides:
Custom mutations go in the third argument. They receive a context with the transaction, auth data, and server actions.
Mutation Context
Every mutation receives MutatorContext:
Use ctx.server?.asyncTasks for work that should run after the transaction
commits—analytics, notifications, search indexing.
Convergent Mutations
Mutations run on both client and server. Both must produce identical database state. To avoid lots of “rebasing” back and forth, you want to follow some patterns.
The rule: never generate non-deterministic values inside mutations.
For example, never call Date.now() inside a mutation. Reuse timestamps from
the data you’re given.
Pattern 1: Pass IDs and timestamps from the caller
The mutation receives pre-generated values. Both client and server use the same
id and createdAt. When a mutation creates related entities, derive their IDs
deterministically from the parent:
This pattern scales—channels get ${server.id}-tasks, permissions get
${channel.id}-permission, notifications get ${message.id}-notification.
Pattern 2: Pass “next” IDs for follow-up entities
When a mutation needs to create a follow-up entity (like a draft after sending a message), pass its ID from the caller:
What’s safe inside mutations
Server-only code doesn’t need to converge:
Queries
Queries are plain functions in src/data/queries/ that use the global zql
builder:
Use them with useQuery:
Query Options
Three ways to call useQuery:
Advanced Filtering
Use the expression builder for complex conditions:
Available operators: =, !=, <, >, <=, >=, IN, NOT IN, LIKE,
ILIKE, IS, IS NOT.
Pagination
Use .start() for cursor-based pagination:
The cursor is an object with values for each orderBy field. Pass the last
item’s values to fetch the next page.
Relationships
Define how tables connect in src/data/relationships.ts:
Query related data with .related():
Permissions
Zero permissions are flexible, you can use plain functions or whatever technique
you choose. over-zero has a serverWhere() helper which makes it easy to do
“query-based” permissions. Basically on the server-side they will run, but on
the client they always pass.
Read permissions go in query files:
Write permissions go in model files:
Check permissions in mutations with ctx.can():
Check in React with usePermission():
Server Actions
Server actions run only server-side—for emails, webhooks, analytics, external
APIs. Define them in src/data/server/createServerActions.ts:
Use in mutations:
Async tasks run after the transaction commits.
In general you always want to do any long-running (non zero-mutation related) thing inside an asyncTask. Zero keeps the transaction open so long as your mutator is not resolved on the server, which will slow your database down.
Code Generation
Your bun dev automatially watches and re-generates over-zero glue code for
you.
To run manually:
Terminal
This creates files in src/data/generated/:
types.ts- TypeScript types from schemastables.ts- Table schema exportsmodels.ts- Aggregated model exportssyncedQueries.ts- Query functions wrapped with valibot validators
Import types from ~/data/types:
Client Setup
The client is configured in src/zero/client.tsx:
Wrap your app with the provider:
Storage backends:
‘idb’- IndexedDB for web (persistent)‘sqlite’- SQLite for native (persistent, faster)‘mem’- In-memory for anonymous users or SSR
Server Setup
The server is configured in src/zero/server.ts:
Zero needs two API routes. These are already set up in app/api/zero/.
Type Augmentation
Configure global types in src/zero/types.ts:
Debugging
Add ?debug=2 to your URL for detailed Zero logs.
Common issues:
- Queries not updating - Ensure you’re using
useQuery, not a one-time fetch - Mutations not syncing - Check network tab for push errors; verify permissions
- Type errors after schema change - Run
bun tko zero/generate
Learn More
- Zero Documentation - Core concepts, API reference
- over-zero Package - Helper library details
- Rocicorp GitHub - Source and examples
Edit this page on GitHub.