Liveblocks 2.0 Tutorial 2026: Real-Time Collaboration with Next.js 15

Real-time collaboration used to be a six-month project: stand up a WebSocket cluster, pick a CRDT, write a presence protocol, design a conflict-resolution strategy, and pray the client reconnects cleanly when someone closes their laptop. Liveblocks 2.0 collapses that into a managed service with first-class React hooks. You drop a provider into your Next.js tree, call useOthers() or useStorage(), and your app gains Figma-style multiplayer for free.
In this tutorial, you will build a collaborative canvas app from scratch with Liveblocks 2.0 and Next.js 15. By the end, multiple users will see each other's cursors, share live presence, leave threaded comments on the canvas, and edit a shared list of sticky notes that stays consistent even when someone goes offline mid-edit.
Prerequisites
Before starting, make sure you have:
- Node.js 20 or newer installed
- A free Liveblocks account (sign up at liveblocks.io)
- Familiarity with React, TypeScript, and the Next.js App Router
- Basic understanding of authentication patterns
- A code editor (VS Code recommended)
What You Will Build
By the end of this tutorial, you will have:
- A Next.js 15 app with the Liveblocks provider wired into the App Router
- Live cursors that follow other users in real time
- Presence avatars that show who is currently in the room
- A shared canvas of sticky notes synced through Liveblocks Storage
- Threaded comments anchored to canvas elements
- Token-based authentication so only signed-in users join rooms
- A production-ready deployment on Vercel
Step 1: Project Setup
Create a fresh Next.js 15 app with TypeScript and Tailwind. Use the App Router — Liveblocks 2.0's hooks and the new comments primitives expect React Server Components.
npx create-next-app@latest liveblocks-canvas \
--typescript --tailwind --app --eslint \
--src-dir --import-alias "@/*"
cd liveblocks-canvasInstall the Liveblocks packages you will need. The split is intentional: @liveblocks/client is the framework-agnostic core, @liveblocks/react exposes hooks, and @liveblocks/react-ui ships pre-built components for comments and notifications.
npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui
npm install -D @liveblocks/node@liveblocks/node powers the server-side authentication endpoint we will build in Step 4.
Step 2: Configure Your Liveblocks Project
Open the Liveblocks dashboard and create a new project. Note three values from the API keys page:
- Public key — safe to expose in the browser; used for prototyping
- Secret key — server-side only; used to mint room access tokens
- Project ID — visible in the project URL
Add them to .env.local:
LIVEBLOCKS_SECRET_KEY=sk_dev_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_dev_xxxxxxxxxxxxxxxxxxxxxNever commit the secret key. The public key is fine for development, but production apps should always use the secret key with a token-based auth endpoint, which we will build shortly.
Step 3: Define Your Liveblocks Types
Liveblocks 2.0 leans hard on TypeScript. You declare the shape of presence, storage, user metadata, and thread metadata once in a global type, and every hook downstream is fully typed. Create src/liveblocks.config.ts:
import { LiveList, LiveObject } from "@liveblocks/client";
export type StickyNote = LiveObject<{
id: string;
x: number;
y: number;
text: string;
color: "yellow" | "pink" | "blue" | "green";
authorId: string;
}>;
declare global {
interface Liveblocks {
Presence: {
cursor: { x: number; y: number } | null;
selectedNoteId: string | null;
};
Storage: {
notes: LiveList<StickyNote>;
};
UserMeta: {
id: string;
info: {
name: string;
avatar: string;
color: string;
};
};
ThreadMetadata: {
noteId: string;
};
}
}Two things worth highlighting:
Presenceis ephemeral state broadcast to other users in the room (cursor position, current selection). It vanishes the moment someone disconnects.Storageis durable shared state stored on Liveblocks servers.LiveListandLiveObjectare conflict-free data structures that merge concurrent edits automatically.
Step 4: Build the Auth Endpoint
Production apps should never expose the public key directly. Instead, create a server route that authenticates the user with your existing session, then asks Liveblocks for a room-scoped access token. Create src/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) {
const user = await getCurrentUser(request);
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { room } = await request.json();
const session = liveblocks.prepareSession(user.id, {
userInfo: {
name: user.name,
avatar: user.avatar,
color: pickColor(user.id),
},
});
session.allow(room, session.FULL_ACCESS);
const { status, body } = await session.authorize();
return new NextResponse(body, { status });
}
async function getCurrentUser(request: NextRequest) {
return {
id: "user-" + Math.random().toString(36).slice(2, 10),
name: "Demo User",
avatar: "/avatars/default.png",
};
}
function pickColor(userId: string) {
const colors = ["#6366f1", "#ec4899", "#10b981", "#f59e0b", "#06b6d4"];
const index = userId.charCodeAt(userId.length - 1) % colors.length;
return colors[index];
}Replace getCurrentUser with your real session logic — Auth.js, Clerk, Better Auth, or whatever you already use. The session.allow() call grants access to a specific room with FULL_ACCESS. Use READ_ACCESS for read-only viewers like guests.
Step 5: Mount the Room Provider
Create src/components/Room.tsx to wrap pages that need multiplayer features. The provider handles the WebSocket lifecycle, reconnection logic, and presence broadcast for you.
"use client";
import { ReactNode } from "react";
import {
LiveblocksProvider,
RoomProvider,
ClientSideSuspense,
} from "@liveblocks/react/suspense";
import { LiveList } from "@liveblocks/client";
export function Room({
children,
roomId,
}: {
children: ReactNode;
roomId: string;
}) {
return (
<LiveblocksProvider authEndpoint="/api/liveblocks-auth">
<RoomProvider
id={roomId}
initialPresence={{ cursor: null, selectedNoteId: null }}
initialStorage={{ notes: new LiveList([]) }}
>
<ClientSideSuspense fallback={<CanvasSkeleton />}>
{children}
</ClientSideSuspense>
</RoomProvider>
</LiveblocksProvider>
);
}
function CanvasSkeleton() {
return (
<div className="flex h-screen items-center justify-center text-zinc-500">
Connecting to room...
</div>
);
}A few important details:
ClientSideSuspenseis the recommended way to gate content that depends on Storage. It waits for the initial snapshot before rendering.initialPresenceandinitialStorageonly run for the very first user in a room. Everyone else gets the existing room state.- The
suspenseimport path enables React Suspense integration; the non-suspense version returns optional values you have to null-check.
Step 6: Render Live Cursors
Live cursors are the iconic Figma effect. They are dead simple in Liveblocks 2.0 because cursor data lives in Presence — broadcast at 60 frames per second, garbage-collected automatically when a user disconnects.
Create src/components/LiveCursors.tsx:
"use client";
import { useMyPresence, useOthers } from "@liveblocks/react/suspense";
import { useEffect } from "react";
export function LiveCursors() {
const [, updateMyPresence] = useMyPresence();
const others = useOthers();
useEffect(() => {
function onPointerMove(event: PointerEvent) {
updateMyPresence({
cursor: { x: event.clientX, y: event.clientY },
});
}
function onPointerLeave() {
updateMyPresence({ cursor: null });
}
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerleave", onPointerLeave);
return () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerleave", onPointerLeave);
};
}, [updateMyPresence]);
return (
<>
{others.map(({ connectionId, presence, info }) => {
if (!presence.cursor) return null;
return (
<Cursor
key={connectionId}
x={presence.cursor.x}
y={presence.cursor.y}
color={info?.color ?? "#6366f1"}
name={info?.name ?? "Anonymous"}
/>
);
})}
</>
);
}
function Cursor({
x,
y,
color,
name,
}: {
x: number;
y: number;
color: string;
name: string;
}) {
return (
<div
className="pointer-events-none fixed left-0 top-0 z-50 transition-transform duration-75"
style={{ transform: `translate(${x}px, ${y}px)` }}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill={color}>
<path d="M3 3l14 5-6 2-2 6z" />
</svg>
<span
className="ml-3 rounded-md px-2 py-0.5 text-xs font-medium text-white shadow-md"
style={{ background: color }}
>
{name}
</span>
</div>
);
}useOthers() returns an array of every other connected user with their current presence and metadata. It re-renders only when the relevant subset changes, so adding cursors is essentially free even with dozens of collaborators.
Step 7: Add Presence Avatars
A floating avatar stack tells users who else is in the room without forcing them to chase cursors around the screen.
"use client";
import { useOthers, useSelf } from "@liveblocks/react/suspense";
export function ActiveCollaborators() {
const others = useOthers();
const self = useSelf();
return (
<div className="fixed right-4 top-4 flex items-center gap-2">
<span className="text-sm text-zinc-500">
{others.length + 1} online
</span>
<div className="flex -space-x-2">
{self ? (
<Avatar
name={self.info?.name ?? "You"}
color={self.info?.color ?? "#6366f1"}
isSelf
/>
) : null}
{others.slice(0, 4).map(({ connectionId, info }) => (
<Avatar
key={connectionId}
name={info?.name ?? "Anon"}
color={info?.color ?? "#10b981"}
/>
))}
{others.length > 4 ? (
<span className="ml-2 text-xs text-zinc-500">
+{others.length - 4}
</span>
) : null}
</div>
</div>
);
}
function Avatar({
name,
color,
isSelf,
}: {
name: string;
color: string;
isSelf?: boolean;
}) {
return (
<div
className="flex h-9 w-9 items-center justify-center rounded-full border-2 border-white text-sm font-semibold text-white shadow"
style={{ background: color, outline: isSelf ? "2px solid #111" : "none" }}
title={name}
>
{name.charAt(0).toUpperCase()}
</div>
);
}useSelf() returns the current user including their token-issued metadata. Combine it with useOthers() to render the full participant list.
Step 8: Sync the Sticky Notes Canvas
Now for the meaty part: shared state. We will store an array of sticky notes in Liveblocks Storage so every user sees the same canvas, and conflict resolution happens for free.
"use client";
import {
useMutation,
useStorage,
useSelf,
} from "@liveblocks/react/suspense";
import { LiveObject } from "@liveblocks/client";
import { nanoid } from "nanoid";
export function StickyCanvas() {
const notes = useStorage((root) => root.notes);
const self = useSelf();
const addNote = useMutation(({ storage }, x: number, y: number) => {
const note = new LiveObject({
id: nanoid(),
x,
y,
text: "New note",
color: "yellow" as const,
authorId: self?.id ?? "anonymous",
});
storage.get("notes").push(note);
}, [self?.id]);
const updateNote = useMutation(
({ storage }, id: string, patch: Partial<{ text: string; x: number; y: number }>) => {
const list = storage.get("notes");
for (let index = 0; index < list.length; index++) {
const note = list.get(index);
if (note?.get("id") === id) {
note.update(patch);
return;
}
}
},
[],
);
function onCanvasDoubleClick(event: React.MouseEvent) {
addNote(event.clientX, event.clientY);
}
return (
<div
onDoubleClick={onCanvasDoubleClick}
className="relative h-screen w-full bg-zinc-50"
>
{notes.map((note) => (
<StickyNote
key={note.id}
note={note}
onChange={(text) => updateNote(note.id, { text })}
/>
))}
<p className="absolute bottom-4 left-4 text-sm text-zinc-400">
Double-click anywhere to add a note
</p>
</div>
);
}Three things to notice:
useStorageaccepts a selector — it only re-renders when the selected slice changes, so editing one note does not re-render the others.useMutationis the only safe way to write to Storage. Liveblocks records the mutation, broadcasts it to peers, and merges concurrent writes deterministically.LiveObjectandLiveListuse a Yjs-like algorithm under the hood, so concurrent edits never silently overwrite each other.
A minimal StickyNote editor:
function StickyNote({
note,
onChange,
}: {
note: { id: string; x: number; y: number; text: string; color: string };
onChange: (text: string) => void;
}) {
return (
<div
className="absolute w-48 rounded-md p-3 shadow-lg"
style={{
left: note.x,
top: note.y,
background: "#fef08a",
}}
>
<textarea
defaultValue={note.text}
onBlur={(event) => onChange(event.target.value)}
className="h-24 w-full resize-none bg-transparent text-sm outline-none"
/>
</div>
);
}Step 9: Add Threaded Comments
Liveblocks 2.0 ships a comments primitive that handles threading, reactions, and read receipts out of the box. Anchor a thread to each sticky note by setting noteId in ThreadMetadata.
"use client";
import {
Composer,
Thread,
} from "@liveblocks/react-ui";
import { useThreads } from "@liveblocks/react/suspense";
export function NoteComments({ noteId }: { noteId: string }) {
const { threads } = useThreads({
query: { metadata: { noteId } },
});
return (
<div className="space-y-4">
{threads.map((thread) => (
<Thread key={thread.id} thread={thread} />
))}
<Composer
metadata={{ noteId }}
placeholder="Add a comment..."
/>
</div>
);
}Import the default styles in your root layout once:
import "@liveblocks/react-ui/styles.css";The Thread and Composer components render fully accessible, dark-mode-aware UI. They handle optimistic updates, mention autocomplete, and Markdown rendering for you.
Step 10: Wire It All Together
Finally, the page. Pass a unique room id per board so different documents stay isolated.
import { Room } from "@/components/Room";
import { LiveCursors } from "@/components/LiveCursors";
import { ActiveCollaborators } from "@/components/ActiveCollaborators";
import { StickyCanvas } from "@/components/StickyCanvas";
export default function BoardPage({
params,
}: {
params: { boardId: string };
}) {
return (
<Room roomId={`board-${params.boardId}`}>
<ActiveCollaborators />
<StickyCanvas />
<LiveCursors />
</Room>
);
}Run the dev server, open the same board URL in two browser windows, and watch your cursors track each other in real time.
npm run devStep 11: Optimize for Production
Three knobs you should turn before deploying:
Throttle presence updates. Liveblocks already throttles cursor broadcasts on the wire, but you can reduce client-side work by passing throttle: 16 to LiveblocksProvider for 60fps cursors, or throttle: 50 for 20fps if precision is not critical.
Use Status for connection awareness. useStatus() returns the connection state ("connecting", "connected", "reconnecting", "disconnected"). Show a banner when it changes to "reconnecting" so users know to wait before making big edits.
Set a room idle timeout. In the Liveblocks dashboard, configure your project to auto-disconnect rooms after a period of inactivity. This keeps your monthly active connection count predictable.
Step 12: Deploy to Vercel
Liveblocks works seamlessly with Vercel. Push your code to GitHub, import the repo into Vercel, and add two environment variables:
LIVEBLOCKS_SECRET_KEY(production key from the Liveblocks dashboard)NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY(only if you still use the public-key fallback)
That is it. Liveblocks runs on its own globally distributed infrastructure, so you do not need to provision Redis, manage a WebSocket cluster, or worry about cold starts on serverless.
Testing Your Implementation
Open the deployed URL in two different browsers (or one normal window and one incognito). You should see:
- Both cursors tracking smoothly in real time
- Both avatars appearing in the top-right stack
- Sticky notes appearing instantly on both screens when one user double-clicks
- Comment threads attached to notes syncing across windows
- A graceful reconnect when you toggle airplane mode briefly
Troubleshooting
"Cannot read property of undefined" inside a Suspense boundary. You forgot to wrap that component with ClientSideSuspense or you imported from @liveblocks/react instead of @liveblocks/react/suspense.
Cursors do not appear. Check that the cursor component is rendered above the canvas in z-index, and that useMyPresence().cursor is being updated. A common bug is forgetting to set cursor to null on pointerleave, which leaves stale cursors stuck.
Storage mutations silently fail. Mutations only run inside useMutation. Calling storage.get(...).push(...) outside that hook is a no-op because Liveblocks needs the transactional context to broadcast the change.
Stuck on "Connecting to room..." Your auth endpoint is returning the wrong shape. Make sure the response body is the raw body returned by session.authorize() and the status code is propagated.
Next Steps
- Add Yjs integration to power a collaborative rich-text editor with
@liveblocks/yjs - Persist sticky notes to your own database using the Liveblocks Storage REST API and webhooks
- Layer in notifications with
@liveblocks/react-uiso users get notified about mentions - Combine with TanStack Query v5 for hybrid client-server state
- Pair with Better Auth for production-grade authentication
Conclusion
Liveblocks 2.0 turns multiplayer into a feature you ship in an afternoon, not a quarter. By leaning on Presence for ephemeral state, Storage for durable conflict-free state, and the comments primitives for collaboration UX, you get the Figma experience without operating any of the hard pieces. Start with one collaborative surface in your app — usually a dashboard, a planning canvas, or a document editor — and let your users tell you what to make multiplayer next.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

WebSockets with Socket.io and Next.js: Build a Real-time Chat Application
Learn how to build a complete real-time chat application with Socket.io and Next.js. This tutorial covers WebSockets, chat rooms, typing indicators, user presence, and production deployment.

Build a Full-Stack App with Firebase and Next.js 15: Auth, Firestore & Real-Time
Learn how to build a full-stack app with Next.js 15 and Firebase. This guide covers authentication, Firestore, real-time updates, Server Actions, and deployment to Vercel.

Build a Full-Stack App with Appwrite Cloud and Next.js 15
Learn how to build a complete full-stack application using Appwrite Cloud as your backend-as-a-service and Next.js 15 App Router. Covers authentication, databases, file storage, and real-time features.