Liveblocks adds real-time collaboration to React apps — presence tracks who’s online with shared cursors and selections; storage syncs shared data with conflict-free CRDT guarantees. createClient({ authEndpoint }) authenticates with your backend. RoomProvider wraps the collaborative section. useMyPresence broadcasts the current user’s cursor position; useOthers renders all connected users’ presence. useStorage reads CRDT storage (LiveList, LiveObject, LiveMap); useMutation writes to it atomically. useHistory provides undo and redo for storage mutations. useThreads and useCreateThread add commenting to any document with persistent threads. Liveblocks Rooms are ephemeral by default but can persist storage. Claude Code generates Liveblocks client configuration, presence hooks with cursor rendering, storage mutations for collaborative editing, server-side auth endpoints, and the Next.js integration patterns.
CLAUDE.md for Liveblocks
## Liveblocks Stack
- Version: @liveblocks/react >= 2.0, @liveblocks/client >= 2.0
- Client: createClient({ authEndpoint: "/api/liveblocks-auth" }) — server validates users
- Room: RoomProvider + LiveblocksProvider in app tree
- Presence: useMyPresence([cursor]) — broadcast; useOthers() — read others
- Storage: useStorage(root => root.list) → LiveList; useMutation to write
- Types: defined in liveblocks.config.ts — Presence, Storage, UserMeta, RoomEvent
- History: useHistory() — history.undo() / history.redo() for storage mutations
- Threads: useThreads() + useCreateThread() for contextual comments
Configuration
// liveblocks.config.ts — type configuration
import { createClient, LiveList, LiveMap, LiveObject } from "@liveblocks/client"
import { createRoomContext } from "@liveblocks/react"
export const client = createClient({
authEndpoint: "/api/liveblocks-auth",
// Or public key for testing: publicApiKey: "pk_dev_xxxx"
throttle: 16, // 60fps cursor updates
})
export type Presence = {
cursor: { x: number; y: number } | null
selectedId: string | null
name: string
color: string
}
export type Storage = {
// Collaborative document
nodes: LiveMap<
string,
LiveObject<{
id: string
type: "text" | "image" | "shape"
x: number
y: number
width: number
height: number
content?: string
}>
>
order: LiveList<string> // Z-order of node IDs
}
export type UserMeta = {
id: string
info: { name: string; avatar: string; color: string }
}
export type RoomEvent =
| { type: "NOTIFICATION"; message: string }
| { type: "CURSOR_LOCK"; nodeId: string; userId: string }
const {
RoomProvider,
useRoom,
useMyPresence,
useUpdateMyPresence,
useOthers,
useOthersMapped,
useStorage,
useMutation,
useSelf,
useHistory,
useBatch,
useStatus,
useThreads,
useCreateThread,
} = createRoomContext<Presence, Storage, UserMeta, RoomEvent>(client)
export {
RoomProvider,
useRoom,
useMyPresence,
useUpdateMyPresence,
useOthers,
useOthersMapped,
useStorage,
useMutation,
useSelf,
useHistory,
useBatch,
useStatus,
useThreads,
useCreateThread,
}
Auth Endpoint
// app/api/liveblocks-auth/route.ts — server auth
import { Liveblocks } from "@liveblocks/node"
import { auth } from "@/lib/auth"
import type { NextRequest } from "next/server"
import { NextResponse } from "next/server"
const liveblocks = new Liveblocks({
secret: process.env.LIVEBLOCKS_SECRET_KEY!,
})
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { room } = await req.json()
// Validate the user can access this room
const canAccess = await checkUserRoomAccess(session.user.id, room)
if (!canAccess) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const liveblocksSession = liveblocks.prepareSession(session.user.id, {
userInfo: {
name: session.user.name ?? "Anonymous",
avatar: session.user.image ?? "",
color: generateUserColor(session.user.id),
},
})
liveblocksSession.allow(room, liveblocksSession.FULL_ACCESS)
const { status, body } = await liveblocksSession.authorize()
return new NextResponse(body, { status })
}
function generateUserColor(userId: string): string {
const colors = ["#e03131", "#2f9e44", "#1971c2", "#f08c00", "#7048e8", "#0ca678"]
const hash = userId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0)
return colors[hash % colors.length]
}
Presence — Cursors and Avatars
// components/canvas/CollaborativeCursors.tsx — live cursors
"use client"
import { useOthersMapped, useUpdateMyPresence } from "@/liveblocks.config"
import { useCallback, useEffect } from "react"
export function CollaborativeCursors({ canvasRef }: { canvasRef: React.RefObject<HTMLDivElement> }) {
const updatePresence = useUpdateMyPresence()
const others = useOthersMapped(other => ({
cursor: other.presence.cursor,
name: other.info?.name ?? "Anonymous",
color: other.info?.color ?? "#888",
}))
const handlePointerMove = useCallback(
(e: PointerEvent) => {
if (!canvasRef.current) return
const rect = canvasRef.current.getBoundingClientRect()
updatePresence({
cursor: {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
},
})
},
[updatePresence, canvasRef]
)
const handlePointerLeave = useCallback(() => {
updatePresence({ cursor: null })
}, [updatePresence])
useEffect(() => {
const el = canvasRef.current
if (!el) return
el.addEventListener("pointermove", handlePointerMove)
el.addEventListener("pointerleave", handlePointerLeave)
return () => {
el.removeEventListener("pointermove", handlePointerMove)
el.removeEventListener("pointerleave", handlePointerLeave)
}
}, [handlePointerMove, handlePointerLeave, canvasRef])
return (
<>
{others.map(([connectionId, { cursor, name, color }]) =>
cursor ? (
<div
key={connectionId}
className="absolute pointer-events-none z-50"
style={{ left: cursor.x, top: cursor.y, transform: "translate(-2px, -2px)" }}
>
<svg width="18" height="22" fill="none">
<path d="M0 0l6 18 3-5 5 3z" fill={color} />
</svg>
<div
className="rounded px-1.5 py-0.5 text-xs text-white font-medium whitespace-nowrap mt-1"
style={{ backgroundColor: color }}
>
{name}
</div>
</div>
) : null
)}
</>
)
}
Storage Mutations
// components/canvas/useCanvasActions.ts — storage mutations
import { useMutation, useHistory } from "@/liveblocks.config"
import { LiveObject } from "@liveblocks/client"
export function useCanvasActions() {
const { undo, redo, canUndo, canRedo, pause, resume } = useHistory()
const addNode = useMutation(
({ storage }, node: { type: "text" | "image"; x: number; y: number }) => {
const nodes = storage.get("nodes")
const order = storage.get("order")
const id = crypto.randomUUID()
nodes.set(
id,
new LiveObject({
id,
type: node.type,
x: node.x,
y: node.y,
width: 200,
height: 100,
content: node.type === "text" ? "Double-click to edit" : undefined,
})
)
order.push(id)
},
[]
)
const moveNode = useMutation(
({ storage }, { id, x, y }: { id: string; x: number; y: number }) => {
const node = storage.get("nodes").get(id)
node?.set("x", x)
node?.set("y", y)
},
[]
)
const deleteNode = useMutation(({ storage }, id: string) => {
storage.get("nodes").delete(id)
const order = storage.get("order")
const idx = order.indexOf(id)
if (idx !== -1) order.delete(idx)
}, [])
const updateNodeContent = useMutation(
({ storage }, { id, content }: { id: string; content: string }) => {
storage.get("nodes").get(id)?.set("content", content)
},
[]
)
return {
addNode,
moveNode,
deleteNode,
updateNodeContent,
undo,
redo,
canUndo,
canRedo,
}
}
Online Avatars
// components/canvas/ActiveUsers.tsx — presence avatars
import { useSelf, useOthersMapped } from "@/liveblocks.config"
export function ActiveUsers() {
const me = useSelf()
const others = useOthersMapped(o => ({ name: o.info?.name, avatar: o.info?.avatar, color: o.info?.color }))
return (
<div className="flex -space-x-2">
{[...others.entries()].slice(0, 4).map(([id, { name, avatar, color }]) => (
<img
key={id}
src={avatar ?? `https://api.dicebear.com/9.x/initials/svg?seed=${name}`}
alt={name ?? "User"}
title={name}
className="h-8 w-8 rounded-full border-2 border-background object-cover"
style={{ borderColor: color }}
/>
))}
{others.size > 4 && (
<div className="h-8 w-8 rounded-full border-2 border-background bg-muted flex items-center justify-center text-xs font-medium">
+{others.size - 4}
</div>
)}
</div>
)
}
For the Y.js real-time collaboration alternative that uses the same CRDT primitives (YArray, YMap, YText) directly with freedom to choose your own transport layer (WebSocket, WebRTC, or Hocuspocus) without Liveblocks’ authentication and presence infrastructure, see the Tiptap collaboration guide for Y.js + Hocuspocus patterns. For the Ably real-time messaging alternative when you need a lower-level pub/sub message channel model without the structured CRDT storage — better for custom real-time protocols and fan-out messaging patterns, the Supabase realtime guide covers channel subscription patterns. The Claude Skills 360 bundle includes Liveblocks skill sets covering presence, storage mutations, and auth. Start with the free tier to try collaborative feature generation.