Sign in

Custom app

Build a custom collaborative application when no ready-made editor or integration matches your interface, for example an issue tracker, CRM, dashboard, or internal tool. Synchronize your application state, show live collaborators, and layer on activity feeds, comments, notifications, and AI editing as your product needs them.

Custom app

Custom realtime features in the Linear-like Issue Tracker example

Features

  • Realtime collaboration: Application state is permanent & updates in realtime for connected users.
  • Presence: Show avatar stacks, live selections, multiplayer cursors, and agent activity.
  • Activity feed: Show a persistent, realtime timeline of everything happening in your app.
  • Revalidating API data: Tell other clients to refetch data stored in your own database.
  • Server-side editing: Modify application state from a trusted back end.
  • Agentic editing: Generate and apply validated changes with AI.
  • Version history: Save, preview, and restore application state from snapshots.
  • Multiplayer undo/redo: Each user can independently undo and redo their own changes.
  • File uploads: Attach uploaded files to your application records.
  • Comments: Attach commenting threads to any record in your app.
  • Notifications: Notify users about mentions, assignments, and custom events.
  • Permissions: Control which users can read and edit the app.

Get started

Choose a starting point for your app.

Implementation

This is an overview of how each feature can be implemented, using an issue tracker as the example app. Sync can be used to store any type of permanent realtime data, such as issues. Presence can hold temporary selections and interaction state, and Feeds can store durable activity streams.

Realtime collaboration

Using Sync, you can add realtime collaboration to your application state using conflict-free data types. In this snippet, a realtime “priority” property can be read and edited using useStorage and useMutation.

import { useMutation, useStorage } from "@liveblocks/react/suspense";
function IssuePriority() { const priority = useStorage((root) => root.priority);
const setPriority = useMutation(({ storage }, newPriority: string) => { storage.set("priority", newPriority); });
return ( <> <span>Priority: {priority}</span> <Select value={priority} items={["Low", "Medium", "High"]} onChange={(newPriority) => setPriority(newPriority)} /> </> )

Learn more under Storage.

Presence

Using presence, you can show avatar stacks, live selections, and agent activity in your app. To build a custom avatar stack, read connected collaborators with useOthers and the current user with useSelf, then render the user data returned by resolveUsers.

import { useOthers, useSelf } from "@liveblocks/react/suspense";
function CustomAvatarStack() { const others = useOthers(); const currentUser = useSelf();
return ( <div style={{ display: "flex", marginLeft: "8px" }}> {others.map(({ connectionId, info }) => ( <img key={connectionId} src={info.avatar} alt={info.name} style={{ borderRadius: "9999px", marginLeft: "-8px" }} /> ))} <img src={currentUser.info.avatar} alt={currentUser.info.name} style={{ borderRadius: "9999px", marginLeft: "-8px" }} /> </div> );}

Learn more under Presence.

Activity feed

Using Feeds, you can add a persistent, realtime activity feed to your app, for example a timeline of status changes, assignments, and activity on an issue. Render the timeline with useFeedMessages and append events with useCreateFeedMessage.

import {  useCreateFeedMessage,  useFeedMessages,} from "@liveblocks/react/suspense";
function ActivityFeed() { const { messages } = useFeedMessages("activity"); const createMessage = useCreateFeedMessage();
return ( <aside> {messages.map((message) => ( <p key={message.id}>{message.data.text}</p> ))} <button onClick={ () => createMessage("activity", { text: "Issue moved to In progress" }) } > Move issue </button> </aside> );}

Your server and background workflows can append to the same feed with Liveblocks.createFeedMessage, and the same primitives also power chat interfaces and AI agent progress. Learn more under Feeds.

Revalidating API data

You can keep some app data in your own database rather than in Sync, and still make it feel realtime. When a user saves a change through your API, use useBroadcastEvent to send a transient broadcast event to the other connected clients, and useEventListener to revalidate the cached data when it arrives, for example with SWR.

import {  useBroadcastEvent,  useEventListener,} from "@liveblocks/react/suspense";import { useSWRConfig } from "swr";
function IssueSettings() { const broadcast = useBroadcastEvent(); const { mutate } = useSWRConfig();
useEventListener(({ event }) => { if (event.type === "REVALIDATE") { mutate(event.key); } });
return ( <button onClick={async () => { await saveIssue(); broadcast({ type: "REVALIDATE", key: "/api/issues" }); }} > Save issue </button> );}

Broadcast events are not persisted—use them for signals, not for state. Read revalidating API data with SWR for the full pattern, and learn more under events.

Server-side editing

Edit your application state from your server with Liveblocks.mutateStorage, for example to add a link to an issue. The mutation uses the same Sync data types as the client, and connected users receive the result 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 links = root.get("links"); links.push("https://example.com");});

