Sign in

Spreadsheet

Create a collaborative spreadsheet, table, or data grid with Liveblocks. Synchronize rows, columns, and cell values, show each user’s live selection, and let AI fill in data alongside your users. Get started with an integration, such as Handsontable, AG Grid, or build a custom table using primitives.

Example of a collaborative table

Multiplayer editing in the Multiplayer Handsontable example

Features

Get started

Choose the features you need for your spreadsheet. Each guide uses Next.js and can be combined with the others.

Implementation

This is an overview of how each feature can be implemented. Store permanent rows, columns, and cell values in Sync. Keep temporary selections and active cells in Presence. The snippets below build a custom table, but the same data model works when rendering with a grid library such as AG Grid or Handsontable.

Realtime collaboration

Store rows by stable ID in a LiveMap, with each row a LiveObject keyed by column ID. Keep row and column order in LiveList structures. Because each cell is a separate property, two users editing different cells in the same row merge cleanly, and sorting or reordering never conflicts with a cell edit. Read the table with useStorage and update it with useMutation.

import { LiveObject } from "@liveblocks/client";import { useMutation, useStorage } from "@liveblocks/react/suspense";
function Table() { const columns = useStorage((root) => root.columns); const rows = useStorage((root) => root.rows); const rowOrder = useStorage((root) => root.rowOrder);
const setCell = useMutation( ({ storage }, rowId: string, columnId: string, value: string) => { storage.get("rows").get(rowId)?.set(columnId, value); }, [] );
const addRow = useMutation(({ storage }) => { const rowId = crypto.randomUUID();
storage.get("rows").set(rowId, new LiveObject({})); storage.get("rowOrder").push(rowId); }, []);
return ( <> <table> <tbody> {rowOrder.map((rowId) => ( <tr key={rowId}> {columns.map((columnId) => ( <td key={columnId}> <input value={rows.get(rowId)?.[columnId] ?? ""} onChange={(event) => setCell(rowId, columnId, event.target.value) } /> </td> ))} </tr> ))} </tbody> </table> <button onClick={addRow}>Add row</button> </> );}

Liveblocks applies changes optimistically and resolves simultaneous edits for you. Read Storage to choose the right structure for each part of your table.

Presence

Use Presence for information that only matters while someone is connected, such as their selected cell or the range they are highlighting. Add useUpdateMyPresence to share a user’s selection and render other users’ selections with useOthers, for example as a colored outline around each user’s selected cell.

import { useOthers, useUpdateMyPresence } from "@liveblocks/react/suspense";
function Cell({ rowId, columnId }: { rowId: string; columnId: string }) { const updateMyPresence = useUpdateMyPresence(); const selectedBy = useOthers((others) => others.find( (other) => other.presence.selectedCell?.rowId === rowId && other.presence.selectedCell?.columnId === columnId ) );
return ( <td onFocus={() => updateMyPresence({ selectedCell: { rowId, columnId } })} style={{ outline: selectedBy && `2px solid ${selectedBy.info.color}` }} /> );}

Learn more under Presence.

Server-side editing

Trusted server processes can edit the spreadsheet from the back end with Liveblocks.mutateStorage, for example to import records, sync a column with another system, or update a status when something changes elsewhere. The server reads and writes the same Sync data as connected users, so changes appear in realtime.

import { LiveObject } from "@liveblocks/client";import { Liveblocks } from "@liveblocks/node";
const liveblocks = new Liveblocks({ secret: process.env.LIVEBLOCKS_SECRET_KEY!,});
await liveblocks.mutateStorage("my-room-id", ({ root }) => { const rowId = crypto.randomUUID();
root .get("rows") .set(rowId, new LiveObject({ name: "Acme Inc.", status: "Active" })); root.get("rowOrder").push(rowId);});

Learn more under Server-side editing.

Agentic editing

To allow AI agents to modify your spreadsheet, generate your changes with AI then use mutateStorage to apply them. To show that AI is working in your app use setPresence to show it working—your agent will appear in Presence alongside humans. Finally, remove the agent’s presence to indicate that the agent is no longer working.

