← All posts
·13 min read

Claude Code with Liveblocks: Realtime Done Right

Claude CodeLiveblocksRealtimeCollaboration
Claude Code with Liveblocks: realtime done right

Why Liveblocks without CLAUDE.md ships collaboration that leaks keys and loses edits

Liveblocks is a hosted platform for realtime collaboration: shared rooms where multiple users see each other's presence, edit shared state that merges without conflicts, and broadcast events to everyone connected. It handles the hard parts of multiplayer, the websocket transport, the conflict-free data structures, the presence sync, the awareness of who is in a room, so an application gets cursors, live editing, and comments without building a realtime backend. The appeal is that a collaborative feature becomes a few hooks instead of a distributed-systems project. The risk is that the easy path in the docs uses a public key and a single shared room, and an assistant generating "a Liveblocks integration" reproduces that demo shape, which is exactly wrong for an application with real users and real access boundaries.

A Claude Code session that wires up Liveblocks without project rules produces a room where two browsers see each other's cursors, which is enough to look like working collaboration. What it gets wrong is the two things that matter most. It authenticates with the public key or, worse, puts the secret key in client code, so anyone can read the key and join any room or run up the account's usage. It updates shared Storage by reading a value, mutating it, and writing it back, which races against every other user and loses edits under concurrency, defeating the entire point of a conflict-free data store. It mixes ephemeral presence, the cursor position and selection that should vanish when a user leaves, into persisted Storage that outlives the session. It opens every room to every user with no access check. Each of these works for one user in a demo and breaks the moment a second user joins or an attacker reads the bundle.

This guide covers the CLAUDE.md configuration that locks Claude Code into Liveblocks patterns that hold under real use: the secret key kept server-side behind token authentication, Storage updated through conflict-free mutations instead of read-modify-write, presence kept separate from persisted state, room access gated per user, and the data model split correctly between what persists and what is ephemeral. For the rich-text editor that pairs with Liveblocks for collaborative documents, Claude Code with Tiptap covers the editor integration. For the React patterns the Liveblocks hooks build on, Claude Code with React covers the component foundation.

The Liveblocks CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Liveblocks integration it needs to declare: the SDK and client setup, the authentication model, the Storage conflict policy, the presence conventions, the room access policy, and the hard rules that block the key-leak and lost-update mistakes Claude makes most often.

# Liveblocks rules

## Stack
- @liveblocks/client and @liveblocks/react for the frontend
- @liveblocks/node for server-side token auth
- Public key NEVER used in production; token auth ONLY

## Authentication (MANDATORY)
- Secret key (sk_) lives server-side ONLY, never in a browser bundle
- Auth endpoint issues a per-user, per-room token
- The auth endpoint checks the user CAN access the requested room
- NEVER use the public key (pk_) for an app with real users
- Token carries the user's id and the rooms they may join

## Storage (MANDATORY)
- Mutate Storage through LiveObject / LiveList / LiveMap methods
- NEVER read-modify-write a whole object; use targeted mutations
- Concurrent edits merge via the conflict-free types, not last-write-wins
- Batch related mutations so they apply atomically

## Presence (MANDATORY)
- Presence holds ONLY ephemeral state: cursor, selection, isTyping
- Persisted data goes in Storage, NEVER in presence
- NEVER store anything in presence that must survive a disconnect

## Room access
- Room id namespaced by tenant/resource (org:123:doc:456)
- The auth endpoint authorizes the specific room, not a wildcard
- Read-only collaborators issued a token with read access only

## Hard rules
- NEVER put the secret key in client-side code
- NEVER use the public key in production
- NEVER read-modify-write Storage; use LiveObject/LiveList/LiveMap mutations
- NEVER put persisted data in presence
- NEVER issue a room token without checking the user can access it
- NEVER open a room with a wildcard access grant in production
- ALWAYS namespace room ids by tenant and resource
- ALWAYS batch related Storage mutations

Three rules here prevent the majority of the failures Claude generates without them.

