In this tutorial, we’ll build an Excalidraw-style collaborative whiteboard using Next.js, HTML5 Canvas, and Velt. You’ll add real-time features like live cursors, comments, presence, and huddles directly into your app. Instead of wiring everything manually, we’ll use Velt MCP and AI agents to handle the integration. We’ll also look at how CRDT-based sync keeps everything in real time.
By the end, you’ll have a fully working multi-user canvas app with production-ready collaboration built in.
What we are building #
- Excalidraw-style infinite whiteboard
- Real-time collaboration with cursors, comments, huddle, notifications, and presence
- Multi-user canvas with shared state
Why add collaboration to Canvas apps #
Single-user by default: Most canvas apps work locally and don’t support multiple users out of the box -Real-time sync is complex: Handling state sync, conflicts, and updates across users is not trivial -Lack of shared context: Without comments, cursors, and presence, collaboration feels disconnected
Why use Velt #
Velt is a collaboration SDK that lets you add real-time, multi-user features directly into your app without building the backend infrastructure yourself. It handles presence, syncing, communication, and UI components out of the box, so you can focus on your product.
-Drop-in collaboration layer: Add features like comments, cursors, and presence without building from scratch -Real-time features built in: Cursors, comments, presence, notifications, and huddles -CRDT-based sync support: Enables conflict-free real-time state updates for multi-user apps -AI-powered setup with MCP: Use Velt MCP and agent skills to automatically install and configure features -No infra needed: No need to manage WebSockets, sync engines, or backend services -Customizable UI components: Easily integrate collaboration UI into your existing design system
Prerequisites #
-
Node.js 18+
-
Velt API key (from Velt dashboard)
-
AI coding editor (Anitgravity is used in this demo)
-
Basic React and TypeScript knowledgeSetting up the project- Clone the repository - https://github.com/Studio1HQ/Velt-Demos/tree/main/excalidraw-velt-demo - Run
npm install -
And then run
npm run dev -
Now, open http://localhost:3000
Tutorial: Building Velt-powered Excalidraw-like App #
Step 1: Set up Velt MCP
Velt MCP lets your editor (Antigravity) run the Velt installer and guide the integration.
Now, add the Velt MCP installer to Antigravity using the command below:
npx -y @velt-js/mcp-installer
Add it to the Antigravity MCP server configuration with** command: "npx"and.**args: ["-y", "@velt-js/mcp-installer"]
Also, Velt Agent Skills guides the AI on what to implement using best practices, while MCP gives it access to the tools needed to actually execute those changes. Together, they make the integration accurate, structured, and reliable. We have both installed and will be used accordingly.Get your Velt API key:
- Go to the Velt Dashboard
- Create a project
- Copy your API key
Add it to your .env
:
NEXT_PUBLIC_VELT_API_KEY=your_api_key_here
Step 2: Start Velt installation using AI
Now that MCP is set up, we can let the AI agent handle the Velt integration for us.
Open your editor (Antigravity) and type:
install velt
This triggers the Velt MCP installer, which runs as a guided setup inside your editor.
Instead of manually adding SDKs and wiring things, the agent walks you through the setup step by step.
It will ask you for a few inputs:
- Yourproject directory- Your** Velt API key and auth token**- The** features you want to enable**(comments, presence, cursors, CRDT, etc.) - Where to place the** VeltProvider**(recommended:
app/page.tsx
) - UI placement preferences (like corner position)
You can answer each step directly in chat. The flow is simple and guided.
Step 3: Provide the prompt for MCP
At this point, we already have a working whiteboard built manually. Now, instead of integrating Velt step by step ourselves, we use Velt Agent Skills to analyze this existing app and plan how collaboration should be added.
In your editor, after running install velt
, provide the following prompt:
I want to start the Velt integration. Review my project structure and use your Velt Agent Skills to plan the CRDT store implementation.
Once you provide this, the agent starts analyzing your codebase. It looks at how your canvas is structured, how state is managed, and where real-time sync can be introduced. Based on this, it generates an integration plan tailored to your whiteboard.
Instead of manually deciding how to structure CRDT or where to wire Velt, the agent uses its skills to plan it correctly for your app.
After reviewing the plan, you can approve it, and the agent will apply the changes step by step.
Step 4: Understand the existing project structure
Before we look at what MCP added, let us understand how this project is structured. Since the agent analyzes your codebase before integrating Velt, this gives context for what it is working with.
The project is organized into three main folders: app
, components
, and lib
.
- The
app/
folder contains the core application logic. This is where the whiteboard is rendered and all canvas interactions like drawing, selecting, and updating elements are handled. - The
components/
folder contains UI elements and collaboration integrations. This is where Velt features are connected to your app, including user identity, comments, and UI-level controls. - The
lib/
folder handles state management and shared logic. It manages canvas data, document context, and sync-ready state, making it easier to extend the app with real-time collaboration.
Step 5: Add real-time collaboration features (Using MCP)
Now that the whiteboard is working, we layer Velt on top to make it collaborative. This is where users start seeing each other, interacting in real time, and sharing context.
After you provide the prompt and approve the plan, the MCP installer integrates Velt into your project. It sets up the foundation required for collaboration to work correctly with your existing whiteboard.
Presence and cursors
In app/layout.tsx, the
VeltProvider
enables real-time awareness across your app. Then in VeltSetup.tsx
, each user is identified using. This is what allows Velt to track who is online.Learn more here
'use client'
import React, { useEffect, useState, Suspense } from "react";
import {
VeltProvider,
useSetDocument,
VeltCursor,
useVeltClient,
} from "@veltdev/react";
import { useCurrentDocument } from "@/lib/useCurrentDocument";
import { TEST_USERS } from "@/lib/users";
import { useSearchParams } from "next/navigation";
function VeltIdentity({ children }: { children: React.ReactNode }) {
const { documentId } = useCurrentDocument();
useSetDocument(documentId ?? "default-whiteboard");
return <>{children}</>;
}
function VeltProviderInner({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
const [user, setUser] = useState(TEST_USERS[0]);
useEffect(() => {
const userIndex = searchParams.get("user");
if (userIndex) {
const index = parseInt(userIndex);
if (!isNaN(index) && TEST_USERS[index]) {
setUser(TEST_USERS[index]);
}
} else {
// Fallback or default behavior
}
}, [searchParams]);
return (
<VeltProvider
apiKey={process.env.NEXT_PUBLIC_VELT_API_KEY!}
authProvider={{
user: user,
}}
>
<VeltIdentity>
{/* <VeltCursor /> */}
{children}
</VeltIdentity>
</VeltProvider>
);
}
export function VeltSetup({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={null}>
<VeltProviderInner>{children}</VeltProviderInner>
</Suspense>
);
}
Once identity is set, Velt automatically shows:
- Active users (avatars)
- Live cursor positions
You don’t have to manually sync cursor movement. Velt handles that internally based on user sessions.
Canvas comments
Comments are one of the most important parts of a canvas app.
"use client";
import { useCommentAnnotations, VeltCommentPin } from "@veltdev/react";
import { Point } from "@/lib/types";
import { useWhiteboardStore } from "@/lib/useWhiteboardStore";
interface CanvasCommentLayerProps {
zoom: number;
pan: Point;
}
In CanvasCommentLayer.tsx, comments are rendered as an overlay on top of the canvas. Instead of attaching comments to DOM elements, we attach them to canvas coordinates.
{
// Use bounds or specific fields
// Normalized rect provided by normalizeRect helper? not available here easily.
// Just use raw coords if available, or approximate.
// Rect/Ellipse/Diamond have x1,y1,x2,y2 usually?
// Wait, types.ts says DrawingElement...
// Let's assume standard shape properties
if (
"x1" in element &&
"y1" in element &&
"x2" in element &&
"y2" in element
) {
worldX = (element.x1 + element.x2) / 2;
worldY = (element.y1 + element.y2) / 2;
}
} else if (element.type === "line" || element.type === "arrow") {
worldX = (element.x1 + element.x2) / 2;
worldY = (element.y1 + element.y2) / 2;
}
}
}
if (typeof worldX !== "number" || typeof worldY !== "number") {
return null;
}
const screenX = (worldX + pan.x) * zoom;
const screenY = (worldY + pan.y) * zoom;
return (
<div
key={annotation.annotationId}
style={{
position: "absolute",
left: `${screenX}px`,
top: `${screenY}px`,
transform: "translate(-50%, -100%)",
zIndex: 50,
pointerEvents: "auto",
}}
>
<VeltCommentPin annotationId={annotation.annotationId} />
</div>
);
})}
</div>
);
From app[/page.tsx](https://github.com/Studio1HQ/Velt-Demos/blob/main/excalidraw-velt-demo/app/page.tsx)
, you trigger comments like this:
- Capture the
(x, y)
position on click - Pass context to Velt
- Optionally attach to a specific element using
elementId
This enables:
- Freeform comments anywhere on the canvas
- Context-aware discussions linked to shapes
This is very similar to how tools like Miro or Figma handle comments.
Sidebar and UI controls
These files handle user-facing UI around collaboration.
ProfileMenu.tsx
shows user identity and active participants -
ThemeToggle.tsx
syncs your app theme with Velt UI
ProfileMenu.tsx
"use client";
import React, { useEffect, useState } from "react";
import { ChevronDown, User, Check, LogOut, RefreshCwIcon } from "lucide-react";
// import { useVeltClient } from "@veltdev/react";
import { TEST_USERS } from "@/lib/users";
export function ProfileMenu() {
const [isOpen, setIsOpen] = useState(false);
const [currentUser, setCurrentUser] = useState(TEST_USERS[0]); // Default to first user
// Initialize from URL on mount
useEffect(() => {
const params = new