Client-first sync with Zero and on-zero - instant UI, offline support, real-time updates
Takeout uses Zero for data sync, wrapped with
on-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 on-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:
typeMutatorContext={
tx: Transaction // database transaction
authData: AuthData |null// current user
environment:'server'|'client'
can:(where, obj)=>Promise<void>
server?:{
actions: ServerActions
asyncTasks: AsyncAction[]
}
}
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
// caller generates these values
await zero.mutate.post.insert({
id:randomId(),// generated before mutation
createdAt: Date.now(),// captured before mutation
userId: currentUser.id,
content:'Hello',
})
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:
// from chat app: insertServer.ts
asyncfunctioninsertServer(tx, server){
await tx.mutate.server.insert(server)
// derive role IDs from server ID - same on client and server
Zero permissions are flexible—you can use plain functions or whatever technique
you choose. on-zero provides a serverWhere() helper that makes “query-based”
permissions easy. These run on the server but always pass on the client.
In general, you always want to do any long-running work
(non-Zero-mutation-related) inside an asyncTask. Zero keeps the transaction
open until your mutator resolves on the server, which will slow your database
down.
Code Generation
Your bun dev automatically watches and re-generates on-zero glue code for
you.
To run manually:
bun zero:generate
This creates files in src/data/generated/:
types.ts - TypeScript types from schemas
tables.ts - Table schema exports
models.ts - Aggregated model exports
syncedQueries.ts - Query functions wrapped with valibot validators
Import types from ~/data/types:
importtype{ Post, PostUpdate, User }from'~/data/types'