The secret key rule is the one that protects the account and the data. Liveblocks gives you a public key for prototyping and a secret key for production token auth. The public key lets any client join any room, which is fine for a demo and unacceptable for an application where rooms hold private data. The secret key must stay server-side, used only by an auth endpoint that issues a short-lived, per-user, per-room token. The rule forbids the public key in production and forbids the secret key anywhere a browser can read it, which is the single most important boundary in a Liveblocks integration.

The Storage mutation rule is what makes collaboration actually conflict-free. Liveblocks Storage is built on conflict-free replicated data types, LiveObject, LiveList, and LiveMap, that merge concurrent edits correctly when you mutate them through their methods. The antipattern, which an assistant reaches for because it matches ordinary state code, is to read the whole object, change a field in plain JavaScript, and write it back. That read-modify-write races against every other user's write and produces last-write-wins, which loses edits. The rule requires targeted mutations through the Live types so concurrent changes merge instead of clobbering.

The presence separation rule keeps ephemeral and persisted state where each belongs. Presence is the live, throwaway state, where a user's cursor is, what they have selected, whether they are typing, that should disappear when they disconnect. Storage is the durable document state that survives every session. Mixing them, putting document content in presence or cursor position in Storage, either loses data on disconnect or leaves stale cursors persisted forever. The rule draws the line: ephemeral in presence, durable in Storage.

Install and the client

Install the frontend and server packages. The frontend hooks render the collaborative UI; the server package issues tokens.

npm install @liveblocks/client @liveblocks/react
npm install @liveblocks/node   # server-side, for token auth

Configure the client to authenticate against your own endpoint rather than with a public key. This is the setup that keeps the secret key off the client.

// src/liveblocks.config.ts
import { createClient } from "@liveblocks/client";
import { createRoomContext } from "@liveblocks/react";

const client = createClient({
  authEndpoint: "/api/liveblocks-auth",   // your server route, not a public key
});

type Presence = {
  cursor: { x: number; y: number } | null;
  selection: string | null;
};

type Storage = {
  // LiveObject/LiveList/LiveMap, never plain objects for shared state
};

export const { RoomProvider, useStorage, useMutation, useMyPresence, useOthers } =
  createRoomContext<Presence, Storage>(client);

The authEndpoint points at a server route that issues tokens, so the client never holds a key of any kind. The Presence type holds only ephemeral fields, cursor and selection, matching the presence separation rule. The typed room context gives the hooks their shape so every Storage and presence access is type-checked. This is the configuration that makes the rest of the integration safe by construction.

The auth endpoint that gates room access

The auth endpoint is where the secret key lives and where access control happens. It identifies the user, checks they can access the requested room, and issues a token scoped to that room.

// app/api/liveblocks-auth/route.ts  -- server-side only
import { Liveblocks } from "@liveblocks/node";
import { getSession } from "@/auth";
import { userCanAccessRoom } from "@/access";

const liveblocks = new Liveblocks({
  secret: process.env.LIVEBLOCKS_SECRET_KEY!,  // sk_, server-side only
});

export async function POST(req: Request) {
  const session = await getSession(req);
  if (!session) return new Response("Unauthorized", { status: 401 });

  const { room } = await req.json();
  const access = await userCanAccessRoom(session.userId, room);
  if (!access) return new Response("Forbidden", { status: 403 });

  const liveSession = liveblocks.prepareSession(session.userId, {
    userInfo: { name: session.name, avatar: session.avatar },
  });

  // Grant the specific room, with read or write per the access check.
  if (access === "write") liveSession.allow(room, liveSession.FULL_ACCESS);
  else liveSession.allow(room, liveSession.READ_ACCESS);

  const { body, status } = await liveSession.authorize();
  return new Response(body, { status });
}

The endpoint reads the user's session and rejects an unauthenticated request. It checks userCanAccessRoom for the specific room and rejects if the user has no access. It then prepares a session for that user and grants exactly the requested room, with write access or read-only depending on the access level. The secret key never leaves this server route, and the token the client receives is scoped to one room with one access level. This is the auth model the CLAUDE.md rule produces: per-user, per-room, access-checked, with the key behind the server boundary.

