Every workspace eventually grows a blank canvas.
Docs are great for prose. Tasks are great for work that has a shape. But some thinking only happens on an infinite surface: a system diagram, a sprint retro with sticky notes, a user-flow sketched in real time while three people argue about it. That is the Miro / FigJam shaped hole, and until last month OneCamp did not have one.
This post is about filling it. Not with an iframe to someone else’s SaaS, but with a real multiplayer whiteboard that runs entirely on your own infrastructure, shares the collaboration plumbing the docs already use, and obeys the exact same permission model as everything else in the workspace.
OneCamp is open-source and self-hosted. That single fact rules out the easy path.
I could not reach for a commercial canvas SDK with per-seat pricing or a cloud sync backend. Whatever I picked had to be license-safe for commercial self-hosting, and the real-time layer had to be something I could run next to the rest of the stack without bolting on a second sync service.
So the two big decisions were made for me, in a good way:
That second decision is the one that made the whole feature small enough to actually finish.
OneCamp’s docs already sync through a Hocuspocus server backed by Yjs, a CRDT library that merges concurrent edits without a central lock. I did not want a second one for boards.
The trick is the document name. Docs connect with their bare UUID. Boards connect with a board: prefix, and the single server routes the two kinds to the right backend hooks:
const BOARD_PREFIX = 'board:'
const parseDocumentName = (documentName) => {
if (documentName.startsWith(BOARD_PREFIX)) {
return { kind: 'board', id: documentName.slice(BOARD_PREFIX.length) }
}
return { kind: 'doc', id: documentName }
}
From there, three hooks do all the work, and each one calls a small internal endpoint on the Go backend:
onAuthenticate verifies (with the user’s own token) that they may join this board, and sets a server-enforced read-only flag for viewers.onLoadDocument seeds the Yjs document from the last persisted state.onStoreDocument debounces and persists the serialized document back to the backend.Docs run their Tiptap transform in these hooks. Boards skip all of that: a board is a raw Yjs document holding Excalidraw elements, so it persists as a single base64-encoded Yjs update. No HTML, no transform, just the canvas state.
Excalidraw keeps its scene as a flat array of elements, each with an id and a monotonically increasing version. Yjs gives me a shared map. The binding between them is the heart of the canvas component, and it is intentionally simple.
On a local edit, every changed element is written into a shared Y.Map keyed by element id, but only if its version is newer than what is already there:
yDoc.transact(() => {
for (const el of elements) {
const existing = yElements.get(el.id)
if (!existing || (existing.version ?? 0) < (el.version ?? 0)) {
yElements.set(el.id, el)
}
}
}, LOCAL_ORIGIN)
That version check is a last-write-wins merge at the element level. Two people dragging two different shapes never conflict. Two people nudging the same shape converge on the higher version. Yjs handles the hard part (merging the map itself across clients); the version gate keeps element updates idempotent and echo-free. The LOCAL_ORIGIN tag lets the observer ignore the writes this client just made, so there is no update loop.
The remote direction is the mirror image: when the shared map changes from somewhere else, the elements flow back into Excalidraw’s scene.
The thing that makes a whiteboard feel alive is seeing other people’s cursors glide around. The thing that makes a whiteboard feel slow is broadcasting that badly.
Cursors ride on Yjs awareness, a separate ephemeral channel that is never persisted into the document. Each client publishes its identity once (name, color derived from the user id, avatar key) and then streams pointer positions. Remote states are rendered as Excalidraw “collaborators”.
Two details matter for production:
A board needs an owner, collaborators, a privacy flag, a soft-delete, a place in the sidebar, and a row in search. OneCamp’s docs already have all of that. So rather than invent a parallel universe, the board is a first-class node in the graph database (Dgraph) that deliberately mirrors the Doc subsystem:
Board node with board_uuid, title, the serialized state, a privacy flag, created-by, and edit / read / comment user lists with reverse edges.board_deleted_at timestamp, so deletes are reversible and auditable.Because the board reuses the doc’s access shape, the authorization checks in the board controllers are the same checks the doc controllers make. The collaboration server’s onAuthenticate calls back into this exact model, so a viewer joins the live session but is forced read-only by the server, not just the client. The client cannot lie its way into editing.
This is the recurring theme of OneCamp: a new surface should inherit the existing rules, not reimplement them.
Drop an image onto the board and the naive approach embeds the bytes (as a data URL) right into the document. That document is synced to every client and persisted on every change. A few screenshots and your “lightweight” canvas state is multiple megabytes that get shipped over the wire on every edit.
So images never enter the board state. When you paste or drop one:
The shared document stays tiny. The bytes live in object storage where bytes belong. And because the image fetch is access-checked, a board’s images are exactly as private as the board.
A blank canvas is intimidating. The fastest way to make it useful is to let someone type “onboarding flow from signup to first value” and watch a real diagram appear.
OneCamp’s boards have an AI panel that turns a prompt into an editable diagram. The important word is editable: it does not paste an image, it generates real Excalidraw elements you can then move, recolor, and rewire.
The server side is deliberately strict. The model is constrained to emit a small JSON graph (nodes and edges, or for UI mockups, a device frame and components), and the backend then:
It supports flowcharts, roadmaps, user journeys, mind maps, org charts, and, my favorite, device UI mockups for mobile and desktop, where the model describes navbars, cards, buttons and inputs and the client renders them as a tidy wireframe inside a phone or browser frame.
The generated elements are appended to the live scene, which means they flow straight into the Yjs document, which means your collaborators watch the diagram materialize on their screens in real time. And the whole thing rides the same model-agnostic AI service as the rest of OneCamp, with the same rate limiter, circuit breaker, and per-user model selection. The workspace owner picks the model; the board does not care.
A board is a conversation as much as a drawing. Comments are pinned to a scene coordinate and live in the same Yjs document, so they sync in real time, persist with the board, and stay anchored as you pan and zoom. Threads, resolve, delete, and emoji reactions all come along.
But a comment that only lives on the canvas is a comment nobody sees until they open the board. So when a board comment @mentions someone, the board does a second, deliberate thing: it mirrors that mention into the workspace’s activity subsystem. A real comment + mention is persisted in the graph and linked to the board, the mentioned people get a notification, and the mention shows up in their activity feed with a one-click jump straight to the board.
The canvas thread is the live display; the activity mirror is how mentions actually reach people. The two are kept in sync without forcing the whole comment system onto the canvas.
Here is the nightmare on any shared canvas. Someone hits select-all, presses delete, and the collaboration server dutifully persists the now-empty board over the good one. Everyone’s work, gone, and the “save” worked perfectly.
So boards keep a version history, designed so storage never grows without bound:
Restoring is owner / editor only, and it first snapshots the current state so the restore is itself reversible. There is an honest caveat I built around rather than hid: because the live document stays in memory while people are connected, a restore takes effect when the board is next opened fresh, and the UI says so plainly.
To round out the safety story there are sensible limits with graceful messaging (a soft warning as a board gets very large, a hard cap that makes AI generation refuse rather than degrade the canvas, prompt-length and image-size caps), and object snapping with alignment guides so diagrams come out tidy.
I skipped a few things deliberately, because shipping the right small thing beats shipping a shaky big one:
A whiteboard is where teams put their least-finished, most-honest thinking: the architecture nobody has approved yet, the org chart mid-reshuffle, the launch plan with the awkward bits. That is precisely the content you least want sitting on a third party’s servers.
OneCamp’s boards run on infrastructure you own, sync through a server you control, store images and snapshots in your own bucket, and generate diagrams with a model you chose. They are multiplayer and modern and a little magical, and none of that requires handing your messiest thinking to someone else.
That is the whole pitch, really. Not “a whiteboard in your app”, but “a whiteboard you actually own”.
OneCamp is an open-source, self-hosted, AI-era workspace: chat, docs, tasks, projects, calls, an AI coworker, and now a real-time collaborative whiteboard, all on your own infrastructure.