import { LiveObject } from "@liveblocks/client";import { Liveblocks } from "@liveblocks/node";import { generateText, Output } from "ai";import { z } from "zod";
const liveblocks = new Liveblocks({ secret: process.env.LIVEBLOCKS_SECRET_KEY!,});
const roomId = "spreadsheet-room";const agent: Liveblocks["UserMeta"] = { id: "ai-agent", info: { name: "AI agent", color: "#7c3aed" },};
await liveblocks.setPresence(roomId, { userId: agent.id, userInfo: agent.info, data: { status: "thinking", selectedCell: null }, ttl: 60,});
await liveblocks.mutateStorage(roomId, async ({ root }) => { const rows = root.get("rows");
const { output: record } = await generateText({ model: "openai/gpt-5.6-sol", output: Output.object({ schema: z.object({ name: z.string(), status: z.string(), }), }), prompt: `Create a cell for a new lead. Here are the current rows: ${rows.toJSON()}`, });
const rowId = crypto.randomUUID();
rows.set(rowId, new LiveObject(record)); root.get("rowOrder").push(rowId);});
await liveblocks.setPresence(roomId, { userId: agent.id, userInfo: agent.info, data: { status: "idle", selectedCell: null }, ttl: 2,});

Additionally, you can use Feeds to store AI workflow state, and to pass agent status updates to the UI. Learn more under Agentic editing.

Version history

Create versions before importing data, running a bulk update, or letting an agent restructure the spreadsheet. Use useHistoryVersions to list versions, useHistoryVersionStorageData to build a read-only preview, and useRestoreToStorageVersion to restore the complete spreadsheet as one synchronized change.

import { useRestoreToStorageVersion } from "@liveblocks/react/suspense";
function RestoreSpreadsheet({ versionId }: { versionId: string }) { const restore = useRestoreToStorageVersion(versionId);
return <button onClick={() => restore()}>Restore this version</button>;}

Automatic versions can be enabled in the dashboard, and meaningful versions can be created from your backend with Liveblocks.createVersionHistorySnapshot. Learn more under Version history.

Multiplayer undo/redo

Connect undo and redo to the spreadsheet toolbar with useUndo, useRedo, useCanUndo, and useCanRedo. Each user’s history is independent, so undoing a cell edit does not reverse another collaborator’s work. Because a mutation is one history entry, a paste or fill that writes many cells becomes a single undo step.

import {  useCanRedo,  useCanUndo,  useRedo,  useUndo,} from "@liveblocks/react/suspense";
function SpreadsheetToolbar() { const undo = useUndo(); const redo = useRedo(); const canUndo = useCanUndo(); const canRedo = useCanRedo();
return ( <> <button onClick={undo} disabled={!canUndo}> Undo </button> <button onClick={redo} disabled={!canRedo}> Redo </button> </> );}

Learn more under Multiplayer undo/redo.

File uploads

Attachment columns can hold images, documents, and other uploaded assets. Upload with useUploadFile, store the returned LiveFile in the row’s cell, and resolve it for display with useFileUrl.

import type { LiveFile } from "@liveblocks/client";import { useMutation, useUploadFile } from "@liveblocks/react/suspense";
function AttachmentCell({ rowId }: { rowId: string }) { const uploadFile = useUploadFile(); const setAttachment = useMutation( ({ storage }, liveFile: LiveFile) => { storage.get("rows").get(rowId)?.set("attachment", liveFile); }, [rowId] );
return ( <input type="file" onChange={async (event) => { const file = event.currentTarget.files?.[0]; if (file) setAttachment(await uploadFile(file)); }} /> );}

Files are stored in the room, so the same permissions that protect the table protect its attachments.

Comments

Use Comments for discussions attached to the spreadsheet. Store the stable row and column IDs in thread metadata so a thread remains attached to its cell through sorting, filtering, and reordering. Filter threads with useThreads and create them with Composer.

import { useThreads } from "@liveblocks/react/suspense";import { Composer, Thread } from "@liveblocks/react-ui";
function CellThreads({ rowId, columnId }: { rowId: string; columnId: string }) { const { threads } = useThreads({ query: { metadata: { rowId, columnId } }, });
return ( <> {threads.map((thread) => ( <Thread key={thread.id} thread={thread} /> ))} <Composer metadata={{ rowId, columnId }} /> </> );}

The table Comments quickstart shows this pattern on a custom React table, and the AG Grid and Handsontable guides show how to render comment indicators through each library’s custom cell renderers.

Permissions

Each spreadsheet is contained inside a room in your Liveblocks app, and permission groups can set access to the table. For example, your spreadsheet may have an editor group and a viewer group. This can be set when modifying or creating a room, for example with Liveblocks.createRoom.

await liveblocks.createRoom(`my-room-id`, {  defaultAccesses: [    // No access by default  ],  groupsAccesses: {    // "viewers" group has read access    viewers: ["*:read"],  },  usersAccesses: {    // "olivier" has write access    olivier: ["*:write"],  },});

More complex controls can be set too, learn more under Permissions.

Examples

Explore complete examples that combine the features described above.