Skip to main content
TaskForge Studio uses Liveblocks to power real-time collaboration features including cursor presence, live drawing, and synchronized state across multiple users.

Architecture Overview

The real-time system consists of:
  1. Liveblocks Client - Manages WebSocket connections and sync
  2. Room Provider - Scopes collaboration to specific boards
  3. Presence - Tracks user cursors and selections
  4. Storage - Synchronizes drawing layers across clients

Configuration

1

Configure the Liveblocks client

Set up the Liveblocks client in liveblocks.config.ts:
liveblocks.config.ts
import { createClient, LiveList, LiveMap, LiveObject } from '@liveblocks/client';
import { createRoomContext } from '@liveblocks/react';
import { Layer, Color } from './types/canvas';

const client = createClient({
  throttle: 16,
  authEndpoint: '/api/liveblocks-auth',
});

type Presence = {
  cursor: { x: number; y: number } | null;
  selection: string[];
};

type Storage = {
  layers: LiveMap<string, LiveObject<Layer>>;
  layerIds: LiveList<string>;
};

type UserMeta = {
  id?: string;
  info?: {
    name?: string;
    picture?: string;
  };
};

export const {
  suspense: {
    RoomProvider,
    useMyPresence,
    useUpdateMyPresence,
    useOthers,
    useOthersConnectionIds,
    useOther,
    useStorage,
    useMutation,
  },
} = createRoomContext<Presence, Storage, UserMeta>(client);
The throttle: 16 setting updates at ~60fps for smooth cursor movement.
2

Create the authentication endpoint

Implement the Liveblocks authentication endpoint at app/api/liveblocks-auth/route.ts:
app/api/liveblocks-auth/route.ts
import { auth, currentUser } from '@clerk/nextjs/server';
import { Liveblocks } from '@liveblocks/node';
import { ConvexHttpClient } from 'convex/browser';
import { api } from '@/convex/_generated/api';

const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
const liveblocks = new Liveblocks({
  secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});

export async function POST(request: Request) {
  const authorization = await auth();
  const user = await currentUser();

  if (!authorization || !user) {
    return new Response('Unauthorized', { status: 403 });
  }

  const { room } = await request.json();
  const board = await convex.query(api.board.get, { id: room });

  if (board?.orgId !== authorization.orgId) {
    return new Response('Unauthorized', { status: 403 });
  }

  const userInfo = {
    name: user.firstName || 'Teammate',
    picture: user.imageUrl,
  };

  const session = liveblocks.prepareSession(user.id, { userInfo });

  if (room) {
    session.allow(room, session.FULL_ACCESS);
  }

  const { status, body } = await session.authorize();
  return new Response(body, { status });
}
This endpoint:
  • Authenticates users with Clerk
  • Verifies board access via Convex
  • Creates a Liveblocks session with user metadata
  • Grants room access permissions
3

Create a Room wrapper component

Set up the Room provider in components/room.tsx:
components/room.tsx
'use client';

import { ReactNode } from 'react';
import { RoomProvider } from '@/liveblocks.config';
import { ClientSideSuspense } from '@liveblocks/react';
import { LiveList, LiveMap, LiveObject } from '@liveblocks/client';
import { Layer } from '@/types/canvas';

interface RoomProps {
  children: React.ReactNode;
  roomId: string;
  fallback: NonNullable<ReactNode> | null;
}

export const Room = ({ children, roomId, fallback }: RoomProps) => {
  return (
    <RoomProvider
      id={roomId}
      initialPresence={{
        cursor: null,
        selection: [],
      }}
      initialStorage={{
        layers: new LiveMap<string, LiveObject<Layer>>(),
        layerIds: new LiveList(),
      }}
    >
      <ClientSideSuspense fallback={fallback}>
        {() => children}
      </ClientSideSuspense>
    </RoomProvider>
  );
};
4

Wrap your collaborative page

Use the Room component in your board page:
app/board/[boardId]/page.tsx
import { Canvas } from './_components/canvas';
import { Room } from '@/components/room';
import { Loading } from './_components/loading';

const BoardIdPage = ({ params }: { params: { boardId: string } }) => {
  return (
    <Room roomId={params.boardId} fallback={<Loading />}>
      <Canvas boardId={params.boardId} />
    </Room>
  );
};

export default BoardIdPage;

Environment Variables

# Liveblocks
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_prod_...
LIVEBLOCKS_SECRET_KEY=sk_prod_...

Implementing Cursor Presence

Show real-time cursors for all connected users:

Display all cursors

app/board/[boardId]/_components/cursors-presence.tsx
'use client';

import { memo } from 'react';
import { useOthersConnectionIds } from '@/liveblocks.config';
import { Cursor } from './cursor';

const Cursors = () => {
  const ids = useOthersConnectionIds();
  return (
    <>
      {ids.map((connectionId) => (
        <Cursor key={connectionId} connectionId={connectionId} />
      ))}
    </>
  );
};

export const CursorsPresence = memo(() => {
  return <Cursors />;
});

Render individual cursors

app/board/[boardId]/_components/cursor.tsx
'use client';

import { connectionToColor } from '@/lib/utils';
import { useOther } from '@/liveblocks.config';
import { memo } from 'react';
import { LuMousePointer2 } from 'react-icons/lu';

interface CursorProps {
  connectionId: number;
}

export const Cursor = memo(({ connectionId }: CursorProps) => {
  const info = useOther(connectionId, (user) => user?.info);
  const cursor = useOther(connectionId, (user) => user.presence.cursor);

  const name = info?.name || 'Teammate';

  if (!cursor) return null;

  const { x, y } = cursor;

  return (
    <foreignObject
      style={{ transform: `translateX(${x}px) translateY(${y}px)` }}
      height={50}
      width={name.length * 10 + 24}
      className='relative drop-shadow-md'
    >
      <LuMousePointer2
        className='h-5 w-5'
        style={{
          fill: connectionToColor(connectionId),
          color: connectionToColor(connectionId),
        }}
      />
      <div
        className='absolute left-5 px-1.5 py-0.5 rounded-md text-sm text-white'
        style={{ backgroundColor: connectionToColor(connectionId) }}
      >
        {name}
      </div>
    </foreignObject>
  );
});

Update cursor position

import { useUpdateMyPresence } from '@/liveblocks.config';

function Canvas() {
  const updateMyPresence = useUpdateMyPresence();

  const handlePointerMove = (e: React.PointerEvent) => {
    const cursor = { x: e.clientX, y: e.clientY };
    updateMyPresence({ cursor });
  };

  const handlePointerLeave = () => {
    updateMyPresence({ cursor: null });
  };

  return (
    <svg
      onPointerMove={handlePointerMove}
      onPointerLeave={handlePointerLeave}
    >
      {/* Canvas content */}
    </svg>
  );
}

Working with Storage

Synchronize data across all clients using Liveblocks storage:

Reading from storage

import { useStorage } from '@/liveblocks.config';

function LayersList() {
  const layerIds = useStorage((root) => root.layerIds);
  
  return (
    <div>
      {layerIds?.map((id) => (
        <LayerItem key={id} id={id} />
      ))}
    </div>
  );
}

Mutating storage

import { useMutation } from '@/liveblocks.config';
import { LiveObject } from '@liveblocks/client';

function useCreateLayer() {
  return useMutation(({ storage }, layer: Layer) => {
    const layers = storage.get('layers');
    const layerIds = storage.get('layerIds');
    
    const layerId = crypto.randomUUID();
    layers.set(layerId, new LiveObject(layer));
    layerIds.push(layerId);
    
    return layerId;
  }, []);
}

Utility: Connection Colors

Generate consistent colors for user cursors in lib/utils.ts:
lib/utils.ts
const COLORS = [
  '#0000FF',
  '#FF0000',
  '#FFFF00',
  '#FF6600',
  '#00FF00',
  '#6600FF',
];

export function connectionToColor(connectionId: number): string {
  return COLORS[connectionId % COLORS.length];
}

Best Practices

Optimize re-renders: Use memo() for cursor components and selective subscriptions with useOther() to prevent unnecessary re-renders.
Room security: Always validate that users have permission to access a room in your authentication endpoint.
Throttling: The default throttle of 16ms provides smooth updates. Adjust based on your use case - lower for more precision, higher for reduced bandwidth.

Monitoring Connection Status

import { useStatus } from '@/liveblocks.config';

function ConnectionStatus() {
  const status = useStatus();
  
  return (
    <div>
      Status: {status}
      {/* "connecting" | "connected" | "reconnecting" | "disconnected" */}
    </div>
  );
}

Next Steps

Build docs developers (and LLMs) love