Skip to content

Fix: Liveblocks Not Working — Room Not Connecting, Presence Not Syncing, or Storage Mutations Lost

FixDevs ·

Quick Answer

How to fix Liveblocks issues — room setup, real-time presence with useOthers, conflict-free storage with useMutation, Yjs integration, authentication, and React suspense patterns.

The Problem

The room never connects and presence is empty:

const others = useOthers();
console.log(others);  // [] — always empty even with multiple tabs open

Or storage mutations don’t persist:

const updateTitle = useMutation(({ storage }) => {
  storage.get('document').set('title', 'New Title');
}, []);
// Mutation runs but other clients don't see the change

Or the connection drops with an authentication error:

Error: Liveblocks: Could not connect to room "my-room" — authentication failed

Or useStorage returns null forever:

const document = useStorage((root) => root.document);
// null — never resolves, Suspense fallback shown indefinitely

Why This Happens

Liveblocks provides real-time collaboration through WebSocket rooms. Each room has presence (ephemeral per-user data) and storage (persistent, conflict-free shared state):

  • The room must be explicitly entereduseOthers, useStorage, and useMutation only work inside a RoomProvider. Without it, hooks return default/empty values without errors.
  • Authentication is required in production — the public API key works for development, but production deployments need a backend endpoint that returns a Liveblocks token. Without proper auth, the WebSocket connection fails.
  • Storage must be initializeduseStorage reads from Liveblocks’ CRDT storage, which starts empty. You must provide initialStorage in RoomProvider or initialize it in a mutation. If storage is never initialized, reads return null.
  • Mutations use a specific CRDT API — Liveblocks storage uses LiveObject, LiveList, and LiveMap types. Plain JavaScript mutations (obj.key = value) don’t work. You must use the CRDT methods (.set(), .push(), .delete()) inside useMutation.

Fix 1: Set Up Liveblocks with React

npm install @liveblocks/client @liveblocks/react @liveblocks/node
// liveblocks.config.ts
import { createClient } from '@liveblocks/client';
import { createRoomContext } from '@liveblocks/react';

const client = createClient({
  // Development: public key (client-side, no auth needed)
  publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!,

  // Production: use authEndpoint instead
  // authEndpoint: '/api/liveblocks-auth',
});

// Define types for your application
type Presence = {
  cursor: { x: number; y: number } | null;
  name: string;
  color: string;
};

type Storage = {
  document: LiveObject<{
    title: string;
    content: string;
    lastEditedBy: string;
  }>;
  tasks: LiveList<LiveObject<{
    id: string;
    text: string;
    completed: boolean;
  }>>;
};

type UserMeta = {
  id: string;
  info: {
    name: string;
    avatar: string;
    color: string;
  };
};

export const {
  RoomProvider,
  useOthers,
  useMyPresence,
  useSelf,
  useStorage,
  useMutation,
  useHistory,
  useUndo,
  useRedo,
  useStatus,
  suspense: {
    RoomProvider: SuspenseRoomProvider,
    useOthers: useSuspenseOthers,
    useStorage: useSuspenseStorage,
  },
} = createRoomContext<Presence, Storage, UserMeta>(client);
// app/room/[id]/page.tsx
'use client';

import { RoomProvider } from '@/liveblocks.config';
import { LiveObject, LiveList } from '@liveblocks/client';
import { CollaborativeEditor } from '@/components/CollaborativeEditor';

export default function RoomPage({ params }: { params: { id: string } }) {
  return (
    <RoomProvider
      id={`room-${params.id}`}
      initialPresence={{ cursor: null, name: 'Anonymous', color: '#000' }}
      initialStorage={{
        document: new LiveObject({
          title: 'Untitled',
          content: '',
          lastEditedBy: '',
        }),
        tasks: new LiveList([]),
      }}
    >
      <CollaborativeEditor />
    </RoomProvider>
  );
}

Fix 2: Real-Time Presence (Cursors, Selections)

// components/CollaborativeEditor.tsx
'use client';

import { useMyPresence, useOthers, useSelf } from '@/liveblocks.config';
import { useEffect } from 'react';

