Whiteboard IDE Explained: Building a Software Design Tool with Node.js and React
A new category of developer tooling is emerging: tools that sit before the code editor, not after it. Whiteboard, an open-source IDE for software design that just came out of YC W26, is a good example. Instead of jumping straight into VS Code and writing implementation details, Whiteboard gives engineers a canvas-first environment to sketch architecture, define data flow, and think through a system before a single line of production code exists. This piece breaks down how a tool like this is actually built — the frontend rendering challenges, the backend architecture choices, and what you’d need to know to build something similar yourself.
Why Design-First IDEs Are Having a Moment
Most engineering teams still design systems in Figma, Excalidraw, or on a physical whiteboard, then manually translate that thinking into code. The gap between “design intent” and “implementation” is where a lot of bugs, misalignment, and rework happens. Tools like Whiteboard try to close that gap by keeping design artifacts inside the development workflow — versioned, diffable, and eventually machine-readable enough that AI agents or codegen tools can consume them directly.
This matters for a full-stack audience because building this kind of tool touches almost every layer of the stack:
- A performant canvas rendering engine (React + Canvas/SVG or WebGL)
- A real-time collaboration layer (WebSockets, CRDTs)
- A persistence layer for design documents (Node.js API + database)
- Local-first architecture with sync (IndexedDB, service workers)
- Extensibility via plugins (similar to VS Code’s extension model)
Let’s walk through how each piece typically gets built.
Frontend Architecture: Rendering an Infinite Canvas in React
The core UX challenge in any whiteboard-style tool is rendering thousands of nodes, connectors, and text elements without dropping frames. Plain DOM rendering falls apart past a few hundred elements, so most implementations lean on <canvas> with a custom scene graph, or a library like react-konva for a middle ground.
Scene Graph Basics
A scene graph decouples “what to draw” from “how to draw it.” Each node (box, arrow, text) is a plain JS object; a render loop walks the tree and paints only what’s in the viewport.
type SceneNode = {
id: string;
type: "box" | "arrow" | "text";
x: number;
y: number;
width: number;
height: number;
children?: SceneNode[];
};
function renderScene(
ctx: CanvasRenderingContext2D,
nodes: SceneNode[],
viewport: { x: number; y: number; scale: number }
) {
ctx.save();
ctx.translate(-viewport.x, -viewport.y);
ctx.scale(viewport.scale, viewport.scale);
for (const node of nodes) {
if (!isInViewport(node, viewport)) continue; // culling
drawNode(ctx, node);
}
ctx.restore();
}
Viewport Culling for Performance
The isInViewport check above is what keeps frame rates stable at scale — you never pay the cost of drawing nodes the user can’t see.
function isInViewport(
node: SceneNode,
viewport: { x: number; y: number; scale: number; width: number; height: number }
): boolean {
const visibleLeft = viewport.x;
const visibleTop = viewport.y;
const visibleRight = viewport.x + viewport.width / viewport.scale;
const visibleBottom = viewport.y + viewport.height / viewport.scale;
return (
node.x < visibleRight &&
node.x + node.width > visibleLeft &&
node.y < visibleBottom &&
node.y + node.height > visibleTop
);
}
React + Canvas: Managing State Without Re-Renders
The trap most React devs fall into here is letting node position updates trigger React re-renders on every mouse move. Instead, drag interactions should mutate a ref-backed store and only sync to React state on drag-end.
function useDraggableNode(nodeRef: React.MutableRefObject<SceneNode>) {
const isDragging = useRef(false);
const onPointerMove = useCallback((e: PointerEvent) => {
if (!isDragging.current) return;
nodeRef.current.x += e.movementX;
nodeRef.current.y += e.movementY;
requestAnimationFrame(() => scheduleRedraw());
}, []);
return { onPointerMove };
}
This pattern — mutable refs + requestAnimationFrame scheduling — is the same trick used in high-performance drag-and-drop libraries and is essential if you’re rendering with React at all.
Backend Architecture: Node.js for Real-Time Sync
Design docs in a tool like Whiteboard need to support multiple users editing simultaneously. This is a classic CRDT (Conflict-free Replicated Data Type) or OT (Operational Transform) problem, and Node.js’s event-driven model is well suited to the WebSocket-heavy workload.
WebSocket Server Skeleton
import { WebSocketServer, WebSocket } from "ws";
import { applyOperation, Operation } from "./crdt";
const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map<string, Set<WebSocket>>();
wss.on("connection", (socket, req) => {
const roomId = new URL(req.url!, "http://localhost").searchParams.get("room")!;
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(socket);
socket.on("message", (raw) => {
const op: Operation = JSON.parse(raw.toString());
applyOperation(roomId, op);
for (const client of rooms.get(roomId)!) {
if (client !== socket && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(op));
}
}
});
socket.on("close", () => {
rooms.get(roomId)?.delete(socket);
});
});
Why CRDTs Over OT
Most modern collaborative tools (Figma-adjacent included) favor CRDTs because they don’t require a central server to resolve ordering — clients can merge changes independently, which matters if you want offline-first support.
| Approach | Server dependency | Offline support | Complexity | Common use case |
|---|---|---|---|---|
| Operational Transform (OT) | High — needs central transform authority | Poor | High | Google Docs-style text editing |
| CRDT (e.g. Yjs, Automerge) | Low — peer-to-peer merge possible | Excellent | Medium | Whiteboards, design tools, local-first apps |
| Last-Write-Wins (LWW) | Medium | Poor | Low | Simple config sync, non-critical state |
| Locking / pessimistic concurrency | High | None | Low | Legacy CMS, single-editor documents |
For a design tool where nodes are largely independent objects (boxes, arrows), CRDT libraries like Yjs are the practical choice — you get undo/redo, offline editing, and merge conflict resolution mostly for free.
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(
"wss://sync.example.com",
"design-doc-123",
ydoc
);
const nodesMap = ydoc.getMap<SceneNode>("nodes");
nodesMap.observe((event) => {
event.changes.keys.forEach((change, key) => {
if (change.action === "add" || change.action === "update") {
redrawNode(nodesMap.get(key)!);
}
});
});
Persistence Layer: Storing Design Documents
A design document isn’t a blob of pixels — it’s structured data (nodes, edges, metadata) that needs to be queryable, versionable, and diffable. A common pattern:
- Postgres for document metadata, permissions, and version history
- Object storage (S3-compatible) for exported snapshots (PNG/SVG thumbnails)
- Yjs binary updates stored as append-only logs, compacted periodically
// Express route for saving a compacted snapshot
import express from "express";
import { Pool } from "pg";
const app = express();
const pool = new Pool();
app.post("/api/documents/:id/snapshot", express.raw({ type: "application/octet-stream" }), async (req, res) => {
const { id } = req.params;
const update = req.body as Buffer;
await pool.query(
`INSERT INTO document_snapshots (document_id, update_data, created_at)
VALUES ($1, $2, NOW())`,
[id, update]
);
res.status(201).json({ ok: true });
});
Local-First: IndexedDB as the Source of Truth
To make the app feel instant and work offline, the client should treat local storage as primary and the server as a sync target, not the other way around.
import { IndexeddbPersistence } from "y-indexeddb";
const persistence = new IndexeddbPersistence("design-doc-123", ydoc);
persistence.on("synced", () => {
console.log("Loaded from IndexedDB, now syncing with server");
});
This is the same local-first pattern used by Linear, Excalidraw, and most modern collaborative tools — load instantly from disk, reconcile with the network in the background.
Extensibility: Plugin Architecture
Developer tools live or die by their plugin ecosystem. A minimal plugin API for a design IDE typically exposes lifecycle hooks and a scoped API surface, similar to how VS Code extensions work.
interface WhiteboardPlugin {
name: string;
onLoad?(api: PluginAPI): void;
onNodeCreate?(node: SceneNode, api: PluginAPI): void;
}
interface PluginAPI {
registerCommand(name: string, handler: () => void): void;
getSelectedNodes(): SceneNode[];
createNode(partial: Partial<SceneNode>): SceneNode;
}
const exportToMermaidPlugin: WhiteboardPlugin = {
name: "export-to-mermaid",
onLoad(api) {
api.registerCommand("export.mermaid", () => {
const nodes = api.getSelectedNodes();
console.log(generateMermaidDiagram(nodes));
});
},
};
Sandboxing matters here — running third-party plugin code in the main thread is a security and stability risk. A Web Worker or iframe sandbox with postMessage communication is the safer route in a browser-based build.
Deployment: Self-Hosting with Docker
Since tools like Whiteboard are open source, self-hosting is a major selling point for teams with data residency concerns. A typical docker-compose.yml for this kind of stack:
version: "3.9"
services:
frontend:
build: ./frontend
ports:
- "3000:3000"
depends_on:
- api
api:
build: ./api
environment:
DATABASE_URL: postgres://user:pass@db:5432/whiteboard
REDIS_URL: redis://cache:6379
ports:
- "8080:8080"
depends_on:
- db
- cache
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: whiteboard
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
volumes:
- db-data:/var/lib/postgresql/data
cache:
image: redis:7-alpine
volumes:
db-data:
Redis in this setup typically backs the WebSocket pub/sub layer so you can horizontally scale the API service without losing real-time sync across instances.
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
Comparing Design-First Tools
| Tool | Primary format | Real-time collab | Self-hostable | Codegen output |
|---|---|---|---|---|
| Whiteboard (OSS) | Structured design graph | Yes (CRDT-based) | Yes | Planned/emerging |
| Excalidraw | Freeform vector shapes | Yes | Yes | No |
| Figma | Vector design files | Yes | No | Limited (dev mode) |
| Mermaid.js | Text-to-diagram | No | N/A (static render) | N/A |
| draw.io / diagrams.net | XML-based diagrams | Limited | Yes | No |
The differentiator for a tool positioned as a “software design IDE” rather than a general diagramming tool is the intent to eventually bridge design → code, meaning the underlying data model needs to be closer to an AST than a set of shapes.
Practical Lessons for Building Your Own Version
If you’re inspired to build something similar as a side project or internal tool, a few hard-won lessons apply regardless of stack:
- Don’t render with the DOM past a few hundred nodes. Canvas or WebGL is non-negotiable past that threshold.
- Separate interaction state from render state. Dragging, resizing, and selection should live in refs/mutable stores, not
useState, or you’ll fight React’s reconciler constantly. - **Pick CRDT over OT unless you have a strong reason not to
Never Miss an Article
Stay Updated
Get new deep-dives on JavaScript, TypeScript, Go, and cloud-native engineering delivered to your reader.
Written by
Aditya RawasFull-stack engineer writing deep-dives on JavaScript, TypeScript, React, AWS, Docker, and Kubernetes. Passionate about making complex engineering concepts accessible to developers at every level.