Read Server-side editing for validation, versioning, bulk mutations, and other document formats.

Agentic editing

To allow AI agents to modify your app, 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 { 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 = "my-room-id";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", selectedIssueId: null }, ttl: 60,});
await liveblocks.mutateStorage(roomId, async ({ root }) => { const document = root.toJSON();
const { output: newLinks } = await generateText({ model: "openai/gpt-5.6-sol", output: Output.object({ schema: z.object({ links: z.array(z.string()), }), }), prompt: `Add related links to this issue. Here is the issue and current links: ${document}`, });
const links = root.get("links");
for (const link of newLinks.links) { links.push(link); }});
await liveblocks.setPresence(roomId, { userId: agent.id, userInfo: agent.info, data: { status: "idle", selectedIssueId: 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 version snapshots, manually or automatically, list old versions, and restore to a specific version—useful before bulk imports or large agent edits. Use useHistoryVersions to list versions, useHistoryVersionStorageData to render a read-only preview, and useRestoreToStorageVersion to restore the complete application Storage state as one synchronized change.

import {  useHistoryVersions,  useRestoreToStorageVersion,} from "@liveblocks/react/suspense";
function AppHistoryVersions() { const versions = useHistoryVersions(); const restore = useRestoreToStorageVersion();
return ( <div> {versions.map((version) => ( <div key={version.id} onClick={() => restore(version.id)}> Snapshot created at {version.createdAt} </div> ))} </div> );}

Read Version history to learn more.

Multiplayer undo/redo

Each user can independently undo and redo their own changes with useUndo and useRedo. Additionally, useCanUndo and useCanRedo can be used to disable the undo and redo buttons when the user is at the beginning or end of the undo/redo stack.

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

History can also be paused, resumed, and disabled. Read Multiplayer undo/redo to learn more.

File uploads

You can upload files and other binary assets with useUploadFile, for example to attach a screenshot to an issue. It uploads the file to the current room as a LiveFile, which you can then attach to your data tree.

import { useCallback } from "react";import { useMutation, useUploadFile } from "@liveblocks/react/suspense";
function UploadAttachment({ issueId }: { issueId: string }) { const uploadFile = useUploadFile();
const addAttachment = useMutation( ({ storage }, liveFile) => { const attachments = storage.get("attachments"); attachments.push(liveFile); }, [issueId] );
const handleFileChange = useCallback(async (file) => { const liveFile = await uploadFile(file); addAttachment(liveFile); }, []);
return ( <label> <input type="file" onChange={(e) => handleFileChange(e.currentTarget.files[0])} /> ⬆️ Upload attachment </label> );}

Learn more under File uploads.

Comments

Add commenting to your app, for example as a discussion section below each issue. useThreads allows you to fetch the threads for a specific record, while the Thread and Composer components render discussions and create new ones.

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

Learn more under Comments.

Notifications

Using Notifications, you can create an inbox tray that notifies users about mentions and replies in comments. You can also trigger custom notifications for your own application events, such as an issue being assigned to a user. Render the current user’s inbox with useInboxNotifications and the ready-made InboxNotification component.

import { useInboxNotifications } from "@liveblocks/react/suspense";import { InboxNotificationList, InboxNotification } from "@liveblocks/react-ui";
function InboxTray() { const { inboxNotifications } = useInboxNotifications();
return ( <InboxNotificationList> {inboxNotifications.map((inboxNotification) => ( <InboxNotification key={inboxNotification.id} inboxNotification={inboxNotification} kinds={{ thread: (props) => ( <InboxNotification.Thread {...props} showRoomName={false} /> ), $issueAssigned: (props) => ( <InboxNotification.Custom {...props} title="Issue assigned to you" aside={<InboxNotification.Icon></InboxNotification.Icon>} > {props.inboxNotification.activities[0].data.issueTitle} </InboxNotification.Custom> ), }} /> ))} </InboxNotificationList> );}

You can trigger custom notifications with Liveblocks.triggerInboxNotification. Learn more under Notifications.

Permissions

Each part of your app is contained inside a room in your Liveblocks app, and permission groups can set access to it. For example, your issue board 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 implementations in our example gallery.