export function CollaborativeEditor() {
  const [myPresence, updateMyPresence] = useMyPresence();
  const others = useOthers();
  const self = useSelf();

  // Track cursor position
  useEffect(() => {
    function handlePointerMove(e: PointerEvent) {
      updateMyPresence({
        cursor: { x: e.clientX, y: e.clientY },
      });
    }

    function handlePointerLeave() {
      updateMyPresence({ cursor: null });
    }

    window.addEventListener('pointermove', handlePointerMove);
    window.addEventListener('pointerleave', handlePointerLeave);

    return () => {
      window.removeEventListener('pointermove', handlePointerMove);
      window.removeEventListener('pointerleave', handlePointerLeave);
    };
  }, [updateMyPresence]);

  return (
    <div style={{ position: 'relative', width: '100%', height: '100vh' }}>
      {/* Show other users' cursors */}
      {others.map(({ connectionId, presence, info }) => {
        if (!presence.cursor) return null;

        return (
          <div
            key={connectionId}
            style={{
              position: 'absolute',
              left: presence.cursor.x,
              top: presence.cursor.y,
              pointerEvents: 'none',
              transform: 'translate(-4px, -4px)',
              zIndex: 50,
            }}
          >
            {/* Cursor dot */}
            <svg width="24" height="24" viewBox="0 0 24 24">
              <path
                d="M5 3l14 8-8 3-3 8z"
                fill={info?.color || presence.color}
              />
            </svg>
            {/* Name label */}
            <span style={{
              backgroundColor: info?.color || presence.color,
              color: 'white',
              padding: '2px 6px',
              borderRadius: '4px',
              fontSize: '12px',
              whiteSpace: 'nowrap',
            }}>
              {info?.name || presence.name}
            </span>
          </div>
        );
      })}

      {/* User list */}
      <div style={{ display: 'flex', gap: '4px', padding: '8px' }}>
        {self && (
          <div style={{
            width: '32px', height: '32px', borderRadius: '50%',
            backgroundColor: self.info?.color, border: '2px solid white',
          }} title={`${self.info?.name} (you)`} />
        )}
        {others.map(({ connectionId, info }) => (
          <div key={connectionId} style={{
            width: '32px', height: '32px', borderRadius: '50%',
            backgroundColor: info?.color, border: '2px solid white',
          }} title={info?.name} />
        ))}
      </div>

      {/* Editor content */}
      <EditorContent />
    </div>
  );
}

Fix 3: Conflict-Free Storage (CRDT)

'use client';

import { useStorage, useMutation } from '@/liveblocks.config';
import { LiveObject } from '@liveblocks/client';

function TaskList() {
  // Read from storage — reactive, updates in real-time
  const tasks = useStorage((root) => root.tasks);
  const title = useStorage((root) => root.document.title);

  // Mutations — write to storage using CRDT methods
  const addTask = useMutation(({ storage }, text: string) => {
    const tasks = storage.get('tasks');
    tasks.push(new LiveObject({
      id: crypto.randomUUID(),
      text,
      completed: false,
    }));
  }, []);

  const toggleTask = useMutation(({ storage }, taskId: string) => {
    const tasks = storage.get('tasks');
    const task = tasks.find(t => t.get('id') === taskId);
    if (task) {
      task.set('completed', !task.get('completed'));
    }
  }, []);

  const deleteTask = useMutation(({ storage }, taskId: string) => {
    const tasks = storage.get('tasks');
    const index = tasks.findIndex(t => t.get('id') === taskId);
    if (index !== -1) {
      tasks.delete(index);
    }
  }, []);

  const updateTitle = useMutation(({ storage }, newTitle: string) => {
    storage.get('document').set('title', newTitle);
  }, []);

  if (tasks === null) return <div>Loading...</div>;

  return (
    <div>
      <input
        value={title ?? ''}
        onChange={(e) => updateTitle(e.target.value)}
      />

      <ul>
        {tasks.map((task, index) => (
          <li key={task.id}>
            <input
              type="checkbox"
              checked={task.completed}
              onChange={() => toggleTask(task.id)}
            />
            <span style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
              {task.text}
            </span>
            <button onClick={() => deleteTask(task.id)}>×</button>
          </li>
        ))}
      </ul>

      <button onClick={() => {
        const text = prompt('New task:');
        if (text) addTask(text);
      }}>
        Add Task
      </button>
    </div>
  );
}

