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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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:

  1. A Next.js 15 app with the Liveblocks provider wired into the App Router
  2. Live cursors that follow other users in real time
  3. Presence avatars that show who is currently in the room
  4. A shared canvas of sticky notes synced through Liveblocks Storage
  5. Threaded comments anchored to canvas elements
  6. Token-based authentication so only signed-in users join rooms
  7. 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-canvas

Install 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:

  1. Public key — safe to expose in the browser; used for prototyping
  2. Secret key — server-side only; used to mint room access tokens
  3. 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_xxxxxxxxxxxxxxxxxxxxx

Never 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:

  • Presence is ephemeral state broadcast to other users in the room (cursor position, current selection). It vanishes the moment someone disconnects.
  • Storage is durable shared state stored on Liveblocks servers. LiveList and LiveObject are 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:

  • ClientSideSuspense is the recommended way to gate content that depends on Storage. It waits for the initial snapshot before rendering.
  • initialPresence and initialStorage only run for the very first user in a room. Everyone else gets the existing room state.
  • The suspense import 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:

  • useStorage accepts a selector — it only re-renders when the selected slice changes, so editing one note does not re-render the others.
  • useMutation is the only safe way to write to Storage. Liveblocks records the mutation, broadcasts it to peers, and merges concurrent writes deterministically.
  • LiveObject and LiveList use 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 dev

Step 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-ui so 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.


Want to read more tutorials? Check out our latest tutorial on AI SDK 4.0: New Features and Use Cases.

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

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.

30 min read·