Rocicorp Zero transforme le réseau en simple détail d'implémentation. Au lieu d'écrire des appels fetch, des indicateurs de chargement et une logique d'invalidation de cache, vous écrivez des requêtes qui lisent directement depuis un magasin de données local. Zero réplique un sous-ensemble de votre base PostgreSQL vers chaque client en fonction des requêtes qu'il exécute, synchronise les écritures vers le serveur en arrière-plan et maintient chaque client connecté à jour en temps réel. Le résultat ressemble à une application native : les lectures sont instantanées, les écritures sont optimistes et le mode hors-ligne fonctionne le plus souvent tout seul.
En 2026, le mouvement local-first est passé des démonstrations de recherche aux outils de production. Zero, issu de l'équipe derrière Replicache et Reflect, est l'une des options les plus abouties : un moteur de synchronisation piloté par les requêtes qui réplique Postgres dans SQLite côté serveur et envoie exactement les lignes dont chaque client a besoin. Dans ce tutoriel, vous construirez un suivi de tickets collaboratif de zéro et apprendrez l'intégralité du flux de données de Zero.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé (
node --version) - Une instance PostgreSQL 15+ en cours d'exécution avec la réplication logique activée (nous utiliserons Docker)
- Des connaissances de base en React et Next.js App Router
- Une familiarité avec TypeScript et async/await
- Un éditeur de code (VS Code recommandé)
Aucune expérience préalable des moteurs de synchronisation n'est requise : nous expliquerons chaque concept au fur et à mesure.
Ce que vous allez construire
Un suivi de tickets multi-utilisateurs où :
- Les tickets et commentaires se chargent instantanément depuis un magasin local, sans aucun spinner
- Les modifications apparaissent immédiatement (de façon optimiste) et se réconcilient automatiquement avec le serveur
- Chaque navigateur connecté voit les changements en temps réel sans re-fetch manuel
- Les permissions sont appliquées côté serveur : les utilisateurs ne synchronisent que les tickets qu'ils possèdent ou qui leur sont partagés
Toute la couche de données se résume à un schéma, des requêtes et des mutators — aucun endpoint REST, aucun resolver GraphQL, aucune tuyauterie WebSocket.
Comment fonctionne Zero (le modèle mental)
Zero comporte trois pièces mobiles :
zero-cache— un processus serveur qui se connecte à votre Postgres (la source « upstream »), le réplique dans une copie SQLite locale et sert les données aux clients via WebSockets.- Le client — votre application Next.js détient un magasin de données local. Les requêtes lisent depuis ce magasin de façon synchrone ; le magasin local est hydraté et tenu à jour par
zero-cache. - Les requêtes et mutators synchronisés — des fonctions TypeScript qui définissent *ce qu'*un client peut lire et comment il peut écrire. Elles s'exécutent à la fois sur le client (pour un retour instantané) et sur le serveur (comme source de vérité).
Lorsque vous appelez une requête, Zero l'enregistre auprès de zero-cache, qui diffuse les lignes correspondantes puis pousse chaque changement futur. Lorsque vous appelez un mutator, Zero l'applique d'abord localement, puis l'envoie au serveur pour validation et persistance.
Étape 1 : Mise en place du projet
Créez un nouveau projet Next.js et démarrez une instance Postgres.
npx create-next-app@latest zero-tracker --typescript --app --tailwind
cd zero-trackerLancez Postgres avec la réplication logique via Docker Compose. Zero a besoin de wal_level=logical pour diffuser les changements.
# 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:Démarrez-le :
docker compose up -dCréez les tables que Zero répliquera. Enregistrez ceci dans db/init.sql et exécutez-le sur la base de données.
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Étape 2 : Installer Zero et définir le schéma
Installez le paquet Zero :
npm install @rocicorp/zero zodLe schéma est le cœur d'une application Zero. Il reflète vos tables Postgres en TypeScript et vous fournit un constructeur de requêtes entièrement typé. Créez 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],
})
// Un constructeur de requêtes typé utilisé partout où nous lisons des données.
export const zql = createBuilder(schema)
declare module '@rocicorp/zero' {
interface DefaultTypes {
schema: typeof schema
}
}Remarquez que les types de colonnes (string(), number()) décrivent la forme côté client. Zero convertit pour vous les horodatages BIGINT de Postgres en nombres JavaScript.
Étape 3 : Définir les relations
Les relations vous permettent de naviguer d'un ticket vers ses commentaires et son créateur en une seule requête. Ajoutez-les à 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,
}),
}))Puis enregistrez-les dans createSchema :
export const schema = createSchema({
tables: [user, issue, comment],
relationships: [issueRelationships, commentRelationships],
})Une relation one renvoie une seule ligne liée (le créateur d'un ticket), tandis que many renvoie un tableau (les commentaires d'un ticket).
Étape 4 : Écrire des requêtes synchronisées avec permissions
Dans les versions récentes de Zero, les requêtes synchronisées définissent à la fois quelles données sont lisibles et qui peut les lire. Chaque requête est une fonction TypeScript qui reçoit un argument ctx portant l'utilisateur authentifié. Comme le client ne peut pas altérer ctx, le filtrage qui s'appuie dessus constitue votre couche de permissions de lecture.
Créez zero/queries.ts :
// zero/queries.ts
import {defineQueries, defineQuery} from '@rocicorp/zero'
import {zql} from './schema'
export const queries = defineQueries({
// Tous les tickets créés par l'utilisateur courant, du plus
// récent au plus ancien, avec leur créateur et leurs commentaires.
myIssues: defineQuery(({ctx}) =>
zql.issue
.where('creatorID', ctx.userID)
.related('creator')
.related('comments', q => q.related('creator'))
.orderBy('createdAt', 'desc'),
),
// Un seul ticket avec son fil de commentaires complet.
issueDetail: defineQuery((id: string, {ctx}) =>
zql.issue
.where('id', id)
.where('creatorID', ctx.userID)
.related('comments', q =>
q.related('creator').orderBy('createdAt', 'asc'),
)
.one(),
),
})Quelques points à souligner :
ctx.userIDest contrôlé par le serveur. Même si la requête s'exécute aussi sur le client pour des résultats instantanés, le serveur la ré-exécute en tant qu'autorité, de sorte qu'un client malveillant ne peut pas lire les tickets d'un autre utilisateur..related()accepte un callback de sous-requête, ce qui permet de trier ou de contraindre davantage les lignes liées..one()renvoie une seule ligne ouundefinedau lieu d'un tableau.
C'est un changement majeur par rapport à REST : il n'y a pas d'endpoint par vue. Vous décrivez la forme des données dont un écran a besoin, et Zero la maintient vivante.
Étape 5 : Définir les mutators
Les écritures passent par des mutators — des fonctions typées validées avec Zod. Un mutator s'exécute de façon optimiste sur le client et de façon faisant autorité sur le serveur. Créez 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'})
},
),
})Comme le même code de mutator s'exécute des deux côtés, votre logique de permissions d'écriture et de validation réside à un seul et unique endroit. Définir creatorID à partir de ctx.userID plutôt que de args signifie qu'un client ne peut jamais usurper la propriété.
Étape 6 : Lancer zero-cache
zero-cache est le moteur qui réplique Postgres et sert les clients. Configurez-le via des variables d'environnement. Créez .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"Ajoutez un script à package.json et lancez-le :
{
"scripts": {
"zero": "zero-cache-dev"
}
}npm run zeroLe premier lancement réplique vos tables dans le fichier de réplique SQLite. Gardez ce processus actif dans son propre terminal, aux côtés de next dev.
Étape 7 : Brancher le ZeroProvider
Le provider crée l'instance Zero côté client et la met à disposition des hooks. Dans une vraie application, userID et le jeton auth proviennent de votre fournisseur de session ; ici, nous restons simples. Créez 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}) {
// Remplacez ceci par votre véritable utilisateur authentifié + 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>
)
}La prop context devient le ctx que vos requêtes et mutators synchronisés reçoivent sur le client. Montez le provider dans app/layout.tsx en enveloppant children avec le composant Providers.
Étape 8 : Construire l'interface réactive
Voici la récompense. Le hook useQuery lit depuis le magasin local et se ré-affiche automatiquement dès que les données changent — que le changement provienne de cet utilisateur, d'un autre utilisateur ou de la synchronisation en arrière-plan. Créez app/page.tsx :
// app/page.tsx
'use client'
import {useQuery, 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">Mes tickets</h1>
<div className="mb-8 flex gap-2">
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Titre du nouveau ticket"
className="flex-1 rounded border px-3 py-2"
/>
<button
onClick={handleCreate}
className="rounded bg-violet-600 px-4 py-2 text-white"
>
Ajouter
</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} commentaires
</span>
</div>
<button
onClick={() => z.mutate.closeIssue({id: issue.id})}
className="mt-2 text-sm text-gray-400 hover:text-red-500"
>
Fermer
</button>
</li>
))}
</ul>
</main>
)
}Saisissez un titre, cliquez sur Ajouter, et le ticket apparaît avant même la fin de l'aller-retour réseau. Ouvrez la même page dans un second onglet et regardez les nouveaux tickets arriver en direct — sans polling, sans refetch manuel.
Étape 9 : Les mutations optimistes en pratique
Vous avez déjà écrit du code optimiste sans vous en rendre compte. Lorsque z.mutate.createIssue(...) s'exécute, Zero :
- Applique l'insertion au magasin local de façon synchrone, ce qui ré-affiche
useQueryinstantanément. - Envoie la mutation au serveur pour validation et persistance dans Postgres.
- Si le serveur la rejette (par exemple, un titre de plus de 120 caractères échoue à la validation Zod), Zero annule le changement local automatiquement.
Vous ne touchez jamais à un état de chargement ni à une annulation manuelle source d'erreurs. Pour faire remonter les erreurs serveur, entourez l'appel d'un try/catch et affichez une notification en cas d'échec.
Étape 10 : Gestionnaires de requête et de mutation côté serveur
Pour que les permissions fassent autorité, zero-cache rappelle votre application afin d'exécuter les requêtes et mutators synchronisés avec le vrai contexte authentifié. Créez les deux routes API référencées dans votre .env.
Le gestionnaire de requête :
// 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)
}Le gestionnaire de mutation suit la même forme avec handlePushRequest et votre dbProvider (un adaptateur Drizzle ou Postgres brut). L'idée clé : le ctx du client est un indice pour l'optimisme, mais le serveur dérive ctx du cookie de session et ré-exécute chaque requête et chaque mutator comme source de vérité. Un client falsifié obtient simplement des résultats corrects, filtrés par les permissions.
Tester votre implémentation
Vérifiez la boucle complète :
- Lectures instantanées : rechargez la page — les tickets apparaissent sans spinner.
- Synchronisation en temps réel : ouvrez deux onglets côte à côte ; créez un ticket dans l'un et confirmez qu'il apparaît dans l'autre en un instant.
- Écritures optimistes : limitez votre réseau dans les DevTools à « Slow 3G » et ajoutez un ticket — il doit tout de même apparaître immédiatement, puis persister.
- Permissions : changez le
userIDdansProviderspouruser-2et confirmez que vous ne voyez plus les tickets deuser-1. - Annulation de validation : envoyez temporairement un titre de 200 caractères et confirmez que la ligne optimiste disparaît après le rejet du serveur.
Dépannage
zero-cache s'arrête avec une erreur de réplication. Votre Postgres doit tourner avec wal_level=logical. Vérifiez avec SHOW wal_level; — il doit afficher logical. L'option command de Docker ci-dessus le configure.
Les requêtes renvoient des tableaux vides alors que des lignes existent. Votre requête synchronisée filtre sur ctx.userID, et le creatorID de vos données initiales ne correspond pas au userID du provider. Insérez une ligne avec creatorID = 'user-1'.
Les types sont any dans le constructeur de requêtes. Assurez-vous que le bloc declare module '@rocicorp/zero' dans schema.ts est présent afin que le constructeur récupère les types de votre schéma.
Les changements ne se synchronisent pas entre onglets. Confirmez que NEXT_PUBLIC_ZERO_CACHE_URL pointe vers le zero-cache en cours d'exécution (port 4848 par défaut) et que le cache et next dev tournent tous les deux.
Prochaines étapes
- Ajoutez le partage : introduisez une table
issueShareet étendezmyIssuesavec une conditionor()pour que les utilisateurs voient les tickets partagés avec eux, sur le modèle du schéma de permissions de lecture. - Remplacez l'authentification de démonstration par une vraie session avec Better Auth ou Auth.js v5.
- Générez votre schéma Zero à partir d'un schéma Drizzle ORM existant avec
drizzle-zero. - Comparez les approches avec nos tutoriels ElectricSQL et TanStack DB pour choisir le bon moteur de synchronisation pour votre stack.
Conclusion
Vous avez construit un suivi de tickets en temps réel et local-first sans écrire le moindre appel fetch, indicateur de chargement ou hook d'invalidation de cache. Le modèle de Zero — un schéma typé, des requêtes synchronisées qui font aussi office de permissions de lecture, et des mutators validés qui s'exécutent à la fois sur le client et le serveur — réduit la pile habituelle de récupération de données à une poignée de fonctions pures. Les lectures sont instantanées parce qu'elles touchent un magasin local ; les écritures semblent instantanées parce qu'elles sont optimistes ; et chaque client reste cohérent parce que zero-cache diffuse les changements de Postgres en temps réel.
La leçon plus profonde est architecturale : lorsque la synchronisation est une primitive plutôt qu'une chose que vous assemblez à partir de REST, de WebSockets et d'un cache client, des catégories entières de bugs — données obsolètes, conditions de course, invalidations oubliées — disparaissent tout simplement. À mesure que l'outillage local-first mûrit tout au long de 2026, Zero constitue un excellent choix par défaut pour les applications collaboratives qui doivent paraître instantanées.