Sign in

Get started with a custom collaborative canvas using Liveblocks and Next.js

This guide shows you how to build a custom collaborative canvas in a Next.js /app directory application. You will synchronize draggable boxes with Storage, add multiplayer undo/redo, then show live cursors and selections with presence.

Using a canvas library?

Follow the Tldraw quickstart if you want a complete canvas renderer and editing toolkit instead of building the interactions yourself.

Quickstart

  1. Install and initialize Liveblocks

    Install @liveblocks/client and @liveblocks/react. Every Liveblocks package should use the same version.

    Terminal
    npm install @liveblocks/client @liveblocks/react

    Initialize liveblocks.config.ts, which will hold your application's collaborative types.

    Terminal
    npx create-liveblocks-app@latest --init --framework react
  2. Define the canvas Storage types

    Each box has a position and fill color. Store the boxes in a LiveMap, keyed by stable IDs, and make each box a LiveObject so its properties can be updated independently.

    liveblocks.config.ts
    import type { LiveMap, LiveObject } from "@liveblocks/client";
    export type Shape = { x: number; y: number; fill: string;};
    declare global { interface Liveblocks { Storage: { shapes: LiveMap<string, LiveObject<Shape>>; }; }}
    export {};

    Use Storage for canvas data that must persist. Cursor and selection state will be added to presence in the final step.

  3. Create a Liveblocks room

    Liveblocks rooms are collaborative spaces where users work on the same data. Set up a LiveblocksProvider, join a room with RoomProvider, and provide the initial boxes for a new room.

    app/Room.tsx
    "use client";
    import { LiveMap, LiveObject } from "@liveblocks/client";import { ClientSideSuspense, LiveblocksProvider, RoomProvider,} from "@liveblocks/react/suspense";import type { ReactNode } from "react";
    export function Room({ children }: { children: ReactNode }) { return ( <LiveblocksProvider publicApiKey={""} throttle={16}> <RoomProvider id="my-canvas-room" initialStorage={{ shapes: new LiveMap([ [ "shape-1", new LiveObject({ x: 100, y: 120, fill: "#f59e0b" }), ], [ "shape-2", new LiveObject({ x: 280, y: 200, fill: "#8b5cf6" }), ], ]), }} > <ClientSideSuspense fallback={<div>Loading…</div>}> {children} </ClientSideSuspense> </RoomProvider> </LiveblocksProvider> );}

    This guide uses a fixed room ID so everyone opening the page joins the same canvas. In a real application, derive a stable room ID from the document or project being opened.

    Wrap your canvas page in the room.

    app/page.tsx
    import { Canvas } from "./Canvas";import { Room } from "./Room";
    export default function Page() { return ( <Room> <Canvas /> </Room> );}
  4. Render and select boxes

    Read an immutable snapshot of the shared boxes with useStorage. Keep the current selection in local React state for now. The effect clears the selection if another user deletes that box.

    app/Canvas.tsx
    "use client";
    import { useStorage } from "@liveblocks/react/suspense";import { useEffect, useState } from "react";
    const BOX_SIZE = 100;
    export function Canvas() { const shapes = useStorage((root) => root.shapes); const [selectedShapeId, setSelectedShapeId] = useState<string | null>( null );
    useEffect(() => { if (selectedShapeId && shapes[selectedShapeId] === undefined) { setSelectedShapeId(null); } }, [selectedShapeId, shapes]);
    return ( <div aria-label="Collaborative canvas" onPointerDown={(event) => { if (event.target === event.currentTarget) { setSelectedShapeId(null); } }} style={{ position: "relative", minHeight: 560, overflow: "hidden", background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 12, }} > {Object.entries(shapes).map(([id, shape]) => ( <button key={id} type="button" aria-label={`Select box ${id}`} aria-pressed={selectedShapeId === id} onPointerDown={(event) => { event.stopPropagation(); setSelectedShapeId(id); }} style={{ position: "absolute", left: shape.x, top: shape.y, width: BOX_SIZE, height: BOX_SIZE, border: selectedShapeId === id ? "3px solid #2563eb" : "3px solid transparent", borderRadius: 12, background: shape.fill, cursor: "grab", touchAction: "none", }} /> ))} </div> );}

    Open the page in two browser tabs. Both tabs render the same two boxes from the room's Storage document.

  5. Add and delete boxes

    Use useMutation to modify the mutable Storage structures. Add these imports and mutations inside Canvas.

    app/Canvas.tsx
    import { LiveObject } from "@liveblocks/client";import { useMutation, useStorage } from "@liveblocks/react/suspense";
    const COLORS = ["#f59e0b", "#8b5cf6", "#10b981", "#ec4899"];
    export function Canvas() { // ...
    const addShape = useMutation(({ storage }) => { const id = crypto.randomUUID(); const fill = COLORS[Math.floor(Math.random() * COLORS.length)] ?? "#f59e0b";
    storage.get("shapes").set( id, new LiveObject({ x: 80 + Math.round(Math.random() * 240), y: 100 + Math.round(Math.random() * 240), fill, }) );
    return id; }, []);
    const deleteShape = useMutation(({ storage }, id: string) => { storage.get("shapes").delete(id); }, []);}

    Add a toolbar before the canvas. A newly created box becomes the local selection, and deleting a box clears that selection.

    app/Canvas.tsx
    return (  <>    <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>      <button        type="button"        onClick={() => setSelectedShapeId(addShape())}      >        Add box      </button>      <button        type="button"        disabled={!selectedShapeId}        onClick={() => {          if (!selectedShapeId) return;          deleteShape(selectedShapeId);          setSelectedShapeId(null);        }}      >        Delete      </button>    </div>
    <div aria-label="Collaborative canvas" /* ... */> {/* Existing boxes */} </div> </>);

    Storage mutations are synchronized to every connected user and added to the current user's multiplayer history.

  6. Drag boxes

    Capture the pointer when a drag begins, remember the pointer's offset inside the box, and update Storage on every pointer movement. The mutation safely does nothing if another user deletes the box mid-drag.

    app/Canvas.tsx
    import type { Shape } from "../liveblocks.config";import {  type PointerEvent,  useEffect,  useRef,  useState,} from "react";
    type Drag = { pointerId: number; shapeId: string; offsetX: number; offsetY: number;};
    export function Canvas() { // ...
    const canvasRef = useRef<HTMLDivElement>(null); const dragRef = useRef<Drag | null>(null);
    const moveShape = useMutation( ({ storage }, id: string, x: number, y: number) => { storage.get("shapes").get(id)?.update({ x, y }); }, [] );
    function startDrag( event: PointerEvent<HTMLButtonElement>, id: string, shape: Readonly<Shape> ) { if (event.button !== 0 || dragRef.current) return;
    const bounds = canvasRef.current?.getBoundingClientRect(); if (!bounds) return;
    event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { pointerId: event.pointerId, shapeId: id, offsetX: event.clientX - bounds.left - shape.x, offsetY: event.clientY - bounds.top - shape.y, }; setSelectedShapeId(id); }
    function moveDrag(event: PointerEvent<HTMLDivElement>) { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return;
    const bounds = event.currentTarget.getBoundingClientRect(); moveShape( drag.shapeId, event.clientX - bounds.left - drag.offsetX, event.clientY - bounds.top - drag.offsetY ); }
    function endDrag(event: PointerEvent<HTMLDivElement>) { if (dragRef.current?.pointerId === event.pointerId) { dragRef.current = null; } }}

    Connect the handlers to the canvas and each box. Pointer capture keeps sending events while the pointer moves outside the selected box.

    app/Canvas.tsx
    <div  ref={canvasRef}  onPointerMove={moveDrag}  onPointerUp={endDrag}  onPointerCancel={endDrag}  onLostPointerCapture={endDrag}  aria-label="Collaborative canvas"  // ...>  {Object.entries(shapes).map(([id, shape]) => (    <button      key={id}      type="button"      onPointerDown={(event) => startDrag(event, id, shape)}      // ...    />  ))}</div>

    Each pointer move updates the box's x and y properties, so other connected users see the complete drag rather than only its final position.

  7. Add multiplayer undo and redo

    Add history controls with useUndo, useRedo, useCanUndo, and useCanRedo.

    app/Canvas.tsx
    import {  useCanRedo,  useCanUndo,  useHistory,  useRedo,  useUndo,  useMutation,  useStorage,} from "@liveblocks/react/suspense";import {  type PointerEvent,  useCallback,  useEffect,  useRef,  useState,} from "react";
    export function Canvas() { // ...
    const undo = useUndo(); const redo = useRedo(); const canUndo = useCanUndo(); const canRedo = useCanRedo(); const { pause, resume } = useHistory();
    return ( <> <div style={{ display: "flex", gap: 8, marginBottom: 12 }}> <button type="button" onClick={() => setSelectedShapeId(addShape())}> Add box </button> <button type="button" /* Delete handler */>Delete</button> <button type="button" disabled={!canUndo} onClick={undo}> Undo </button> <button type="button" disabled={!canRedo} onClick={redo}> Redo </button> </div> {/* Canvas */} </> );}

    A drag generates many Storage mutations. Use useHistory to pause history after pointer capture and resume it when the gesture ends, making the complete drag one undo step. Route every termination event through the same idempotent function, and use it during cleanup so history cannot remain paused if the component unmounts mid-drag.

    app/Canvas.tsx
    // Replace the existing startDrag and endDrag functions.
    const endDrag = useCallback( (pointerId?: number) => { const drag = dragRef.current; if (!drag || (pointerId !== undefined && drag.pointerId !== pointerId)) { return; }
    // Clear first so overlapping end events cannot resume twice. dragRef.current = null; resume(); }, [resume]);
    useEffect(() => () => endDrag(), [endDrag]);
    function startDrag( event: PointerEvent<HTMLButtonElement>, id: string, shape: Readonly<Shape>) { if (event.button !== 0 || dragRef.current) return;
    const bounds = canvasRef.current?.getBoundingClientRect(); if (!bounds) return;
    event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { pointerId: event.pointerId, shapeId: id, offsetX: event.clientX - bounds.left - shape.x, offsetY: event.clientY - bounds.top - shape.y, }; pause(); setSelectedShapeId(id);}
    function finishDrag(event: PointerEvent<HTMLDivElement>) { endDrag(event.pointerId);}

    Replace the three end-event handlers on the canvas with finishDrag.

    app/Canvas.tsx
    <div  ref={canvasRef}  onPointerMove={moveDrag}  onPointerUp={finishDrag}  onPointerCancel={finishDrag}  onLostPointerCapture={finishDrag}  // .../>

    Each user's history contains their own changes. Undoing your drag does not reverse a change made by another collaborator.

  8. Add live cursors and selections with presence

    Finish the canvas by moving the current selection from local React state into presence and adding cursor coordinates. Presence is temporary, per-connection state, so it is not saved in the canvas document.

    First, add the presence type to liveblocks.config.ts.

    liveblocks.config.ts
    declare global {  interface Liveblocks {    Presence: {      cursor: { x: number; y: number } | null;      selectedShapeId: string | null;    };
    Storage: { shapes: LiveMap<string, LiveObject<Shape>>; }; }}

    Set the initial presence values when entering the room.

    app/Room.tsx
    <RoomProvider  id="my-canvas-room"  initialPresence={{ cursor: null, selectedShapeId: null }}>  {/* ... */}</RoomProvider>

    In Canvas, remove the selectedShapeId useState call and the now unused useState import. Read your own selection with useSelf, update it with useUpdateMyPresence, and read collaborators with useOthers.

    app/Canvas.tsx
    import {  // ...  useOthers,  useSelf,  useUpdateMyPresence,} from "@liveblocks/react/suspense";
    export function Canvas() { const shapes = useStorage((root) => root.shapes); const selectedShapeId = useSelf( (me) => me.presence.selectedShapeId ); const others = useOthers(); const updateMyPresence = useUpdateMyPresence();
    useEffect(() => { if (selectedShapeId && shapes[selectedShapeId] === undefined) { updateMyPresence({ selectedShapeId: null }); } }, [selectedShapeId, shapes, updateMyPresence]);
    function selectShape(id: string | null) { updateMyPresence({ selectedShapeId: id }); }}

    Replace every local selection update with selectShape. This keeps add, delete, drag, and canvas-background selections visible to other users.

    app/Canvas.tsx
    function addSelectedShape() {  selectShape(addShape());}
    function deleteSelectedShape() { if (!selectedShapeId) return; deleteShape(selectedShapeId); selectShape(null);}
    function startDrag( event: PointerEvent<HTMLButtonElement>, id: string, shape: Readonly<Shape>) { // Existing pointer capture and drag setup... selectShape(id);}
    return ( <> <div style={{ display: "flex", gap: 8, marginBottom: 12 }}> <button type="button" onClick={addSelectedShape}> Add box </button> <button type="button" disabled={!selectedShapeId} onClick={deleteSelectedShape} > Delete </button> {/* Undo and redo buttons */} </div>
    <div ref={canvasRef} onPointerDown={(event) => { if (event.target === event.currentTarget) selectShape(null); }} // ... /> </>);

    Publish canvas-relative cursor coordinates while preserving the existing drag handler. Clear the cursor when it leaves the canvas.

    app/Canvas.tsx
    function handleCanvasPointerMove(event: PointerEvent<HTMLDivElement>) {  const bounds = event.currentTarget.getBoundingClientRect();  updateMyPresence({    cursor: {      x: event.clientX - bounds.left,      y: event.clientY - bounds.top,    },  });  moveDrag(event);}
    return ( <div ref={canvasRef} onPointerMove={handleCanvasPointerMove} onPointerLeave={() => updateMyPresence({ cursor: null })} // ... />);

    Finally, show when another user has selected a box and render their cursor. Keep the current user's selection blue and use purple for other users.

    app/Canvas.tsx
    {Object.entries(shapes).map(([id, shape]) => {  const selectedByOther = others.some(    (other) => other.presence.selectedShapeId === id  );
    return ( <button key={id} type="button" onPointerDown={(event) => startDrag(event, id, shape)} style={{ // ... border: selectedShapeId === id ? "3px solid #2563eb" : selectedByOther ? "3px solid #7c3aed" : "3px solid transparent", }} /> );})}
    {others.map(({ connectionId, presence }) => presence.cursor ? ( <div key={connectionId} aria-hidden="true" style={{ position: "absolute", left: presence.cursor.x, top: presence.cursor.y, color: "#7c3aed", fontSize: 12, fontWeight: 600, pointerEvents: "none", transform: "translate(6px, 6px)", }} > ● User {connectionId} </div> ) : null)}

    Open the canvas in two tabs. Boxes now move continuously in both tabs, each drag is one undo step, and each tab shows the other user's cursor and selection.

What to read next

Congratulations! You’ve built the foundation of a custom multiplayer canvas for your Next.js application.


Examples using Next.js