React • September 25, 2026 • Aditya Rawas • 6 min read

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.

ApproachServer dependencyOffline supportComplexityCommon use case
Operational Transform (OT)High — needs central transform authorityPoorHighGoogle Docs-style text editing
CRDT (e.g. Yjs, Automerge)Low — peer-to-peer merge possibleExcellentMediumWhiteboards, design tools, local-first apps
Last-Write-Wins (LWW)MediumPoorLowSimple config sync, non-critical state
Locking / pessimistic concurrencyHighNoneLowLegacy 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

ToolPrimary formatReal-time collabSelf-hostableCodegen output
Whiteboard (OSS)Structured design graphYes (CRDT-based)YesPlanned/emerging
ExcalidrawFreeform vector shapesYesYesNo
FigmaVector design filesYesNoLimited (dev mode)
Mermaid.jsText-to-diagramNoN/A (static render)N/A
draw.io / diagrams.netXML-based diagramsLimitedYesNo

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:

  1. Don’t render with the DOM past a few hundred nodes. Canvas or WebGL is non-negotiable past that threshold.
  2. 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.
  3. **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.

Aditya Rawas

Written by

Aditya Rawas

Full-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.