An offline-first kanban prototype that demonstrates a modern approach to building React applications that work fully offline and sync without conflict.
The interesting part is the conflict-resolution layer. Most offline-first apps use a local sync queue plus a last-write-wins resolver. This one uses a CRDT (Yjs) as the data layer, so concurrent edits from multiple tabs or devices merge automatically with no resolver code at all.
The server is a Hocuspocus Node.js process. Persistence runs on Neon Postgres (free tier, no credit card).
Client
- React 18 + TypeScript + Vite
- Redux Toolkit (UI/sync state)
- Redux Saga (orchestration: online detection, provider status)
- Yjs (CRDT data layer)
- y-indexeddb (local persistence)
- y-websocket (sync provider)
- Workbox via vite-plugin-pwa (Service Worker, precache)
Server
- Hocuspocus (Yjs WebSocket backend)
- @hocuspocus/extension-database (pluggable persistence)
- pg (Postgres driver)
- Neon Postgres (managed free tier)
┌──────────────────────────────────────────────────────────┐
│ CLIENT (React + Vite) │
│ │
│ UI ─────► Yjs document ──► y-indexeddb (local) │
│ ▲ │ │
│ │ └─► y-websocket provider │
│ │ │ │
│ RTK + Saga │ │
│ (UI state, │ WebSocket │
│ online status) │ │
└───────────────────────────┼──────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ SERVER (Node.js + Hocuspocus) │
│ │
│ Hocuspocus ──► Database extension ──► pg ──► Neon │
│ (Postgres) │
└──────────────────────────────────────────────────────────┘
The client connects to the server via WebSocket. The server holds an authoritative Y.Doc per board name, persists it to a Postgres yjs_documents table on every change (debounced). When a client reconnects after going offline, the y-websocket provider syncs the diff automatically. No queue code on either side.
In a traditional offline-first design, you queue every mutation locally and replay it against the server when you reconnect. If two clients change the same field while both offline, the server picks a winner by timestamp (last-write-wins) and the loser's change is silently dropped.
With Yjs, both clients' operations merge deterministically and losslessly. No queue, no resolver, no conflict UI. The sync protocol is the standard y-protocols exchange:
- Client connects, sends its state vector.
- Server diffs against its Y.Doc, returns the bytes the client is missing.
- Client applies them.
- Both sides broadcast subsequent local updates to the other in real time.
- If the client disconnects, all edits accumulate locally. On reconnect, the same exchange happens again. No data lost.
Sign up at neon.tech. No credit card. Free tier is 0.5 GB storage and 100 projects.
Create a new project. Copy the connection string. It looks like:
postgres://USER:PASSWORD@HOST/DBNAME?sslmode=require
cp server/.env.example server/.env
# Paste the connection string into server/.env as DATABASE_URL=...cp .env.example .env
# Default VITE_WS_URL=ws://localhost:1234 works for local dev.npm run install:all
npm run devThe first time you connect, the server creates a yjs_documents table in Neon and the client seeds three sample lists.
- Client: http://localhost:5173
- Server: ws://localhost:1234
- Open http://localhost:5173 in two browser tabs.
- Add cards in tab A. Watch them appear in tab B within a second.
- Open DevTools → Network → set "Offline" in tab A.
- Add and move cards in tab A. UI still works; nothing crashes.
- Edit the same card title in tab B at the same time.
- Bring tab A back online.
- Both edits merge. No data is lost. The CRDT handles it.
The server has a Dockerfile suitable for any container host. Tested paths:
- Render (free web service tier): push the repo, set
DATABASE_URLenv var, deploy. Cold starts after 15 min idle. - Railway / Fly.io / Cloudflare Workers: same pattern.
After deploying the server, point the client at it by setting VITE_WS_URL=wss://your-app.onrender.com and rebuilding.
.
├── src/ # React client
│ ├── app/ # Redux store + root saga
│ ├── yjs/ # Y.Doc, provider, CRUD ops
│ ├── features/
│ │ ├── ui/ # UI slice
│ │ ├── sync/ # Sync slice + saga
│ │ └── board/ # Board, List, Card components
│ ├── sw/ # Service Worker registration
│ ├── App.tsx
│ └── main.tsx
├── server/ # Hocuspocus server
│ ├── src/
│ │ ├── index.ts # Server bootstrap
│ │ └── db.ts # pg pool + schema + load/save
│ ├── package.json
│ ├── tsconfig.json
│ ├── .env.example
│ └── Dockerfile
├── package.json # Client (root), concurrent dev scripts
├── vite.config.ts
└── README.md
Yjs owns the kanban data. Redux owns app state that doesn't belong in the document:
- UI ephemeral state (which card is being edited, composer open/closed)
- Network status (online/offline from the browser)
- Sync status (idle / syncing / error / last synced)
Redux Saga earns its keep on the orchestration side: subscribing to online/offline events, watching the y-websocket provider's status channel, and translating those into UI status the user sees in the topbar.
This is a prototype, not a product.
- No drag-and-drop. Moving cards uses buttons.
@dnd-kitwould be a clean drop-in. - No authentication. Single board name (
kanban-board-v1). To add auth, use Hocuspocus'sonAuthenticatehook. - No reconnection backoff strategy beyond y-websocket's defaults.
- Card descriptions are plain strings, not
Y.Text. For true collaborative text editing, swap toY.Textand bind withy-prosemirrorory-quill. - Render free tier has cold starts. Acceptable for a demo. For production, use a paid tier or a different host.