Fix: Liveblocks Not Working — Room Not Connecting, Presence Not Syncing, or Storage Mutations Lost
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 openOr 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 changeOr the connection drops with an authentication error:
Error: Liveblocks: Could not connect to room "my-room" — authentication failedOr useStorage returns null forever:
const document = useStorage((root) => root.document);
// null — never resolves, Suspense fallback shown indefinitelyWhy 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 entered —
useOthers,useStorage, anduseMutationonly work inside aRoomProvider. 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 initialized —
useStoragereads from Liveblocks’ CRDT storage, which starts empty. You must provideinitialStorageinRoomProvideror initialize it in a mutation. If storage is never initialized, reads returnnull. - Mutations use a specific CRDT API — Liveblocks storage uses
LiveObject,LiveList, andLiveMaptypes. Plain JavaScript mutations (obj.key = value) don’t work. You must use the CRDT methods (.set(),.push(),.delete()) insideuseMutation.
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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: cmdk Not Working — Command Palette Not Opening, Items Not Filtering, or Keyboard Navigation Broken
How to fix cmdk command palette issues — Dialog setup, custom filtering, groups and separators, keyboard shortcuts, async search, nested pages, and integration with shadcn/ui and Tailwind.
Fix: Conform Not Working — Form Validation Not Triggering, Server Errors Missing, or Zod Schema Rejected
How to fix Conform form validation issues — useForm setup with Zod, server action integration, nested and array fields, file uploads, progressive enhancement, and Remix and Next.js usage.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Lingui Not Working — Messages Not Extracted, Translations Missing, or Macro Errors
How to fix Lingui.js i18n issues — setup with React, message extraction, macro compilation, ICU format, lazy loading catalogs, and Next.js integration.