Storage mutations that merge

Shared state lives in Storage, and the only safe way to change it is through the Live type methods, which merge concurrent edits. The useMutation hook gives a mutation function that runs against the conflict-free store.

// updating a shared list without losing concurrent edits
import { useMutation, useStorage } from "../liveblocks.config";
import { LiveList } from "@liveblocks/client";

function TodoList() {
  const todos = useStorage((root) => root.todos);

  const addTodo = useMutation(({ storage }, text: string) => {
    // targeted mutation: push onto the LiveList, merges with concurrent pushes
    storage.get("todos").push({ id: crypto.randomUUID(), text, done: false });
  }, []);

  const toggleTodo = useMutation(({ storage }, index: number) => {
    const item = storage.get("todos").get(index);
    if (item) item.set("done", !item.get("done"));
  }, []);

  return (
    <ul>
      {todos?.map((t, i) => (
        <li key={t.id} onClick={() => toggleTodo(i)}>{t.text}</li>
      ))}
    </ul>
  );
}

The addTodo mutation pushes onto a LiveList, which merges with another user's concurrent push so both items survive. The toggleTodo mutation reaches into the specific LiveObject and sets one field, which merges with edits to other fields or other items. Neither reads the whole list, mutates a copy, and writes it back, the read-modify-write pattern that would lose one user's change when two edit at once. This is the conflict-free behaviour the Storage rule enforces, and it is invisible in a single-user test, which is why the rule has to be explicit.

For a set of related changes that must apply together, batch them so they commit atomically.

const reorder = useMutation(({ storage }, from: number, to: number) => {
  storage.get("todos").move(from, to);
}, []);

The move is a single conflict-free operation rather than a delete-then-insert, so a concurrent edit to another item does not corrupt the reorder. Where multiple mutations belong together, wrapping them keeps them atomic from every other client's perspective.

Presence for the ephemeral state

Presence carries the live, throwaway state that should vanish when a user leaves. The useMyPresence and useOthers hooks read and write it.

import { useMyPresence, useOthers } from "../liveblocks.config";

function Cursors() {
  const [, updateMyPresence] = useMyPresence();
  const others = useOthers();

  return (
    <div
      onPointerMove={(e) =>
        updateMyPresence({ cursor: { x: e.clientX, y: e.clientY } })
      }
      onPointerLeave={() => updateMyPresence({ cursor: null })}
    >
      {others.map(({ connectionId, presence }) =>
        presence.cursor ? (
          <Cursor key={connectionId} x={presence.cursor.x} y={presence.cursor.y} />
        ) : null
      )}
    </div>
  );
}

The component writes the local cursor position into presence on pointer move and clears it on pointer leave, and it renders every other user's cursor from useOthers. None of this touches Storage, because a cursor position has no business surviving a disconnect, when a user closes the tab, their cursor should disappear, which is exactly what presence does automatically. Putting the cursor in Storage instead would leave a ghost cursor frozen on the canvas forever, which is the bug the presence separation rule prevents.

Permission hooks for Liveblocks operations

Liveblocks operations are mostly client-side, but the management API and key handling create surfaces an automated session should not touch. Permission hooks in .claude/settings.local.json keep the secret key and destructive room operations out of reach.

{
  "permissions": {
    "allow": [
      "Bash(curl https://api.liveblocks.io/v2/rooms*)"
    ],
    "deny": [
      "Bash(*LIVEBLOCKS_SECRET_KEY*echo*)",
      "Bash(*sk_*)",
      "Bash(curl*DELETE*api.liveblocks.io*)",
      "Bash(curl*POST*api.liveblocks.io/v2/rooms*/storage*)"
    ]
  }
}

