Liveblocks makes it easy to add real-time collaboration to any React app. Here is how to build it.
Install
pnpm add @liveblocks/client @liveblocks/react @liveblocks/react-ui
Configure Liveblocks
// liveblocks.config.ts
import { createClient } from "@liveblocks/client";
import { createRoomContext, createLiveblocksContext } from "@liveblocks/react";
const client = createClient({
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!,
// Or use authEndpoint for authenticated users:
// authEndpoint: "/api/liveblocks-auth",
});
// Define your collaborative types
type Presence = {
cursor: { x: number; y: number } | null;
name: string;
color: string;
};
type Storage = {
todos: LiveList<LiveObject<{
id: string;
text: string;
completed: boolean;
createdBy: string;
}>>;
canvasObjects: LiveMap<string, LiveObject<{
x: number;
y: number;
width: number;
height: number;
color: string;
}>>;
};
type UserMeta = {
id: string;
info: {
name: string;
avatar: string;
color: string;
};
};
type RoomEvent = {
type: "NOTIFICATION";
message: string;
};
export const {
RoomProvider,
useMyPresence,
useOthers,
useStorage,
useMutation,
useSelf,
useRoom,
useBroadcastEvent,
useEventListener,
} = createRoomContext<Presence, Storage, UserMeta, RoomEvent>(client);
export const { LiveblocksProvider } = createLiveblocksContext(client);
// Re-export types from @liveblocks/client for use in storage
import { LiveList, LiveMap, LiveObject } from "@liveblocks/client";
export { LiveList, LiveMap, LiveObject };
Auth Endpoint
// app/api/liveblocks-auth/route.ts
import { Liveblocks } from "@liveblocks/node";
import { NextRequest, NextResponse } from "next/server";
const liveblocks = new Liveblocks({
secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});
export async function POST(request: NextRequest) {
// Get the current user from your auth system
// const user = await getCurrentUser();
const user = { id: "user-1", name: "Demo User", avatar: "/avatars/default.png" };
const colors = ["#ef4444", "#3b82f6", "#22c55e", "#eab308", "#a855f7", "#ec4899"];
const color = colors[Math.abs(hashCode(user.id)) % colors.length];
const session = liveblocks.prepareSession(user.id, {
userInfo: {
name: user.name,
avatar: user.avatar,
color,
},
});
// Allow access to all rooms (restrict in production)
const { body } = await request.json();
session.allow(body.room, session.FULL_ACCESS);
const { body: authBody, status } = await session.authorize();
return new NextResponse(authBody, { status });
}
function hashCode(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
Live Cursors
// components/LiveCursors.tsx
"use client";
import { useMyPresence, useOthers } from "@/liveblocks.config";
import { useCallback } from "react";
export function LiveCursors() {
const [myPresence, updateMyPresence] = useMyPresence();
const others = useOthers();
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
updateMyPresence({
cursor: { x: e.clientX, y: e.clientY },
});
},
[updateMyPresence]
);
const handlePointerLeave = useCallback(() => {
updateMyPresence({ cursor: null });
}, [updateMyPresence]);
return (
<div
className="fixed inset-0 pointer-events-auto"
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
{others.map(({ connectionId, presence, info }) => {
if (!presence.cursor) return null;
return (
<div
key={connectionId}
className="absolute pointer-events-none transition-transform duration-75"
style={{
transform: `translate(${presence.cursor.x}px, ${presence.cursor.y}px)`,
}}
>
<svg
width="24"
height="36"
viewBox="0 0 24 36"
fill="none"
style={{ color: info?.color ?? "#000" }}
>
<path
d="M5.65376 12.3673H5.46026L5.31717 12.4976L0.500002 16.8829L0.500002 1.19841L11.7841 12.3673H5.65376Z"
fill="currentColor"
/>
</svg>
<span
className="absolute left-5 top-5 px-2 py-0.5 rounded text-xs text-white whitespace-nowrap"
style={{ backgroundColor: info?.color ?? "#000" }}
>
{info?.name ?? presence.name ?? "Anonymous"}
</span>
</div>
);
})}
</div>
);
}
Collaborative Todo List
// components/CollaborativeTodos.tsx
"use client";
import { useStorage, useMutation, LiveObject } from "@/liveblocks.config";
import { useState } from "react";
export function CollaborativeTodos() {
const todos = useStorage((root) => root.todos);
const [newTodo, setNewTodo] = useState("");
const addTodo = useMutation(({ storage }, text: string) => {
const todos = storage.get("todos");
todos.push(
new LiveObject({
id: crypto.randomUUID(),
text,
completed: false,
createdBy: "current-user",
})
);
}, []);
const toggleTodo = useMutation(({ storage }, index: number) => {
const todo = storage.get("todos").get(index);
if (todo) {
todo.set("completed", !todo.get("completed"));
}
}, []);
const deleteTodo = useMutation(({ storage }, index: number) => {
storage.get("todos").delete(index);
}, []);
if (!todos) return <div>Loading...</div>;
return (
<div className="max-w-md mx-auto">
<form
onSubmit={(e) => {
e.preventDefault();
if (newTodo.trim()) {
addTodo(newTodo.trim());
setNewTodo("");
}
}}
className="flex gap-2 mb-4"
>
<input
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a todo..."
className="flex-1 border rounded px-3 py-2 text-sm"
/>
<button
type="submit"
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm"
>
Add
</button>
</form>
<ul className="space-y-2">
{todos.map((todo, index) => (
<li
key={todo.id}
className="flex items-center gap-3 p-3 border rounded"
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(index)}
className="accent-primary"
/>
<span className={`flex-1 text-sm ${todo.completed ? "line-through text-muted-foreground" : ""}`}>
{todo.text}
</span>
<button
onClick={() => deleteTodo(index)}
className="text-xs text-red-500"
>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
Room Provider Setup
// app/room/[id]/page.tsx
import { RoomProvider, LiveList } from "@/liveblocks.config";
import { ClientSideSuspense } from "@liveblocks/react";
import { CollaborativeTodos } from "@/components/CollaborativeTodos";
import { LiveCursors } from "@/components/LiveCursors";
export default async function RoomPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<RoomProvider
id={`room-${id}`}
initialPresence={{ cursor: null, name: "User", color: "#3b82f6" }}
initialStorage={{ todos: new LiveList([]), canvasObjects: new LiveMap() }}
>
<ClientSideSuspense
fallback={<div className="flex justify-center py-12">Loading room...</div>}
>
<LiveCursors />
<div className="container py-12">
<h1 className="text-2xl font-bold mb-6">Collaborative Room</h1>
<CollaborativeTodos />
</div>
</ClientSideSuspense>
</RoomProvider>
);
}
Need Real-Time Collaboration?
We build collaborative features including live editing, shared canvases, and real-time dashboards. Contact us to discuss your vision.