Room-based collaborative drawing app: Next.js canvas, Express API, WebSocket relay
Problem
People need to sketch together in real time inside shared rooms, with persisted drawing state so a room survives participants leaving and rejoining.
What it does
Users sign up/login (JWT cookie), create or join rooms by slug from a dashboard, draw shapes (rect, circle, line, arrow) on an HTML5 canvas, and broadcast finished shapes to everyone in the room over WebSockets; the full shape list is saved to Postgres on unload via a 'save' message so rooms can be reloaded later (apps/ws-backend/src/index.ts, apps/excalidraw-frontend/components/Canvas.tsx).
Architecture
[frontend] Next.js app (canvas, dashboard, auth pages) (client) [http] Express HTTP backend (auth + room CRUD, :3001) (api) [ws] ws WebSocket server (join/leave/chat/save, :8080) (worker) [db] PostgreSQL via Prisma (User, Room with state + participants) (store) [common] @repo/common shared package (Zod schemas, env config) (model) frontend ──▶ http · axios withCredentials for signup/login/create-room/get-all-rooms/get-room frontend ──▶ ws · WebSocket with ?token=<JWT> for join-room, chat (shapes), save ws ──▶ db · Prisma: verify room/user, connect/disconnect participants, persist room.state http ──▶ db · Prisma: create users (bcrypt), create rooms, read room state http ──▶ frontend · JWT set as 'cookie' cookie on login common ──▶ ws · messageSchema validation of WS frames common ──▶ http · signup/login/room Zod schemas
Simplified — box-and-arrow sketch of the repo structure, not a system diagram.
Decisions
01
Three-service monorepo (frontend / http / ws) with shared packages
Isolates stateless REST traffic from long-lived WebSocket connections while sharing Zod schemas and Prisma client via @repo/common and @repo/db
Evidence: turbo.json
02
JWT-in-cookie auth reused for WebSocket handshake
Same JWT_SECRET-signed token is verified in the ws connection query param and Express middleware, so one auth path covers both transports
Evidence: apps/ws-backend/src/index.ts
03
In-memory room membership in the ws server
users[] array holds each socket's joined rooms so 'chat' broadcasts only to roommates without a DB round trip per draw
Evidence: apps/ws-backend/src/index.ts
04
Snapshot-based persistence, not event log
Whole shape array is serialized to Room.state and saved on beforeunload / 'save' message, keeping the model simple but coarse-grained
Evidence: apps/excalidraw-frontend/components/Canvas.tsx
05
Full-shape redraw per frame with clearRect
Rather than incremental diffs, the canvas re-renders shapes + currentShape + receivedShapes on every state change
Evidence: apps/excalidraw-frontend/components/Canvas.tsx
06
Shared Zod validation across transports
HTTP bodies and WS messages are parsed with the same schemas (messageSchema requires roomId, guards malformed frames)
Evidence: packages/common/src/zod.ts
The hard parts
- Real-time sync: received shapes are appended to a separate receivedShapes array and only broadcast on mouseUp, so concurrent editors can drift/overwrite each other's stroke order (apps/excalidraw-frontend/hooks/useSocket.ts, components/Canvas.tsx)
- Undo is per-client only (deletedShape stack) and never propagated to other users or persisted (components/Canvas.tsx)
- Room state is a single JSON blob — last writer wins on 'save', no conflict resolution (apps/ws-backend/src/index.ts)
Known limitations
- Hardcoded localhost URLs: http://localhost:3001 in dashboard/canvas pages and CORS origin http://localhost:3000 (app/dashboard/page.tsx, apps/http-backend/src/index.ts)
- JWT_SECRET falls back to literal "123321" if env unset (packages/common/src/secret.ts)
- CORS origin hardcoded; login cookie maxAge is 30*24*60*60 seconds not ms (loginController.ts)
- No tests, no CI, no Dockerfile/compose, no .env.example, no LICENSE in the repo root
- README hedges with 'Tailwind (if used)' / 'React Flow (if used)' — neither React Flow nor it describes actual routing; README is partially stale
- Canvas page file app/canvas/[roomId]/page.tsx appears empty (0 lines read) — routing may be broken or a stray file
- Select/drag only mutates local shape positions in some paths; dragging sync depends on the next chat broadcast