Fix 4: Authentication

// app/api/liveblocks-auth/route.ts — Next.js App Router
import { Liveblocks } from '@liveblocks/node';
import { auth } from '@/auth';  // Your auth solution

const liveblocks = new Liveblocks({
  secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});

export async function POST(request: Request) {
  const session = await auth();
  if (!session?.user) {
    return new Response('Unauthorized', { status: 401 });
  }

  // Optionally check room permissions
  const { room } = await request.json();

  const lbSession = liveblocks.prepareSession(session.user.id, {
    userInfo: {
      name: session.user.name ?? 'Anonymous',
      avatar: session.user.image ?? '',
      color: generateColor(session.user.id),
    },
  });

  // Grant access to specific rooms
  lbSession.allow(room, lbSession.FULL_ACCESS);
  // Or read-only: lbSession.allow(room, lbSession.READ_ACCESS);

  const { status, body } = await lbSession.authorize();
  return new Response(body, { status });
}
// liveblocks.config.ts — switch to auth endpoint for production
const client = createClient({
  authEndpoint: '/api/liveblocks-auth',
  // Remove publicApiKey in production
});

Fix 5: Undo/Redo and History

'use client';

import { useHistory, useUndo, useRedo, useMutation } from '@/liveblocks.config';

function EditorToolbar() {
  const history = useHistory();
  const undo = useUndo();
  const redo = useRedo();

  // Batch multiple mutations into one undo step
  const moveAndRename = useMutation(({ storage }) => {
    storage.get('document').set('title', 'Moved Document');
    storage.get('document').set('content', 'Updated after move');
    // Both changes are one undo step because they're in one mutation
  }, []);

  // Pause/resume history for drag operations
  const startDrag = () => history.pause();
  const endDrag = () => history.resume();

  return (
    <div>
      <button onClick={undo} disabled={!history.canUndo()}>Undo</button>
      <button onClick={redo} disabled={!history.canRedo()}>Redo</button>
    </div>
  );
}

Fix 6: Connection Status and Error Handling

'use client';

import { useStatus } from '@/liveblocks.config';

function ConnectionIndicator() {
  const status = useStatus();

  // status: 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected'

  const colors = {
    initial: '#gray',
    connecting: '#yellow',
    connected: '#green',
    reconnecting: '#orange',
    disconnected: '#red',
  };

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
      <div style={{
        width: '8px',
        height: '8px',
        borderRadius: '50%',
        backgroundColor: colors[status],
      }} />
      <span>
        {status === 'connected' ? 'Connected' :
         status === 'reconnecting' ? 'Reconnecting...' :
         status === 'connecting' ? 'Connecting...' :
         'Disconnected'}
      </span>
    </div>
  );
}

Still Not Working?

useOthers() always returns empty array — you need at least two clients connected to the same room. Open two browser tabs with the same room ID. Also verify the RoomProvider has a valid id prop and that initialPresence is set. Without initialPresence, the connection may not broadcast presence data.

useStorage returns null forever — storage must be initialized. Pass initialStorage to RoomProvider. If the room already has data from a previous session, the initial storage is ignored and existing data is loaded. If you’re using Suspense mode (SuspenseRoomProvider), wrap the component in a <Suspense> boundary.

Mutations run locally but don’t sync to others — make sure mutations use the CRDT API (storage.get('key').set(...)) and not plain JavaScript assignment. Also check that both clients are in the same room (same room ID). The WebSocket connection must be established — check useStatus() returns 'connected'.

Authentication endpoint returns 403 — verify LIVEBLOCKS_SECRET_KEY is set (not the public key). The secret key starts with sk_. Also check that lbSession.allow(room, ...) is called with the correct room ID matching what the client requests.

For related real-time and React issues, see Fix: React useState Not Updating and Fix: Convex Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles