An offline-first collaborative notes app β built with ZerithDB, Electron, React & TypeScript.
A production-grade reference implementation showing how to integrate ZerithDB into a real desktop application. Write notes offline. Sync instantly when peers appear. No server. No conflicts. No data loss.
Getting Started Β· Architecture Β· Key Concepts Β· Known Issues Β· Build
This project is a minimal collaborative notes application that works fully offline and syncs peer-to-peer when other instances are reachable. It is intentionally lean β the goal is to serve as a clear, copy-paste reference for developers integrating ZerithDB into their own Electron apps.
All data is stored locally in IndexedDB via ZerithDB's Dexie adapter. Sync happens directly between peers over WebRTC. A signaling server is used only to broker the initial handshake β it never sees your data.
| Capability | Where |
|---|---|
| Local-first writes | db.db("notes").insert() / .update() β writes to IndexedDB immediately, no network needed |
| Offline reads | db.db("notes").find() β always reads from the local store |
| CRDT-based sync | ZerithDB's Yjs engine merges concurrent edits automatically on reconnect |
| Keypair identity | db.auth.signIn() β generates/loads an Ed25519 keypair, no server required |
| Cross-tab peer detection | BroadcastChannel heartbeat β reliably detects other open instances |
| OS connectivity events | window online/offline β badge updates instantly on network change |
| Electron IPC | net.isOnline() in main process, contextBridge for safe renderer exposure |
| Node.js polyfills | vite.config.ts define block β patches global and process for simple-peer |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Electron β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Renderer Process β β
β β (Chromium / Vite) β β
β β β β
β β React UI βββΊ db.db("notes") βββΊ IndexedDB β β
β β β β β
β β zerithdb-sync (Yjs) β β
β β β β β
β β zerithdb-network (WebRTC) β β
β ββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββ β
β β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Main Process (Node.js) β β
β β electron/main.js ββ net.isOnline() β β
β β electron/preload.js ββ contextBridge β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
WebRTC (direct after handshake)
β
ββββββββββββββΌβββββββββββββ
β Signaling Server β
β wss://signal.zerithdb β
β (handshake only) β
βββββββββββββββββββββββββββ
Data never passes through the signaling server. It brokers peer discovery only. Once two instances connect, the relay is completely out of the picture.
my-notes-app/
β
βββ electron/
β βββ main.js # Electron main process β BrowserWindow, net.isOnline()
β βββ preload.js # contextBridge β exposes network-change IPC to renderer
β
βββ src/
β βββ db.ts # createApp() singleton + Note type definition
β βββ auth.ts # ensureIdentity() / getPublicKey() β Ed25519 keypair
β βββ main.tsx # Bootstrap: auth β db.sync.enable() β React root
β βββ App.tsx # Top-level layout β sidebar + editor, selectedNote state
β βββ index.css # All styles (dark sidebar, editor pane, sync badge)
β β
β βββ components/
β βββ NoteList.tsx # Custom useNotes hook β polls db.db("notes").find()
β βββ NoteEditor.tsx # Insert / update / delete β all writes offline-safe
β βββ SyncStatus.tsx # BroadcastChannel heartbeat + window online/offline
β
βββ index.html # Vite entry point
βββ vite.config.ts # Aliases + Node.js global polyfills for simple-peer
βββ tsconfig.json
βββ package.json
βββ electron-builder.config.js # Output: dist/ (win-unpacked + NSIS installer)
βββ .gitignore
| Requirement | Version |
|---|---|
| Node.js | β₯ 18.0.0 |
| npm | β₯ 9.0.0 |
| Operating System | Windows 10+, macOS 12+, Ubuntu 20.04+ |
1. Clone and install
git clone https://github.com//chavanGaneshDatta//zerithdb-electron-starter-template.git
cd zerithdb-electron-starter-template
npm install2. Start in development mode
npm run devThis runs Vite's dev server at http://localhost:5173 and launches Electron simultaneously via concurrently. In development, the Electron window loads the Vite dev server. In production it loads the built dist/index.html.
3. Verify it's working
- The Electron window opens with a dark sidebar and a blank editor pane.
- The sync badge reads
β Offline β changes saved locally - Open a second browser tab at
http://localhost:5173β the badge in both tabs updates toβ Synced Β· 1 peerwithin ~2 seconds. - Create a note in one tab β it appears in the other tab's list automatically.
All ZerithDB configuration lives in src/db.ts:
export const db = createApp({
appId: "my-notes-app-v1", // Namespaces the local IndexedDB store.
// Change this to isolate data between app versions.
sync: {
signalingUrl: "wss://signal.zerithdb.dev", // Swap for ws://localhost:4000 in LAN mode
autoReconnect: true, // Retry P2P connection on network restore
reconnectDelay: 2000, // ms between reconnect attempts
},
});Environment-based config (recommended for production):
signalingUrl: import.meta.env.VITE_SIGNAL_URL ?? "wss://signal.zerithdb.dev",Create a .env file at the project root:
VITE_SIGNAL_URL=ws://192.168.1.10:4000createApp() is called once in src/db.ts and exported as a singleton. It initialises the local IndexedDB store immediately β no network connection is made at this point.
// src/db.ts
export const db = createApp({ appId: "my-notes-app-v1", sync: { ... } });Sync is enabled explicitly after identity is established in src/main.tsx:
await ensureIdentity();
db.sync.enable(); // connects when online, queues writes when offlineThis ordering guarantees every write is signed with the correct keypair from the very first operation.
All writes go to the local CRDT store first. There is no "save failed" state β if IndexedDB is available, the write succeeds immediately.
// INSERT β works with zero connectivity
await db.db("notes").insert({
id, title, body, tags: [],
updatedAt: Date.now(),
authorKey: getPublicKey(),
});
// UPDATE β Yjs merges concurrent edits from peers automatically on reconnect
await db.db("notes").update({ id: note.id }, { title, body, updatedAt: Date.now() });
// DELETE β propagated to peers as a CRDT tombstone
await db.db("notes").delete({ id: note.id });zerithdb-react's useQuery hook is not yet stable in the published alpha. This project implements a custom useNotes hook in NoteList.tsx that calls the imperative API directly:
const result = await db.db("notes").find({});
result.sort((a, b) => b.updatedAt - a.updatedAt);Reactivity comes from two sources:
- ZerithDB
db.sync.on("synced")anddb.sync.on("change")events (best-effort, alpha API). - A 2-second polling interval as a reliable fallback so the list is always fresh.
This will be replaced with the official useQuery hook once zerithdb-react stabilises.
db.sync.on("peer-joined") is not yet implemented in the published alpha. SyncStatus.tsx uses the browser's native BroadcastChannel API instead:
- Each tab broadcasts a
heartbeatmessage containing a UUID every 2 seconds. - Tabs that haven't sent a heartbeat in 6 seconds are considered gone.
- The peer count and badge status update accordingly.
This provides a lightweight best-effort peer visibility mechanism for local instances, and will be replaced with ZerithDB's native peer events once they're available.
// src/auth.ts
identity = await db.auth.signIn();
// β { publicKey: "did:key:z6Mk..." }db.auth.signIn() generates a new Ed25519 keypair on first call and persists it to the local store. On subsequent calls it loads the existing key. No network request is made. No server is involved. The public key is a W3C DID (did:key:...) and is stored as authorKey on every note for authorship attribution.
These are limitations of the ZerithDB alpha SDK, not of this integration pattern. Each has a working workaround in place.
Peer-to-peer synchronization currently depends on ZerithDB alpha networking APIs and signaling availability.
Local offline persistence is fully functional and production-tested independently of peer connectivity.
This starter template prioritizes:
- offline durability,
- local-first writes,
- Electron persistence,
- and production packaging stability.
| Issue | Root Cause | Workaround |
|---|---|---|
useQuery not exported by zerithdb-react |
Alpha package incomplete | Custom useNotes hook with db.db().find() + polling |
useSyncStatus not exported by zerithdb-react |
Alpha package incomplete | Custom hook using BroadcastChannel + window events |
ZerithProvider not exported by zerithdb-react |
Alpha package incomplete | Removed; db.sync.enable() called directly in main.tsx |
@zerithdb/* sub-packages not resolved by Vite |
SDK built for monorepo workspace | resolve.alias in vite.config.ts maps to published packages |
ReferenceError: global is not defined |
simple-peer uses Node.js globals |
define: { global: "globalThis" } in vite.config.ts |
navigator is not defined in Electron main |
navigator is browser-only |
Replaced with net.isOnline() from Electron's net module |
db.sync.on("peer-joined") never fires |
Event emitter not yet implemented | BroadcastChannel heartbeat as described above |
npm run buildOutput structure:
dist/ β Vite renderer bundle (loaded by Electron in production)
βββ win-unpacked/ β Unpacked app directory (Windows)
βββ my-notes-app.exe
βββ resources/
dist/
βββ my-notes-app Setup 0.1.0.exe β NSIS installer (ready to distribute)
Note: The output directory is
dist/(notdist-electron/). The NSIS installer is the file you distribute to end users. Thewin-unpacked/folder is the portable version β useful for testing without running the installer.
To build for a specific platform only:
npx electron-builder --mac # β .dmg
npx electron-builder --win # β NSIS .exe installer
npx electron-builder --linux # β .AppImagePlatform targets are configured in electron-builder.config.js.
To run entirely without internet (e.g. a closed office network):
Step 1 β Start the signaling server on one machine:
npx zerithdb signal --port 4000Step 2 β Find that machine's local IP:
# macOS / Linux
ifconfig | grep inet
# Windows
ipconfigStep 3 β Update src/db.ts:
signalingUrl: "ws://192.168.1.10:4000", // replace with actual LAN IPAll instances on the same network will now discover each other without any internet access. The signaling server can even be stopped after peers have connected β sync continues directly between them.
| Scenario | Behaviour |
|---|---|
| App starts with no internet | Loads all notes from local IndexedDB β zero latency, zero errors |
| User creates / edits a note offline | Written to local CRDT store immediately; queued for sync |
| Connectivity restored | CRDT engine replays queued ops and merges with peers |
| Two users edit the same note offline | Yjs merges both edits without data loss or user prompt |
| App closed mid-sync | IndexedDB state is durable β no data lost on restart |
| Network flaps repeatedly | autoReconnect: true retries silently; badge reflects current state |
| Second tab or window opened | BroadcastChannel detects it within ~2 s; badge shows peer count |
Pull requests are welcome. Before submitting, please verify:
-
npm run devstarts without errors - Creating, editing, and deleting notes works while offline
- The sync badge transitions correctly between
offline,connecting, andsynced - No TypeScript errors (
npx tsc --noEmit) - The Known Issues table is updated if any upstream ZerithDB alpha behaviour has changed
When zerithdb-react export stabilise, the custom hooks (useNotes, useSyncStatus) should be replaced with the official useQuery and useSyncStatus hooks.
MIT Β© Ganesh