The allow list permits the read-only room listing Claude needs to inspect the integration. The deny list blocks any attempt to echo the secret key, any command carrying an sk_ literal, room deletions, and direct Storage writes through the management API, which would bypass the conflict-free client path. Combined with the Claude Code permissions model for the full policy surface, the result is a setup where Claude can inspect rooms without being able to leak the key or corrupt shared state.

Common Claude Code mistakes with Liveblocks

Six patterns Claude generates incorrectly without CLAUDE.md constraints.

1. Public key in production

Claude generates: createClient({ publicApiKey: "pk_..." }) for an app with real users.

Correct pattern: createClient({ authEndpoint: "/api/liveblocks-auth" }) with token auth.

2. Secret key in client code

Claude generates: the sk_ key imported into a browser-bundled module.

Correct pattern: the secret key lives only in the server-side auth route.

3. Read-modify-write on Storage

Claude generates: read the whole object, mutate a copy, write it back.

Correct pattern: targeted LiveObject.set / LiveList.push mutations that merge.

4. Persisted data in presence

Claude generates: document content stored in presence.

Correct pattern: durable data in Storage, only ephemeral cursor and selection in presence.

5. Room opened with no access check

Claude generates: an auth endpoint that grants any room to any user.

Correct pattern: check userCanAccessRoom and grant only the authorized room.

6. Wildcard room access

Claude generates: a token granting access to all rooms.

Correct pattern: scope the token to the specific namespaced room, with read or write per the check.

Add each pattern to CLAUDE.md with the corrected form. Combined with CLAUDE.md examples for the general structure, the result is Liveblocks code Claude generates correctly the first time.

Testing realtime behaviour

The tests that matter for Liveblocks prove that the auth endpoint enforces access and that concurrent Storage mutations merge. The auth logic is plain server code and tests like any endpoint; the merge behaviour tests with two simulated clients.

import { describe, it, expect } from "vitest";
import { POST } from "../app/api/liveblocks-auth/route";

describe("liveblocks auth", () => {
  it("rejects an unauthenticated request", async () => {
    const res = await POST(new Request("http://x", { method: "POST", body: "{}" }));
    expect(res.status).toBe(401);
  });

  it("forbids a room the user cannot access", async () => {
    const res = await POST(authedRequest("user-1", { room: "org:other:doc:1" }));
    expect(res.status).toBe(403);
  });

  it("grants read-only to a viewer", async () => {
    const res = await POST(authedRequest("viewer-1", { room: "org:1:doc:1" }));
    expect(res.status).toBe(200);
    // assert the issued token carries read access, not full access
  });
});

The tests confirm the auth endpoint rejects an unauthenticated caller, forbids a room the user has no access to, and issues a read-only token to a viewer. These prove the access boundary the secret-key and room-access rules depend on. The Storage merge behaviour is best verified with the Liveblocks node client driving two sessions against a test room and asserting both edits survive. For the broader testing setup, Claude Code with Vitest covers the configuration.

Building Liveblocks that holds up with many users

The Liveblocks CLAUDE.md in this guide produces a collaborative layer where the secret key stays server-side behind token auth, every room token is access-checked and scoped, Storage is mutated through conflict-free types so concurrent edits merge, and presence holds only the ephemeral state that should vanish on disconnect. The result is multiplayer that works the same with fifty users as with one, instead of a demo that leaks its key and loses edits the moment a second person joins.

The principle is the same as any realtime platform with Claude Code. Liveblocks without a CLAUDE.md produces a room that renders cursors and conceals the two failures that matter, the leaked key and the lost update, until the feature meets real users. The CLAUDE.md is what makes secure auth and conflict-free Storage the default for Claude.

For the rich-text editor that pairs with Liveblocks for collaborative documents, Claude Code with Tiptap covers the editor integration. For the React patterns the hooks build on, Claude Code with React covers the component foundation. For the framework that hosts the auth endpoint and the collaborative UI, Claude Code with Next.js covers the app-level setup.

Get Claudify. Ship Liveblocks collaboration where the key stays server-side and concurrent edits merge instead of clobbering.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame