iTranslated by AI
[React/Next.js] Real-time Collaboration Development with Liveblocks
What is Liveblocks?
Liveblocks | Collaborative experiences in days, not months
Liveblocks provides a complete toolkit for developers to incorporate high-performance collaboration features into their products extremely quickly.
I'm going to sign up right away and try it out.

After creating an account and moving to the dashboard, you'll find that two projects, Development and Production, have already been created.
Additionally, the MAU and the number of concurrent connections within a single room are also displayed.

Base Implementation of the Whiteboard
I'm planning to build a Whiteboard + React app this time, so I'll follow the tutorial below.
Operating Environment
"dependencies": {
"@liveblocks/client": "^1.2.1",
"@liveblocks/react": "^1.0.9",
"next": "13.4.13",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/node": "20.4.8",
"@types/react": "^18.2.6",
"@types/react-dom": "^18.2.4",
"eslint": "8.46.0",
"eslint-config-next": "13.4.13",
"prettier": "^2.8.8",
"typescript": "^5.0.4"
}
Obtaining the API Key
First, obtain the API key you will use in advance. Both Public and Secret keys are available, but for this tutorial, I'll proceed with the Public key.
Public and Secret Keys
Secret keys allow you to control who can access the room. While more secure, they require your own backend endpoint. In this tutorial, we will use a Public key. For more details, refer to the Authentication Guide.
Navigate to the Development project and click on "API keys".

Take note of the "Public key" and set it in .env.local as shown below.
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY=pk_xxxxxxxxxxxxxx
Installing Required Packages
yarn add @liveblocks/client @liveblocks/react
Create providers/liveblocks.ts with the following content:
import { createClient } from '@liveblocks/client';
const client = createClient({
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY ?? '',
});
Creating a Room
Using createRoomContext from @liveblocks/react, we'll create a RoomProvider and hooks so they can be easily consumed from components. We'll add this to the providers/liveblocks.ts file mentioned above.
import { createClient } from '@liveblocks/client';
import { createRoomContext } from '@liveblocks/react'; // Added
const client = createClient({
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY ?? '',
});
export const { RoomProvider } = createRoomContext(client); // Added
Next, apply the RoomProvider in app/layout.tsx.
'use client';
import { RoomProvider } from './liveblocks.config'; // Added
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<head></head>
<body>
{/* Wrap with RoomProvider */}
<RoomProvider id="react-whiteboard-app" initialPresence={{}}>
<div>{children}</div>
</RoomProvider>
</body>
</html>
);
}
All components wrapped in RoomProvider will now have access to special React hooks used to interact with this room.
Creating the Canvas
Using LiveMap, we will store a map of shapes within the room's storage.
LiveMap is a type of storage provided by Liveblocks. LiveMap is similar to a JavaScript Map, but its items are synchronized in real-time across different clients. Even if multiple users insert or delete items simultaneously, LiveMap maintains consistency for all users in the room.
Initialize the storage using the initialStorage prop of RoomProvider.
'use client';
import { LiveMap } from '@liveblocks/client'; // Added
import { RoomProvider } from './liveblocks.config';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<head></head>
<body>
<RoomProvider
id="react-whiteboard-app"
initialPresence={{}}
initialStorage={{ // Added
shapes: new LiveMap(),
}}
>
<div>{children}</div>
</RoomProvider>
</body>
</html>
);
}
By using useMap from createRoomContext, you can access the storage saved within Liveblocks.
export const { RoomProvider, useMap } = createRoomContext(client);
useMap returns null while connecting, so it's a good idea to display an indicator if it is null.
Create app/page.tsx with the following content:
'use client';
import { useMap } from '../providers/liveblocks';
const Rectangle = ({ shape }: { shape: any }) => {
const { x, y, fill } = shape;
return (
<div
className="rectangle"
style={{
transform: `translate(${x}px, ${y}px)`,
backgroundColor: fill ? fill : '#CCC',
}}
></div>
);
};
const Canvas = ({ shapes }: { shapes: any }) => {
return (
<>
<div className="canvas">
{Array.from(shapes, ([shapeId, shape]) => {
return <Rectangle key={shapeId} shape={shape} />;
})}
</div>
</>
);
};
const PageWrapper = () => {
const shapes = useMap('shapes');
if (shapes == null) {
return <div className="loading">Loading</div>;
}
return <Canvas shapes={shapes} />;
};
const Page = () => {
return <PageWrapper />;
};
export default Page;
Finally, create app/global.css with the following content and import it in app/layout.tsx.
body {
background-color: #eeeeee;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
}
.canvas {
background-color: #eeeeee;
touch-action: none;
width: 100vw;
height: 100vh;
}
.rectangle {
position: absolute;
/* transition: all 0.1s ease; */
stroke-width: 1;
border-style: solid;
border-width: 2px;
height: 100px;
width: 100px;
}
.toolbar {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
padding: 4px;
border-radius: 8px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1), 0px 0px 0px 1px rgba(0, 0, 0, 0.05);
display: flex;
background-color: #ffffff;
user-select: none;
}
.toolbar button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
border-radius: 4px;
background-color: #f8f8f8;
color: #181818;
border: none;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1), 0px 0px 0px 1px rgba(0, 0, 0, 0.05);
margin: 4px;
font-weight: 500;
font-size: 12px;
}
.toolbar button:hover,
.toolbar button:focus {
background-color: #ffffff;
}
.toolbar button:active {
background-color: #eeeeee;
}
app/layout.tsx
import './globals.css';
Enabling Adding Rectangles
Add the following to app/page.tsx as instructed in the tutorial.
const COLORS = ['#DC2626', '#D97706', '#059669', '#7C3AED', '#DB2777'];
const getRandomInt = (max: number) => {
return Math.floor(Math.random() * max);
};
const getRandomColor = () => {
return COLORS[getRandomInt(COLORS.length)];
};
// ...
const Canvas = ({ shapes }: { shapes: any }) => {
// Added insertRectangle
const insertRectangle = () => {
const shapeId = Date.now().toString();
const rectangle = {
x: getRandomInt(300),
y: getRandomInt(300),
fill: getRandomColor(),
};
shapes.set(shapeId, rectangle);
};
return (
<>
<div className="canvas">
{Array.from(shapes, ([shapeId, shape]) => {
return <Rectangle key={shapeId} shape={shape} />;
})}
</div>
{/* ↓ Added */}
<div className="toolbar">
<button onClick={insertRectangle}>Rectangle</button>
</div>
</>
);
};
// ...

A "Rectangle" button has been added, and you can now add rectangles by clicking it!
Adding Selection
We will use the new hooks useMyPresence and useOthers.
export const { RoomProvider, useMap, useOthers, useMyPresence } =
createRoomContext(client);
We'll set up events for clicking a Rectangle and configure it to display a selection color.
const Rectangle = ({
shape,
id,
onShapePointerDown,
selectionColor,
}: {
shape: any;
id: string;
onShapePointerDown: (e: any, id: string) => void;
selectionColor: string | undefined;
}) => {
const { x, y, fill } = shape;
return (
<div
className="rectangle"
onPointerDown={(e) => onShapePointerDown(e, id)}
style={{
transform: `translate(${x}px, ${y}px)`,
backgroundColor: fill ? fill : '#CCC',
borderColor: selectionColor || 'transparent',
}}
></div>
);
};
We'll update Canvas to reflect the selection state using useMyPresence and useOthers.
const Canvas = ({ shapes }: { shapes: any }) => {
const [{ selectedShape }, setPresence] = useMyPresence();
const others = useOthers();
const insertRectangle = () => {
const shapeId = Date.now().toString();
const rectangle = {
x: getRandomInt(300),
y: getRandomInt(300),
fill: getRandomColor(),
};
shapes.set(shapeId, rectangle);
};
const onShapePointerDown = (e: any, shapeId: string) => {
setPresence({ selectedShape: shapeId });
e.stopPropagation();
};
return (
<>
<div
className="canvas"
onPointerDown={(e) => setPresence({ selectedShape: null })}
>
{Array.from(shapes, ([shapeId, shape]) => {
const selectionColor =
selectedShape === shapeId
? 'blue'
: others.some((user) => user.presence?.selectedShape === shapeId)
? 'green'
: undefined;
return (
<Rectangle
key={shapeId}
shape={shape}
id={shapeId}
onShapePointerDown={onShapePointerDown}
selectionColor={selectionColor}
/>
);
})}
</div>
<div className="toolbar">
<button onClick={insertRectangle}>Rectangle</button>
</div>
</>
);
};

Deleting Rectangles
Add the following to Canvas.
const deleteRectangle = () => {
shapes.delete(selectedShape);
setPresence({ selectedShape: null });
};
// ...
return (
<>
...
<div className="toolbar">
<button onClick={insertRectangle}>Rectangle</button>
{/* Add the following */}
<button onClick={deleteRectangle} disabled={selectedShape == null}>
Delete
</button>
</div>
</>
);

Synchronizing Rectangle Movement
We will modify Canvas.
const Canvas = ({ shapes }: { shapes: any }) => {
const [isDragging, setIsDragging] = useState(false); // Added
const [{ selectedShape }, setPresence] = useMyPresence();
const others = useOthers();
const onShapePointerDown = (e: any, shapeId: string) => {
e.stopPropagation();
setPresence({ selectedShape: shapeId });
setIsDragging(true); // Added
};
// ↓Added
const onCanvasPointerUp = (e: any) => {
if (!isDragging) {
setPresence({ selectedShape: null });
}
setIsDragging(false);
};
// ↓Added
const onCanvasPointerMove = (e: any) => {
e.preventDefault();
if (isDragging) {
const shape = shapes.get(selectedShape);
if (shape) {
shapes.set(selectedShape, {
...shape,
x: e.clientX - 50,
y: e.clientY - 50,
});
}
}
};
return (
<>
<div
className="canvas"
onPointerDown={(e) => setPresence({ selectedShape: null })}
onPointerMove={onCanvasPointerMove} // Added
onPointerUp={onCanvasPointerUp} // Added
>
...
</div>
</>
);
};

Summary
Trying it out this time, I found it very appealing that real-time collaboration features can be easily integrated and that a data store using CRDTs is available for use.
Also, since there is a limit on MAU per room, I think it will be necessary to take that into account when incorporating it.
The code I tried this time is uploaded to the following repository.
Slowhand0309/liveblocks-example: Liveblocks whiteboard + React example.
Discussion