--- meta: title: "@liveblocks/client" parentTitle: "API Reference" description: "API Reference for the @liveblocks/client package" alwaysShowAllNavigationLevels: false --- `@liveblocks/client` provides you with JavaScript bindings for our realtime collaboration APIs, built on top of WebSockets. Read our [getting started](/docs/get-started) guides to learn more. ## createClient Creates a [client](#Client) that allows you to connect to Liveblocks servers. You must define either `authEndpoint` or `publicApiKey`. Resolver functions should be placed inside here, and a number of other options are available. ```tsx import { createClient } from "@liveblocks/client"; const client = createClient({ authEndpoint: "/api/liveblocks-auth", // Other options // ... }); ``` ```tsx title="Every createClient option" isCollapsable isCollapsed import { createClient } from "@liveblocks/client"; const client = createClient({ // Connect with authEndpoint authEndpoint: "/api/liveblocks-auth", // Alternatively, use an authEndpoint callback // authEndpoint: async (room) => { // const response = await fetch("/api/liveblocks-auth", { // method: "POST", // headers: { // Authentication: "", // "Content-Type": "application/json", // }, // body: JSON.stringify({ room }), // }); // return await response.json(); // }, // Alternatively, use a public key // publicApiKey: "pk_...", // Throttle time (ms) between WebSocket updates throttle: 100, // Prevent browser tab from closing while local changes aren’t synchronized yet preventUnsavedChanges: false, // Throw lost-connection event after 5 seconds offline lostConnectionTimeout: 5000, // Disconnect users after X (ms) of inactivity, disabled by default backgroundKeepAliveTimeout: undefined, // Resolve user info for Comments and Notifications resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, avatar: userData.avatar.src, })); }, // Resolve room info for Notifications resolveRoomsInfo: async ({ roomIds }) => { const documentsData = await __getDocumentsFromDB__(roomIds); return documentsData.map((documentData) => ({ name: documentData.name, // url: documentData.url, })); }, // Resolve mention suggestions for Comments resolveMentionSuggestions: async ({ text, roomId }) => { const workspaceUsers = await __getWorkspaceUsersFromDB__(roomId); if (!text) { // Show all workspace users by default return __getUserIds__(workspaceUsers); } else { const matchingUsers = __findUsers__(workspaceUsers, text); return __getUserIds__(matchingUsers); } }, // Polyfill options for non-browser environments polyfills: { // atob, // fetch, // WebSocket, }, }); ``` Returns a [Client](#Client), used for connecting to Liveblocks. The URL of your back end’s [authentication endpoint](/docs/authentication) as a string, or an async callback function that returns a Liveblocks token result. Either `authEndpoint` or `publicApikey` are required. Learn more about [using a URL string](#createClientAuthEndpoint) and [using a callback](#createClientCallback). The public API key taken from your project’s [dashboard](/dashboard/apikeys). Generally not recommended for production use. Either `authEndpoint` or `publicApikey` are required. [Learn more](#createClientPublicKey). The throttle time between WebSocket messages in milliseconds, a number between `16` and `1000` is allowed. Using `16` means your app will update 60 times per second. [Learn more](#createClientThrottle). When set, navigating away from the current page is prevented while Liveblocks is still synchronizing local changes. [Learn more](#prevent-users-losing-unsaved-changes). After a user disconnects, the time in milliseconds before a [`"lost-connection"`](/docs/api-reference/liveblocks-client#Room.subscribe.lost-connection) event is fired. [Learn more](#createClientLostConnectionTimeout). The time before an inactive WebSocket connection is disconnected. This is disabled by default, but setting a number will activate it. [Learn more](#createClientBackgroundKeepAliveTimeout). A function that resolves user information in [Comments](/docs/ready-made-features/comments). Return an array of `UserMeta["info"]` objects in the same order they arrived. [Learn more](#createClientResolveUsers). A function that resolves room information in [Comments](/docs/ready-made-features/comments). Return an array of `RoomInfo` objects in the same order they arrived. [Learn more](#createClientResolveRoomsInfo). A function that resolves mention suggestions in [Comments](/docs/ready-made-features/comments). Return an array of user IDs. [Learn more](#createClientResolveMentionSuggestions). Place polyfills for `atob`, `fetch`, and `WebSocket` inside here. Useful when using a non-browser environment, such as [Node.js](#createClientNode) or [React Native](#createClientReactNative).
How to handle WebSocket messages that are larger than the maximum message size. Can be set to one of these values:
- `"default"` Don’t send anything, but log the error to the console. - `"split"` Break the message up into chunks each of which is smaller than the maximum message size. Beware that using `"split"` will sacrifice atomicity of changes! Depending on your use case, this may or may not be problematic. - `"experimental-fallback-to-http"` Try sending the update over HTTP instead of WebSockets (experimental).
Experimental. Stream the initial Storage content over HTTP, instead of waiting for a large initial WebSocket message to be sent from the server.
### createClient with public key [#createClientPublicKey] When creating a client with a public key, you don’t need to set up an authorization endpoint. We only recommend using a public key when prototyping, or on public landing pages, as it makes it possible for end users to access any room’s data. You should instead use an [auth endpoint](#createClientAuthEndpoint). ```ts import { createClient } from "@liveblocks/client"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); ``` ### createClient with auth endpoint [#createClientAuthEndpoint] If you are not using a public key, you need to set up your own `authEndpoint`. Please refer to our [Authentication guide](/docs/authentication). ```ts import { createClient } from "@liveblocks/client"; const client = createClient({ authEndpoint: "/api/liveblocks-auth" }); ``` ### createClient with auth endpoint callback [#createClientCallback] If you need to add additional headers or use your own function to call your endpoint, `authEndpoint` can be provided as a custom callback. You should return the token created with [`Liveblocks.prepareSession`](/docs/api-reference/liveblocks-node#access-tokens) or [`liveblocks.identifyUser`](/docs/api-reference/liveblocks-node#id-tokens), learn more in [authentication guide](/docs/rooms/authentication). ```ts import { createClient } from "@liveblocks/client"; const client = createClient({ authEndpoint: async (room) => { // Fetch your authentication endpoint and retrieve your access or ID token // ... return { token: "..." }; }, }); ``` `room` is the room ID that the user is connecting to. When using [Notifications](/docs/ready-made-features/comments/email-notifications), `room` can be `undefined`, as the client is requesting a token that grants access to multiple rooms, rather than a specific room. #### Fetch your endpoint Here’s an example of fetching your API endpoint at `/api/liveblocks-auth` within the callback. ```ts import { createClient } from "@liveblocks/client"; const client = createClient({ authEndpoint: async (room) => { const response = await fetch("/api/liveblocks-auth", { method: "POST", headers: { Authentication: "", "Content-Type": "application/json", }, // Don't forget to pass `room` down. Note that it // can be undefined when using Notifications. body: JSON.stringify({ room }), }); return await response.json(); }, }); ``` #### Token details You should return the token created with [`Liveblocks.prepareSession`](/docs/api-reference/liveblocks-node#access-tokens) or [`liveblocks.identifyUser`](/docs/api-reference/liveblocks-node#id-tokens). These are the values the functions can return. 1. A valid token, it returns a `{ "token": "..." }` shaped response. 1. A token that explicitly forbids access, it returns an `{ "error": "forbidden", "reason": "..." }` shaped response. If this is returned, the client will disconnect and won't keep trying to authorize. Any other error will be treated as an unexpected error, after which the client will retry the request until it receives either 1. or 2. ### WebSocket throttle [#createClientThrottle] By default, the client throttles the WebSocket messages sent to one every 100 milliseconds, which translates to 10 updates per second. It’s possible to override that configuration with the `throttle` option with a value between `16` and `1000` milliseconds. ```ts import { createClient } from "@liveblocks/client"; const client = createClient({ throttle: 16, // Other options // ... }); ``` This option is helpful for smoothing out realtime animations in your application, as you can effectively increase the framerate without using any interpolation. Here are some examples with their approximate frames per second (FPS) values. ```ts throttle: 16, // 60 FPS throttle: 32, // 30 FPS throttle: 200, // 5 FPS ``` ### Prevent users losing unsaved changes [#prevent-users-losing-unsaved-changes] Liveblocks usually synchronizes milliseconds after a local change, but if a user immediately closes their tab, or if they have a slow connection, it may take longer for changes to synchronize. Enabling `preventUnsavedChanges` will stop tabs with unsaved changes closing, by opening a dialog that warns users. In usual circumstances, it will very rarely trigger. ```tsx import { createClient } from "@liveblocks/client"; const client = createClient({ preventUnsavedChanges: true, // Other options // ... }); ``` More specifically, this option triggers when: - There are unsaved changes after calling any hooks or methods, in all of our products. - There are unsaved changes in a [Text Editor](/docs/ready-made-features/text-editor). - There’s an unsubmitted comment in the [Composer](/docs/api-reference/liveblocks-react-ui#Composer). - The user has made changes and is currently offline. Internally, this option uses the [beforeunload event](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event). ### Lost connection timeout [#createClientLostConnectionTimeout] If you’re connected to a room and briefly lose connection, Liveblocks will reconnect automatically and quickly. However, if reconnecting takes longer than usual, for example if your network is offline, then the room will emit an event informing you about this. How quickly this event is triggered can be configured with the `lostConnectionTimeout` setting, and it takes a number in milliseconds. `lostConnectionTimeout` can be set between `1000` and `30000` milliseconds. The default is `5000`, or 5 seconds. ```ts import { createClient } from "@liveblocks/client"; const client = createClient({ lostConnectionTimeout: 5000, // Other options // ... }); ``` You can listen to the event with [`room.subscribe("lost-connection")`][]. Note that this also affects when `others` are reset to an empty array after a disconnection. This helps prevent temporary flashes in your application as a user quickly disconnects and reconnects. For a demonstration of this behavior, see our [connection status example][]. ### Background keep-alive timeout [#createClientBackgroundKeepAliveTimeout] By default, Liveblocks applications will maintain an active WebSocket connection to the Liveblocks servers, even when running in a browser tab that’s in the background. However, if you’d prefer for background tabs to disconnect after a period of inactivity, then you can use `backgroundKeepAliveTimeout`. When `backgroundKeepAliveTimeout` is specified, the client will automatically disconnect applications that have been in an unfocused background tab for _at least_ the specified time. When the browser tab is refocused, the client will immediately reconnect to the room and synchronize the document. ```ts import { createClient } from "@liveblocks/client"; const client = createClient({ // Disconnect users after 15 minutes of inactivity backgroundKeepAliveTimeout: 15 * 60 * 1000, // Other options // ... }); ``` `backgroundKeepAliveTimeout` accepts a number in milliseconds—we advise using a value of at least a few minutes, to avoid unnecessary disconnections. ### resolveUsers [#createClientResolveUsers] [Comments](/docs/ready-made-features/comments) stores user IDs in its system, but no other user information. To display user information in Comments components, such as a user’s name or avatar, you need to resolve these IDs into user objects. This function receives a list of user IDs and you should return a list of user objects of the same size, in the same order. ```tsx import { createClient } from "@liveblocks/client"; const client = createClient({ resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, avatar: userData.avatar.src, })); }, // Other options // ... }); ``` The name and avatar you return are rendered in [`Thread`](/docs/api-reference/liveblocks-react-ui#Thread) components. #### User objects The user objects returned by the resolver function take the shape of `UserMeta["info"]`, which contains `name` and `avatar` by default. These two values are optional, though if you’re using the [Comments default components](/docs/api-reference/liveblocks-react-ui#Components), they are necessary. Here’s an example of `userIds` and the exact values returned. ```ts resolveUsers: async ({ userIds }) => { // ["marc@example.com", "nimesh@example.com"]; console.log(userIds); return [ { name: "Marc", avatar: "https://example.com/marc.png" }, { name: "Nimesh", avatar: "https://example.com/nimesh.png" }, ]; }; ``` You can also return custom information, for example, a user’s `color`: ```ts resolveUsers: async ({ userIds }) => { // ["marc@example.com"]; console.log(userIds); return [ { name: "Marc", avatar: "https://example.com/marc.png", // +++ color: "purple", // +++ }, ]; }; ``` #### Accessing user data in React You can access any values set within `resolveUsers` with the [`useUser`](/docs/api-reference/liveblocks-react#useUser) hook. ```tsx import { useUser } from "@liveblocks/react/suspense"; function Component() { const user = useUser("marc@example.com"); // { name: "Marc", avatar: "https://...", ... } console.log(user); } ``` ### resolveRoomsInfo [#createClientResolveRoomsInfo] When using [Notifications](/docs/ready-made-features/comments/email-notifications) with [Comments](/docs/ready-made-features/comments), room IDs will be used to contextualize notifications (e.g. “Chris mentioned you in _room-id_”) in the [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) component. To replace room IDs with more fitting names (e.g. document names, “Chris mentioned you in _Document A_”), you can provide a resolver function to the `resolveRoomsInfo` option in [`createClient`](#createClient). This resolver function will receive a list of room IDs and should return a list of room info objects of the same size and in the same order. ```tsx import { createClient } from "@liveblocks/client"; const client = createClient({ resolveRoomsInfo: async ({ roomIds }) => { const documentsData = await __getDocumentsFromDB__(roomIds); return documentsData.map((documentData) => ({ name: documentData.name, // url: documentData.url, })); }, // Other options // ... }); ``` In addition to the room’s name, you can also provide a room’s URL as the `url` property. If you do so, the [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) component will automatically use it. It’s possible to use an inbox notification’s `roomId` property to construct a room’s URL directly in React and set it on [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) via `href`, but the room ID might not be enough for you to construct the URL , you might need to call your backend for example. In that case, providing it via `resolveRoomsInfo` is the preferred way. ### resolveMentionSuggestions [#createClientResolveMentionSuggestions] To enable creating mentions in [Comments](/docs/ready-made-features/comments), you can provide a resolver function to the `resolveMentionSuggestions` option in [`createClient`](#createClient). These mentions will be displayed in the [`Composer`](/docs/api-reference/liveblocks-react-ui#Composer) component. This resolver function will receive the mention currently being typed (e.g. when writing “@jane”, `text` will be `jane`) and should return a list of user IDs matching that text. This function will be called every time the text changes but with some debouncing. ```tsx import { createClient } from "@liveblocks/client"; const client = createClient({ resolveMentionSuggestions: async ({ text, roomId }) => { const workspaceUsers = await __getWorkspaceUsersFromDB__(roomId); if (!text) { // Show all workspace users by default return __getUserIds__(workspaceUsers); } else { const matchingUsers = __findUsers__(workspaceUsers, text); return __getUserIds__(matchingUsers); } }, // Other options // ... }); ``` ### createClient for Node.js [#createClientNode] To use `@liveblocks/client` in Node.js, you need to provide [`WebSocket`][] and [`fetch`][] polyfills. As polyfills, we recommend installing [`ws`][] and [`node-fetch`][]. ```bash npm install ws node-fetch ``` Then, pass them to the `createClient` polyfill option as below. ```ts import { createClient } from "@liveblocks/client"; import fetch from "node-fetch"; import WebSocket from "ws"; const client = createClient({ polyfills: { fetch, WebSocket, }, // Other options // ... }); ``` Note that `node-fetch` v3+ [does not support CommonJS](https://github.com/node-fetch/node-fetch/blob/main/docs/v3-UPGRADE-GUIDE.md#converted-to-es-module). If you are using CommonJS, downgrade `node-fetch` to v2. ### createClient for React Native [#createClientReactNative] To use `@liveblocks/client` with [React Native](https://reactnative.dev/), you need to add an [`atob`][] polyfill. As a polyfill, we recommend installing [`base-64`][]. ```bash npm install base-64 ``` Then you can pass the `decode` function to our `atob` polyfill option when you create the client. ```ts import { createClient } from "@liveblocks/client"; import { decode } from "base-64"; const client = createClient({ polyfills: { atob: decode, }, // Other options // ... }); ``` ## Client Client returned by [`createClient`][] which allows you to connect to Liveblocks servers in your application, and enter rooms. ### Client.enterRoom Enters a room and returns both the local `Room` instance, and a `leave` unsubscribe function. The authentication endpoint is called as soon as you call this function. Used for setting [initial Presence](#setting-initial-presence) and [initial Storage](#setting-initial-storage) values. ```ts const { room, leave } = client.enterRoom("my-room-id", { // Options // ... }); ``` Note that it’s possible to [add types to your room](#typing-your-data). A [Room](#Room), used for building your Liveblocks application. Learn more about [typing your room](#typing-your-data). A function that’s used to leave the room and disconnect. The ID of the room you’re connecting to. The initial Presence of the user entering the room. Each user has their own presence, and this is readable for all other connected users. A user’s Presence resets every time they disconnect. This object must be JSON-serializable. [Learn more](#setting-initial-presence). The initial Storage structure for the room when it’s joined for the first time. This is only set a single time, when the room has not yet been populated. This object must contain [conflict-free live structures](/docs/api-reference/liveblocks-client#Storage). [Learn more](#setting-initial-storage). Whether the room immediately connects to Liveblocks servers. #### Setting initial Presence [#setting-initial-presence] Presence is used for storing temporary user-based values, such as a user’s cursor coordinates, or their current selection. Each user has their own presence, and this is readable for all other connected users. Set your initial Presence value by using `initialPresence`. ```ts const { room, leave } = client.enterRoom("my-room-id", { // +++ initialPresence: { cursor: null, colors: ["red", "purple"], selection: { id: 72426, }, }, // +++ // Other options // ... }); ``` Each user’s Presence resets every time they disconnect, as this is only meant for temporary data. Any JSON-serializable object is allowed (the `JsonObject` type). #### Setting initial Storage [#setting-initial-storage] Storage is used to store permanent data that’s used in your application, such as shapes on a whiteboard, nodes on a flowchart, or text in a form. The first time a room is entered, you can set an initial value by using `initialStorage`. `initialStorage` is only read and set a single time, unless a new top-level property is added. ```ts import { LiveList, LiveObject } from "@liveblocks/client"; const { room, leave } = client.enterRoom("my-room-id", { // +++ initialStorage: { title: "Untitled", shapes: new LiveList([ new LiveObject({ type: "rectangle", color: "yellow" }), ]), }, // +++ // Other options // ... }); ``` If a new top-level property is added to `initialStorage`, the next time a user connects, the new property will be created. Other properties will be unaffected. Any [conflict-free live structures](/docs/api-reference/liveblocks-client#Storage) and JSON-serializable objects are allowed (the `LsonObject` type). ### Client.getRoom Gets a room by its ID. Returns `null` if [`client.enterRoom`][] has not been called previously. ```ts const room = client.getRoom("my-room"); ``` It’s unlikely you’ll need this API if you’re using the newer [`client.enterRoom`][] API. Note that it’s possible to [add types to your room](#typing-your-data). A [Room](#Room), used for building your Liveblocks application. Returns `null` if the room has not yet been joined by the current client. Learn more about [typing your room](#typing-your-data). The ID of the room you’re connecting to. ### Client.getSyncStatus Gets the current Liveblocks synchronization status. ```ts const syncStatus = client.getSyncStatus(); // "synchronizing" | "synchronized" ``` Will be `"synchronizing"` if there are any local changes to any part of Liveblocks that still need to be acknowledged by the server. Will be `"synchronized"` when all local changes have been persisted. ### Client.logout Purges any auth tokens from the client’s memory. If there are any rooms that are still connected, they will be forced to reauthorize. ```ts client.logout(); ``` _Nothing_ _None_ #### When to logout Use this function if you have a single page application (SPA) and you wish to log your user out, and reauthenticate them. This is a way to update your user’s `info` after a connection has begun. ## Room Room returned by [`client.enterRoom`][] (or [`client.getRoom`][]). ### Room.getPresence Return the current user’s Presence. [Presence](/docs/ready-made-features/presence) is used to store custom properties on each user that exist until the user disconnects. An example use would be storing a user’s cursor coordinates. ```ts const presence = room.getPresence(); // { cursor: { x: 363, y: 723 } } console.log(presence); ``` Presence is set with [`updatePresence`](#Room.updatePresence) and can be typed when you [enter a room](#enter-room-typing-a-room). The example above is using the following type: ```ts file="liveblocks.config.ts" declare global { interface Liveblocks { Presence: { cursor: { x: number; y: number }; }; } } ``` An object holding the Presence value for the currently connected user. Presence is set with [`updatePresence`](#Room.updatePresence). Will always be JSON-serializable. `TPresence` is the `Presence` type you set yourself, [learn more](#Typing-presence). _None_ ### Room.updatePresence Updates the current user’s [Presence](/docs/ready-made-features/presence). Only pass the properties you wish to update—any changes will be merged into the current presence. The entire presence object will not be replaced. ```ts room.updatePresence({ typing: true }); room.updatePresence({ status: "Online" }); // { typing: true, status: "Online" } const presence = room.getPresence(); ``` _Nothing_ The updated Presence properties for the current user inside an object. The user’s entire Presence object will not be replaced, instead these properties will be merged with the existing Presence. This object must be JSON-serializable. Adds Presence values to the history stack, meaning using undo and redo functions will change them. [Learn more](#add-presence-to-history). #### Add Presence to history [#add-presence-to-history] By default, Presence values are not added to history. However, using the `addToHistory` option will add items to the undo/redo stack. ```ts room.updatePresence({ color: "blue" }, { addToHistory: true }); room.updatePresence({ color: "red" }, { addToHistory: true }); room.history.undo(); // { color: "blue" } const presence = room.getPresence(); ``` See [`room.history`][] for more information. ### Room.getOthers Returns an array of currently connected users in the room. Returns a [`User`](#user-type) object for each user. Note that you can also subscribe to others using [`Room.subscribe("others")`](#Room.subscribe.others). ```ts const others = room.getOthers(); for (const other of others) { const { connectionId, id, info, presence, canWrite, canComment } = other; // Do things } ``` An array holding each connected user’s [`User`](#user-type) object. `User` contains the current user’s Presence value, along with other information. Presence is set with [`updatePresence`](#Room.updatePresence). Returns an empty array when no other users are currently connected. Will always be JSON-serializable. _None_ ### Room.broadcastEvent Broadcast an event to other users in the Room. Events broadcast to the room can be listened to with [`Room.subscribe("event")`][]. Takes a custom event payload as first argument. Should be serializable to JSON. ```ts room.broadcastEvent({ type: "REACTION", emoji: "🔥" }); ``` _Nothing_ The event to broadcast to every other user in the room. Must be JSON-serializable. `TRoomEvent` is the `RoomEvent` type you set yourself, [learn more](#typing-multiple-events). Queue the event if the connection is currently closed, or has not been opened yet. We’re not sure if we want to support this option in the future so it might be deprecated to be replaced by something else. [Learn more](#broadcasting-an-event-when-disconnected). #### Receiving an event To receive an event, use [`Room.subscribe("event")`][]. The `user` property received on the other end is the sender’s [`User`](#user-type) instance. ```ts // User 1 room.broadcastEvent({ type: "REACTION", emoji: "🔥" }); // User 2 const unsubscribe = room.subscribe("event", ({ event, user, connectionId }) => { // ^^^^ User 1 if (event.type === "REACTION") { // Do something } }); ``` We recommend using a property such as `type`, so that it’s easy to distinguish between different events on the receiving end. #### Typing multiple events [#typing-multiple-events] When [defining your types](#typing-your-data), you can pass a `RoomEvent` type in your config file to receive type hints in your app. To define multiple different custom events, use a union. ```ts declare global { interface Liveblocks { RoomEvent: | { type: "REACTION"; emoji: string } | { type: "ACTION"; action: string }; } } ``` ```ts room.subscribe("event", ({ event, user, connectionId }) => { if (event.type === "REACTION") { // Do something } if (event.type === "ACTION") { // Do something else } }); ``` #### Broadcasting an event when disconnected [#broadcasting-an-event-when-disconnected] By default, broadcasting an event is a “fire and forget” action. If the sending client is not currently connected to a room, the event is simply discarded. When passing the `shouldQueueEventIfNotReady` option, the client will queue up the event, and only send it once the connection to the room is (re)established. We’re not sure if we want to support `shouldQueueEventIfNotReady` in the future, so it may be deprecated and replaced with something else. ```ts room.broadcastEvent( { type: "REACTION", emoji: "🔥" }, { // +++ shouldQueueEventIfNotReady: true, // +++ } ); ``` ### Room.getSelf Gets the current [`User`](#user-type). Returns `null` if the client is not yet connected to the room. ```ts const { connectionId, presence, id, info, canWrite, canComment } = room.getSelf(); ``` Returns the current [`User`](#user-type). Returns `null` if the client is not yet connected to the room. _None_ Here’s an example of a full return value, assuming `Presence` and `UserMeta` [have been set](#user-type). ```ts const user = room.getSelf(); // { // connectionId: 52, // presence: { // cursor: { x: 263, y: 786 }, // }, // id: "mislav.abha@example.com", // info: { // avatar: "/mislav.png", // }, // canWrite: true, // canComment: true, // } console.log(user); ``` ### Room.getStatus Gets the current WebSocket connection status of the room. The possible value are: `initial`, `connecting`, `connected`, `reconnecting`, or `disconnected`. ```ts const status = room.getStatus(); // "connected" console.log(status); ```
Returns the room’s current connection status. It can return one of five values:
- `"initial"` The room has not attempted to connect yet. - `"connecting"` The room is currently authenticating or connecting. - `"connected"` The room is connected. - `"reconnecting"` The room has disconnected, and is trying to connect again. - `"disconnected"` The room is disconnected, and is no longer attempting to connect.
_None_ ### Room.getStorageStatus Get the Storage status. Use this to tell whether Storage has been synchronized with the Liveblocks servers. ```ts const status = room.getStorageStatus(); // "synchronizing" console.log(status); ```
The current room’s Storage status. `status` can be one of four types.
- `"not-loaded` Storage has not been loaded yet as [`room.getStorage`][] has not been called. - `"loading"` Storage is currently loading for the first time. - `"synchronizing"` Local Storage changes are currently being synchronized. - `"synchronized"` Local Storage changes have been synchronized.
_None_ ### Room.subscribe(storageItem) Subscribe to updates on a particular storage item, and takes a callback function that’s called when the storage item is updated. The Storage `root` is a [`LiveObject`][], which means you can subscribe to this, as well as other live structures. Returns an unsubscribe function. ```ts const { root } = await room.getStorage(); const unsubscribe = room.subscribe(root, (updatedRoot) => { // Do something }); ``` Unsubscribe function. Call it to cancel the subscription. The `LiveObject`, `LiveMap`, or `LiveList` which is being subscribed to. Each time the structure is updated, the callback is called. Function that’s called when `storageItem` updates. Returns the updated storage structure. Subscribe to both `storageItem` and its children. The callback function will be passed a list of updates instead of just the new Storage item. [Learn more](#listening-for-nested-changes). #### Typing Storage To type the Storage values you receive, make sure to set your `Storage` type. ```ts file="liveblocks.config.ts" import { LiveList } from "@liveblocks/client"; declare global { interface Liveblocks { Storage: { animals: LiveList<{ name: string }>; }; } } ``` The type received in the callback will match the type passed. Learn more under [typing your room](#typing-your-data). ```ts const { root } = await room.getStorage(); const animals = root.get("animals"); const unsubscribe = room.subscribe(animals, (updatedAnimals) => { // LiveList<[{ name: "Fido" }, { name: "Felix" }]> console.log(updatedAnimals); }); ``` #### Subscribe to any live structure You can subscribe to any live structure, be it the Storage `root`, a child, or a structure even more deeply nested. ```ts file="liveblocks.config.ts" import { LiveMap, LiveObject } from "@liveblocks/client"; type Person = LiveObject<{ name: string; age: number }>; declare global { interface Liveblocks { Storage: { people: LiveMap; }; } } ``` ```ts const { root } = await room.getStorage(); const people = root.get("people"); const steven = people.get("steven"); const unsubscribeRoot = room.subscribe(root, (updatedRoot) => { // ... }); const unsubscribePeople = room.subscribe(people, (updatedPeople) => { // ... }); const unsubscribeSteven = room.subscribe(steven, (updatedSteven) => { // ... }); ``` #### Listening for nested changes [#listening-for-nested-changes] It’s also possible to subscribe to a Storage item and all of its children by passing an optional `isDeep` option in the third argument. In this case, the callback will be passed a list of updates instead of just the new Storage item. Each such update is a `{ type, node, updates }` object. ```ts const { root } = await room.getStorage(); const unsubscribe = room.subscribe( root, (storageUpdates) => { for (const update of storageUpdates) { const { type, // "LiveObject", "LiveList", or "LiveMap" node, updates, } = update; switch (type) { case "LiveObject": { // updates["property"]?.type; is "update" or "delete" // update.node is the LiveObject that has been updated/deleted break; } case "LiveMap": { // updates["key"]?.type; is "update" or "delete" // update.node is the LiveMap that has been updated/deleted break; } case "LiveList": { // updates[0]?.type; is "delete", "insert", "move", or "set" // update.node is the LiveList that has been updated, deleted, or modified break; } } } }, { isDeep: true } ); ``` #### Using async functions You use an `async` function inside the subscription callback, though bear in mind that the callback itself is synchronous, and there’s no guarantee the `async` function will complete before the callback is run again. ```ts const { root } = await room.getStorage(); const unsubscribe = room.subscribe(root, (updatedRoot) => { async function doThing() { await fetch(/* ... */); } doThing(); }); ``` If the order of updates is imporant in your application, and it’s important to ensure that your `async` function doesn’t start before the previous one finishes, you can use a package such as [`async-mutex`](https://www.npmjs.com/package/async-mutex) to help you with this. Using `runExclusive` will effectively form a queue for all upcoming updates, guaranteeing serial execution. ```ts import { Mutex } from "async-mutex"; const { root } = await room.getStorage(); const myMutex = new Mutex(); const unsubscribeUpdates = room.subscribe(root, (root) => { void myMutex.runExclusive(async () => { await fetch(/* ... */); }); }); ``` Note that this may cause a performance penalty in your application, as certain updates will be ignored. ### Room.subscribe("event") [#Room.subscribe.event] Subscribe to events broadcast by [`Room.broadcastEvent`][]. Takes a callback that’s run when another user calls [`Room.broadcastEvent`][]. Provides the `event` along with the `user` and their `connectionId` of the user that sent the message. Returns an unsubscribe function. ```ts // User 1 room.broadcastEvent({ type: "REACTION", emoji: "🔥" }); // +++ // User 2 const unsubscribe = room.subscribe("event", ({ event, user, connectionId }) => { // ^^^^ Will be User 1 if (event.type === "REACTION") { // Do something } }); // +++ ``` Unsubscribe function. Call it to cancel the subscription. Listen to events. Function that’s called when another user sends an event. Receives the event, the [`user`](#user-type) that sent the event, and their `connectionId`. If this event was sent via [`liveblocks.broadcastEvent`](/docs/api-reference/liveblocks-node#post-broadcast-event) or the [Broadcast event API](/docs/api-reference/rest-api-endpoints#post-broadcast-event), `user` will be `null` and `connectionId` will be `-1`. [Learn more](#receiving-events-from-the-server) #### Typing events When [defining your types](#typing-your-data), you can pass a `RoomEvent` type to your config file to receive type hints in your app. To define multiple different custom events, use a union. ```ts declare global { interface Liveblocks { RoomEvent: | { type: "REACTION"; emoji: string } | { type: "ACTION"; action: string }; } } ``` ```ts room.subscribe("event", ({ event, user, connectionId }) => { if (event.type === "REACTION") { // Do something } if (event.type === "ACTION") { // Do something else } }); ``` #### Receiving events from the server [#receiving-events-from-the-server] Events can be received from the server with either [`liveblocks.broadcastEvent`](/docs/api-reference/liveblocks-node#post-broadcast-event) or the [Broadcast Event API](/docs/api-reference/rest-api-endpoints#post-broadcast-event). In events sent from the server, `user` will be `null`, and `connectionId` will be `-1`. ```ts import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST() { await liveblocks.broadcastEvent({ type: "REACTION", emoji: "🔥" }); } ``` ```ts const unsubscribe = room.subscribe("event", ({ event, user, connectionId }) => { // `null`, `-1` console.log(user, connectionId); }); ``` ### Room.subscribe("my-presence") [#Room.subscribe.my-presence] Subscribe to the current user’s Presence. Takes a callback that is called every time the current user presence is updated with [`Room.updatePresence`][]. Returns an unsubscribe function. ```ts const unsubscribe = room.subscribe("my-presence", (presence) => { // Do something }); ``` Unsubscribe function. Call it to cancel the subscription. Listen to the current user’s presence. Function that’s called when the current user’s Presence has updated, for example with [`Room.updatePresence`][]. Receives the updates Presence value. #### Typing Presence To type the Presence values you receive, make sure to set your Presence type. ```ts file="liveblocks.config.ts" declare global { interface Liveblocks { Presence: { status: string; cursor: { x: number; y: number }; }; } } ``` The type received in the callback will match the type passed. Learn more under [typing your data](#typing-your-data). ```ts const unsubscribe = room.subscribe("my-presence", (presence) => { // { status: "typing", cursor: { x: 45, y: 67 } console.log(presence); }); ``` ### Room.subscribe("others") [#Room.subscribe.others] Subscribe to every other users’ updates. Takes a callback that’s called when a user’s Presence updates, or when they enter or leave the room. Returns an unsubscribe function. ```ts const unsubscribe = room.subscribe("others", (others, event) => { // Do something }); ``` Unsubscribe function. Call it to cancel the subscription. Listen to others. Function that’s called when another user’s Presence has updated, for example with [`Room.updatePresence`][], or an others event has occurred. Receives an array of [`User`](#user-type) values for each currently connected user. Also received an object with information about the event that has triggered the update, [learn more](#listening-for-others-events). #### Typing Presence To type the Presence values you receive, make sure to set your Presence type. ```ts file="liveblocks.config.ts" declare global { interface Liveblocks { Presence: { status: string; cursor: { x: number; y: number }; }; } } ``` The type received in the callback will match the type passed. Learn more under [typing your data](#typing-your-data). ```ts const unsubscribe = room.subscribe("others", (others, event) => { // { status: "typing", cursor: { x: 45, y: 67 } console.log(others[0].presence); }); ``` #### Listening for others events [#listening-for-others-events] The `event` parameter returns information on why the callback has just run, for example if their Presence has updated, if they’ve just left or entered the room, or if the current user has disconnected. ```ts const unsubscribe = room.subscribe("others", (others, event) => { if (event.type === "leave") { // A user has left the room // event.user; } if (event.type === "enter") { // A user has entered the room // event.user; } if (event.type === "update") { // A user has updated // event.user; // event.updates; } if (event.type === "reset") { // A disconnection has occurred and others has reset } }); ``` #### Live cursors Here’s a basic example showing you how to render live cursors. [`Room.updatePresence`](/docs/api-reference/liveblocks-client#Room.updatePresence) is being used to update each user’s cursor position. ```ts file="liveblocks.config.ts" declare global { interface Liveblocks { Presence: { cursor: { x: number; y: number }; }; } } ``` ```ts const { room, leave } = client.enterRoom("my-room-id"); // Call this to update the current user's Presence function updateCursorPosition({ x, y }) { room.updatePresence({ cursor: { x, y } }); } const others = room.getOthers(); // Run __renderCursor__ when any other connected user updates their presence const unsubscribe = room.subscribe("others", (others, event) => { for (const { id, presence } of others) { const { x, y } = presence.cursor; __renderCursor__(id, { x, y }); } } // Handle events and rendering // ... ``` Check our [examples page](/examples/browse/cursors) for live demos. ### Room.subscribe("status") [#Room.subscribe.status] Subscribe to WebSocket connection status updates. Takes a callback that is called whenever the connection status changes. Possible value are: `initial`, `connecting`, `connected`, `reconnecting`, or `disconnected`. Returns an unsubscribe function. ```ts const unsubscribe = room.subscribe("status", (status) => { // "connected" console.log(status); }); ``` Unsubscribe function. Call it to cancel the subscription. Listen to status updates. void`} required >
Function that’s called when the room’s connection status has changed. It can return one of five values:
- `"initial"` The room has not attempted to connect yet. - `"connecting"` The room is currently authenticating or connecting. - `"connected"` The room is connected. - `"reconnecting"` The room has disconnected, and is trying to connect again. - `"disconnected"` The room is disconnected, and is no longer attempting to connect.
#### When to use status Status is a low-level API that exposes the WebSocket’s connectivity status. You can use this, for example, to update a connection status indicator in your UI. It would be normal for a client to briefly lose the connection and restore it with quick `connected` → `reconnecting` → `connected` status jumps. ```ts let indicator = "⚪"; const unsubscribe = room.subscribe("status", (status) => { switch (status) { case "connecting": indiciator = "🟡"; break; case "connected": indicator = "🟢"; break; // ... } }); ``` If you’d like to let users know that there may be connectivity issues, don’t use this API, but instead refer to [`Room.subscribe("lost-connection")`][] which was specially built for this purpose. Do not use this API to detect when Storage or Presence are initialized or loaded. "Connected" does not guarantee that Storage or Presence are ready. To detect when Storage is loaded, rely on awaiting the [`Room.getStorage`][] promise or using the [`Room.subscribe("storage-status")`][] event. ### Room.subscribe("lost-connection") [#Room.subscribe.lost-connection] A special-purpose event that will fire when a previously connected Liveblocks client has lost connection, for example due to a network outage, and was unable to recover quickly. This event is [designed to help improve UX for your users](#when-to-use-lost-connection-events), and will not trigger on short interruptions, those that are less than [5 seconds by default](#setting-lost-connection-timeout). The event only triggers if a previously connected client disconnects. ```ts const unsubscribe = room.subscribe("lost-connection", (event) => { // "lost" console.log(event); }); ``` Unsubscribe function. Call it to cancel the subscription. Listen to lost connection events. void`} required >
Function that’s called when a room’s lost connection event has been triggered. It can return one of three values:
- `"lost"` A connection has been lost for longer than [`lostConnectionTimeout`][]. - `"restored"` The connection has been restored again. - `"failed"` The room has been unable to reconnect again, and is no longer trying. This may happen if a user’s network has recovered, but the room’s authentication values no longer allow them to enter.
#### When to use lost connection events [#when-to-use-lost-connection-events] Lost connections events allows you to build high-quality UIs by warning your users that the application is still trying to re-establish the connection, for example through a toast notification. You may want to take extra care in the mean time to ensure their changes won’t go unsaved, or to help them understand why they’re not seeing updates made by others yet. When this happens, this callback is called with the event `lost`. Then, once the connection restores, the callback will be called with the value `restored`. If the connection could definitively not be restored, it will be called with `failed` (uncommon). ```ts import { toast } from "my-preferred-toast-library"; const unsubscribe = room.subscribe("lost-connection", (event) => { switch (event) { case "lost": toast.warn("Still trying to reconnect..."); break; case "restored": toast.success("Successfully reconnected again!"); break; case "failed": toast.error("Could not restore the connection"); break; } }); ``` #### Setting lost connection timeout [#setting-lost-connection-timeout] The [`lostConnectionTimeout`][] configuration option will determine how quickly the event triggers after a connection loss occurs. By default, it’s set to `5000`ms, which is 5 seconds. ```ts import { createClient } from "@liveblocks/client"; const client = createClient({ // Throw lost-connection event after 5 seconds offline lostConnectionTimeout: 5000, // ... }); ``` ### Room.subscribe("error") [#Room.subscribe.error] Subscribe to unrecoverable room connection errors. This event will be emitted immediately before the client disconnects and won’t try reconnecting again. Returns an unsubscribe function. If you’d like to retry connecting, call [`room.reconnect`][]. ```ts const unsubscribe = room.subscribe("error", (error) => { switch (error.code) { case -1: // Authentication error break; case 4001: // Could not connect because you don't have access to this room break; case 4005: // Could not connect because room was full break; case 4006: // The room ID has changed, get the new room ID (use this for redirecting) const newRoomId = error.message; break; default: // Unexpected error break; } }); ``` Unsubscribe function. Call it to cancel the subscription. Listen to error events. void`} required >
Function that’s called when an unrecoverable error event has been triggered. `error.code` can return one of these values:
- `-1` Authentication error. - `4001` Could not connect because you don't have access to this room. - `4005` Could not connect because room was full. - `4006` The room ID has changed.
#### When to use error events You can use this event to trigger a “Not allowed” screen/dialog. It can also be helpful for implementing a redirect to another page. ```ts const unsubscribe = room.subscribe("error", (error) => { // Could not connect because you don't have access to this room if (error.code === 4001) return __displayForbiddenEntryDialog__(); } }); ``` #### When a room ID has changed When a room ID has been changed with [`liveblocks.updateRoomId`](/docs/api-reference/liveblocks-node#post-rooms-update-roomId) or the [Update Room ID API](/docs/api-reference/rest-api-endpoints#post-rooms-update-roomId), `error.message` will contain the new room ID. ```ts const unsubscribe = room.subscribe("error", (error) => { // The room ID has changed, get the new room ID if (error.code === 4006) const newRoomId = error.message; return __redirect__(`/app/${newRoomId}`) } }); ``` ### Room.subscribe("history") [#Room.subscribe.history] Subscribe to the current user’s history changes. Returns an unsubscribe function. ```ts const unsubscribe = room.subscribe("history", ({ canUndo, canRedo }) => { // Do something }); ``` Unsubscribe function. Call it to cancel the subscription. Listen to history events. void`} required > Function that’s called when the current user’s history changes. Returns booleans that describe whether the user can use [undo](/docs/api-reference/liveblocks-client#Room.history.undo) or [redo](/docs/api-reference/liveblocks-client#Room.history.redo). ### Room.subscribe("storage-status") [#Room.subscribe.storage-status] Subscribe to Storage status changes. Use this to tell whether Storage has been synchronized with the Liveblocks servers. Returns an unsubscribe function. ```ts const unsubscribe = room.subscribe("storage-status", (status) => { switch (status) { case "not-loaded": // Storage has not been loaded yet break; case "loading": // Storage is currently loading break; case "synchronizing": // Local Storage changes are being synchronized break; case "synchronized": // Local Storage changes have been synchronized break; } }); ``` Unsubscribe function. Call it to cancel the subscription. Listen to Storage status events. void`} required >
Function that’s called when the current user’s Storage updated status have changed. `status` can be one of four types.
- `"not-loaded` - Storage has not been loaded yet as [`getStorage`][] has not been called. - `"loading"` - Storage is currently loading for the first time. - `"synchronizing"` - Local Storage changes are currently being synchronized. - `"synchronized"` - Local Storage changes have been synchronized
### Room.batch Batches Storage and Presence modifications made during the given function. Each modification is grouped together, which means that other clients receive the changes as a single message after the batch function has run. When undoing or redoing these changes, the entire batch will be undone/redone together instead of atomically. ```ts const { root } = await room.getStorage(); room.batch(() => { root.set("x", 0); room.updatePresence({ cursor: { x: 100, y: 100 } }); }); ``` Returns the return value from the callback. A callback containing every Storage and Presence notification that will be part of the batch. Cannot be an `async` function. #### When to batch updates For the most part, _you don’t need to batch updates_. For example, given a [whiteboard application](/examples/browse/whiteboard), it’s perfectly fine to update a note’s position on the board multiple times per second, in separate updates. However, should you implement a “Delete all” button, that may delete 50 notes at once, this is where you should use a batch. ```ts const { root } = await room.getStorage(); const notes = root.get("notes"); // ✅ Batch simultaneous changes together room.batch(() => { for (const noteId of notes.keys()) { notes.delete(noteId); } }); ``` This batch places each [`LiveMap.delete`](/docs/api-reference/liveblocks-client#LiveMap.delete) call into a single WebSocket update, instead of sending multiple updates. This will be much quicker. #### Batching groups history changes Batching changes will also group changes into a single history state. ```ts const { root } = await room.getStorage(); const pet = root.set("pet", new LiveObject({ name: "Fido", age: 5 })); // ✅ Batch groups changes into one room.batch(() => { pet.set("name", "Felix"); pet.set("age", 10); }); // { name: "Felix", age: 10 } pet.toImmutable(); room.history.undo(); // { name: "Fido", age: 5 } pet.toImmutable(); ``` #### Doesn’t work with async functions Note that `room.batch` cannot take an `async` function. ```tsx // ❌ Won't work room.batch(async () => { // ... }); // ✅ Will work room.batch(() => { // ... }); ``` ### Room.history Room’s history contains functions that let you undo and redo operations made to Storage and Presence on the current client. Each user has a separate history stored in memory, and history is reset when the page is reloaded. ```ts const { undo, redo, pause, resume /*, ... */ } = room.history; ``` Note that to undo or redo in Yjs, you must use a separate history manager, [`Y.UndoManager`](https://docs.yjs.dev/api/undo-manager). #### Add Presence to history By default, history is only enabled for Storage. However, you can use the `addToHistory` option to additionally [add Presence state to history](/docs/api-reference/liveblocks-client#add-presence-to-history). ```tsx room.updatePresence({ color: "blue" }, { addToHistory: true }); ``` ### Room.history.undo Reverts the last operation. It does not impact operations made by other clients, and will only undo changes made by the current client. ```ts const person = new LiveObject(); person.set("name", "Pierre"); person.set("name", "Jonathan"); room.history.undo(); // "Pierre" root.get("name"); ``` _Nothing_ _None_ ### Room.history.redo Restores the last undone operation. It does not impact operations made by other clients, and will only restore changes made by the current client. ```ts const person = new LiveObject(); person.set("name", "Pierre"); person.set("name", "Jonathan"); room.history.undo(); room.history.redo(); // "Jonathan" root.get("name"); ``` _Nothing_ _None_ ### Room.history.canUndo Returns true or false, depending on whether there are any operations to undo. Helpful for disabling undo buttons. ```ts const person = new LiveObject(); person.set("name", "Pierre"); // true room.history.canUndo(); room.history.undo(); // false room.history.canUndo(); ``` Whether there is an undo operation in the current history stack. _None_ ### Room.history.canRedo Returns true or false, depending on whether there are any operations to redo. Helpful for disabling redo buttons. ```ts const person = new LiveObject(); person.set("name", "Pierre"); // false room.history.canRedo(); room.history.undo(); // true room.history.canRedo(); ``` Whether there is a redo operation in the current history stack. _None_ ### Room.history.clear Clears the undo and redo stacks for the current client. Explicitly clearing history resets the ability to undo beyond the current document state. Other clients’ histories are unaffected. ```ts const person = new LiveObject(); person.set("name", "Pierre"); // true room.history.canUndo(); room.history.clear(); // false room.history.canUndo(); ``` _Nothing_ _None_ ### Room.history.pause All future modifications made on the Room will be merged together to create a single history item until resume is called. ```ts const info = new LiveObject({ time: "one" }); room.history.pause(); info.set("time", "two"); info.set("time", "three"); room.history.resume(); room.history.undo(); // "one" room.get("time"); ``` _Nothing_ _None_ ### Room.history.resume Resumes history after a [pause](#Room.history.pause). Modifications made on the Room are not merged into a single history item any more. ```ts const info = new LiveObject({ time: "one" }); room.history.pause(); info.set("time", "two"); info.set("time", "three"); room.history.resume(); room.history.undo(); // "one" room.get("time"); ``` _Nothing_ _None_ ### Room.connect Connect the local room instance to the Liveblocks server. Does nothing if the room is already connecting, reconnecting or connected. We don’t recommend using this API directly. ```ts room.connect(); ``` _Nothing_ _None_ ### Room.reconnect Reconnect the local room instance to the Liveblocks server, using a new WebSocket connection. ```ts room.reconnect(); ``` _Nothing_ _None_ ### Room.disconnect Disconnect the local room instance from the Liveblocks server. The room instance will remain functional (for example, it will still allow local presence or storage mutations), but since it’s no longer connected, changes will not be persisted or synchronized until the room instance is reconnected again. We don’t recommend using this API directly. ```ts room.disconnect(); ``` _Nothing_ _None_ ## Comments ### Room.getThreads Returns threads, and their associated inbox notifications, that are in the current room. It also returns the request date that can be used for subsequent polling. It’s possible to filter for [a thread’s resolved status](#filtering-resolved-status) and using [custom metadata](#filtering-metadata). ```ts const { threads, inboxNotifications, requestedAt } = await room.getThreads(); // [{ id: "th_s436g8...", type: "thread" }, ...] console.log(threads); // [{ id: "in_fwh3d4...", kind: "thread", }, ...] console.log(inboxNotifications); ``` Threads within the current room. Inbox notifications associated with the threads. The request date to use for subsequent polling. Only return `resolved` or `unresolved` threads. [Learn more](#filtering-resolved-status). Only return threads containing the custom metadata. Metadata is set yourself when creating a thread, for example `{ priority: "HIGH" }`. [Learn more](#filtering-metadata). #### Filtering resolved status [#filtering-resolved-status] You can filter threads by those that are resolved, or unresolved, by passing a `boolean` to `query.resolved`. ```ts // Filtering for threads that are unresolved const threads = await room.getThreads({ query: { // +++ resolved: false, // +++ }, }); ``` #### Filtering metadata [#filtering-metadata] You can define custom metadata when [creating a thread](/docs/api-reference/liveblocks-client#Room.createThread), and the `query.metadata` option allows you to return only threads that match. ```ts // Creating a thread with `priority` metadata await room.createThread({ body: { // ... }, // +++ metadata: { priority: "HIGH" }, // +++ }); // Filtering for threads with the same metadata const threads = await room.getThreads({ query: { // +++ metadata: { priority: "HIGH" }, // +++ }, }); ``` You can also filter for metadata that begins with a specific string. ```ts // Creating a thread with `{ assigned: "sales:stacy" } metadata await room.createThread({ body: { // ... }, // +++ metadata: { assigned: "sales:stacy" }, // +++ }); // Filtering for threads with `assigned` metadata that starts with `sales:` const threads = await room.getThreads({ query: { // +++ metadata: { assigned: { startsWith: "sales:", }, }, // +++ }, }); ``` ### Room.getThreadsSince Returns threads, and their associated inbox notifications, that have been updated or deleted since the requested date. Helpful when used in combination with [`Room.getThreads`](#Room.getThreads) to initially fetch all threads, then receive updates later. ```ts const initial = await room.getThreads(); const { threads, inboxNotifications, requestedAt } = await room.getThreadsSince( { since: initial.requestedAt } ); // { updated: [{ id: "th_s4368s...", type: "thread" }, ...], deleted: [...] } console.log(threads); // { updated: [{ id: "in_ds83hs...", kind: "thread", }, ...], deleted: [...] } console.log(inboxNotifications); ``` Threads that have been updated or deleted since the requested date. Inbox notifications that have been updated or deleted since the requested date. The request date to use for subsequent polling. Only return threads that have been updated or deleted after this date. ### Room.getThread Returns a thread and its associated inbox notification, from its ID, if it exists. ```ts const { thread, inboxNotification } = await room.getThread("th_xxx"); ``` The thread ID can be retrieved from existing threads. ```ts const newThread = await room.createThread(/* ... */); const { thread, inboxNotification } = await room.getThread(newThread.id); ``` The requested thread, or `undefined` if it doesn’t exist. The inbox notification associated with the thread, or `undefined` if it doesn’t exist. The ID of the thread you want to retrieve. ### Room.createThread Creates a thread, and its initial comment, in the current room. A comment’s body is an array of paragraphs, each containing child nodes, learn more under [creating thread content](#creating-thread-content). ```ts const thread = await room.createThread({ body: { version: 1, content: [{ type: "paragraph", children: [{ text: "Hello" }] }], }, }); ``` The thread that has been created. The content of the comment, see [creating thread content](#creating-thread-content). The IDs of the comment’s attachments. Custom metadata to be attached to the thread, see [defining thread metadata](#defining-thread-metadata). #### Creating thread content [#creating-thread-content] A comment’s body is an array of paragraphs, each containing child nodes. Here’s an example of how to construct the following simple comment body, which can be passed to `room.createThread`. > Hello **world** > >
> _Second_ paragraph! ```tsx import { CommentBody } from "@liveblocks/client"; const body: CommentBody = { version: 1, content: [ // +++ { type: "paragraph", children: [{ text: "Hello " }, { text: "world", bold: true }], }, { type: "paragraph", children: [{ text: "Second", italic: true }, { text: " paragraph!" }], }, // +++ ], }; const thread = await room.createThread({ body }); ``` It’s also possible to create links and mentions. > **@Jody Hekla** the > **[Liveblocks](https://liveblocks.io)** website is cool! ```ts const body: CommentBody = { version: 1, content: [ // +++ { type: "paragraph", children: [ { type: "mention", id: "jody.hekla" }, { text: " the " }, { text: "Liveblocks", type: "link", url: "https://liveblocks.io" }, { text: " website is cool!" }, ], }, // +++ ], }; ``` #### Defining thread metadata [#defining-thread-metadata] Custom metadata can be attached to each thread. `string`, `number`, and `boolean` properties are allowed. ```ts import { ThreadMetadata } from "@liveblocks/client"; const metadata: ThreadMetadata = { color: "blue", page: 3, pinned: true, }; const thread = await room.createThread({ body, metadata }); ``` ### Room.deleteThread Deletes a thread by its ID. ```ts await room.deleteThread("th_xxx"); ``` _Nothing_ The ID of the thread to delete. ### Room.editThreadMetadata Edits a thread’s custom metadata. Metadata can be a `string`, `number`, or `boolean`. To delete an existing metadata property, set its value to `null`. ```ts await room.editThreadMetadata({ threadId: "th_xxx", metadata: { color: "blue", page: 3, pinned: true, }, }); ``` The thread metadata. The ID of the thread. An object containing the metadata properties to update. Metadata can be a `string`, `number`, or `boolean`. To delete an existing metadata property, set its value to `null`. ### Room.markThreadAsResolved Marks a thread as resolved. ```ts await room.markThreadAsResolved("th_xxx"); ``` _Nothing_ The ID of the thread to resolve. ### Room.markThreadAsUnresolved Marks a thread as unresolved. ```ts await room.markThreadAsUnresolved("th_xxx"); ``` _Nothing_ The ID of the thread to resolve. ### Room.createComment Creates a comment in a given thread. ```ts const comment = await room.createComment({ threadId: "th_xxx", body: { version: 1, content: [{ type: "paragraph", children: [{ text: "Hello" }] }], }, }); ``` The comment that has been created. The ID of the thread that the comment will be added to. The IDs of the comment’s attachments. The content of the comment, see [creating comment content](#creating-comment-content). #### Creating comment content [#creating-comment-content] A comment’s body is an array of paragraphs, each containing child nodes. Here’s an example of how to construct the following simple comment body, which can be passed to `room.createComment`. > Hello **world** > >
> _Second_ paragraph! ```tsx import { CommentBody } from "@liveblocks/client"; const thread = await room.createThread(/* ... */); const body: CommentBody = { version: 1, content: [ // +++ { type: "paragraph", children: [{ text: "Hello " }, { text: "world", bold: true }], }, { type: "paragraph", children: [{ text: "Second", italic: true }, { text: " paragraph!" }], }, // +++ ], }; const comment = await room.createComment({ threadId: thread.id, body }); ``` It’s also possible to create links and mentions. > **@Jody Hekla** the > **[Liveblocks](https://liveblocks.io)** website is cool! ```ts const body: CommentBody = { version: 1, content: [ // +++ { type: "paragraph", children: [ { type: "mention", id: "jody.hekla" }, { text: " the " }, { text: "Liveblocks", type: "link", url: "https://liveblocks.io" }, { text: " website is cool!" }, ], }, // +++ ], }; ``` ### Room.editComment Edits a comment, replacing its existing comment body and optionally updating its attachments. Learn more about [creating comment content](#creating-comment-content). ```ts const comment = await room.editComment({ threadId: "th_xxx", commentId: "cm_xxx" body: { version: 1, content: [{ type: "paragraph", children: [{ text: "Hello" }] }], }, }); ``` The comment that has been edited. The ID of the thread containing the comment. The ID of the comment that’s being edited. The IDs of the comment’s attachments. The content of the comment, see [creating comment content](#creating-comment-content). ### Room.deleteComment Deletes a comment. If it is the last non-deleted comment, the thread also gets deleted. ```ts await room.deleteComment({ threadId: "th_xxx", commentId: "cm_xxx", }); ``` _Nothing_ The ID of the thread containing the comment. The ID of the comment that’s being edited. ### Room.addReaction Adds a reaction from the current user on a comment. ```ts const reaction = await room.addReaction({ threadId: "th_xxx", commentId: "cm_xxx", emoji: "👍", }); ``` The reaction that has been created. The ID of the thread containing the comment. The ID of the comment to add a reaction to. The emoji reaction to add. ### Room.removeReaction Removes a reaction from a comment. ```ts await room.removeReaction({ threadId: "th_xxx", commentId: "cm_xxx", emoji: "👍", }); ``` _Nothing_ The ID of the thread containing the comment. The ID of the comment to remove a reaction from. The emoji reaction to remove. ### Room.prepareAttachment Creates a local attachment from a file. ```ts const attachment = room.prepareAttachment({ file: new File(["Hello, world!"], "hello.txt"), }); // { "id": "at_1e6nNX...", "name": "hello.txt", "type": "attachment", ... } console.log(attachment); ``` The local attachment that has been created. The file to create the attachment from. ### Room.uploadAttachment Uploads a local attachment. ```ts const attachment = room.prepareAttachment(file); await room.uploadAttachment(attachment); ``` Optionally, an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) can be passed to cancel the upload. ```ts const attachment = room.prepareAttachment(file); // Cancel the upload after 5 seconds room.uploadAttachment(attachment, { signal: AbortSignal.timeout(5000) }); ``` The attachment that has been uploaded. The file to create the attachment from. A set of options. Only the inbox notifications updated or deleted after this date will be returned. ### Room.getAttachmentUrl Returns a presigned URL for an attachment by its ID. ```ts const url = await room.getAttachmentUrl("at_xxx"); // "https://..." console.log(url); ``` A presigned URL for the attachment. The ID of the attachment to get the URL for. ## Notifications ### Client.getInboxNotifications Returns the current user’s inbox notifications and their associated threads. It also returns the request date that can be used for subsequent polling. ```ts const { inboxNotifications, threads, requestedAt } = await client.getInboxNotifications(); // [{ id: "in_fwh3d4...", kind: "thread", }, ...] console.log(inboxNotifications); // [{ id: "th_s436g8...", type: "thread" }, ...] console.log(threads); ``` Current user’s inbox notifications. Threads associated with the inbox notifications. The request date to use for subsequent polling. _None_ ### Client.getInboxNotificationsSince Returns the updated and deleted inbox notifications and their associated threads since the requested date. Helpful when used in combination with [`Client.getInboxNotifications`](#Client.getInboxNotifications) to initially fetch all notifications, then receive updates later. ```ts const initial = await client.getInboxNotifications(); const { inboxNotifications, threads, requestedAt } = await client.getInboxNotificationsSince({ since: initial.requestedAt }); // { updated: [{ id: "in_ds83hs...", kind: "thread", }, ...], deleted: [...] } console.log(inboxNotifications); // { updated: [{ id: "th_s4368s...", type: "thread" }, ...], deleted: [...] } console.log(threads); ``` Inbox notifications that have been updated or deleted since the requested date. Threads that have been updated or deleted since the requested date. The request date to use for subsequent polling. Only the inbox notifications updated or deleted after this date will be returned. ### Client.getUnreadInboxNotificationsCount Gets the number of unread inbox notifications for the current user. ```ts const count = await client.getUnreadInboxNotificationsCount(); ``` Number of unread inbox notifications. _None_ ### Client.markAllInboxNotificationsAsRead Marks all inbox notifications as read, for the current user. ```ts await client.markAllInboxNotificationsAsRead(); ``` _Nothing_ _None_ ### Client.markInboxNotificationAsRead Marks an inbox notification as read, for the current user. ```ts await client.markAllInboxNotificationsAsRead("in_xxx"); ``` _Nothing_ The ID of the inbox notification to be marked as read. ### Client.deleteAllInboxNotifications Deletes an inbox notification for the current user. ```ts await client.deleteAllInboxNotifications(); ``` _Nothing_ _None_ ### Client.deleteInboxNotification Deletes an inbox notification for the current user. ```ts await client.deleteInboxNotification("in_xxx"); ``` _Nothing_ The ID of the inbox notification to be deleted. ### Room.getNotificationSettings Gets the user’s notification settings for the current room. This notates which [`inboxNotifications`](/docs/api-reference/liveblocks-client#Client.getInboxNotifications) the current user receives in the current room. ```ts const settings = await room.getNotificationSettings(); ``` Notification settings for Liveblocks products.
Returns the current room’s notification settings for threads. It can return one of three values:
- `"all"` Receive notifications for every activity. - `"replies_and_mentions"` Receive notifications for mentions and thread you’re participating in. - `"none"` No notifications are received.
_None_ ### Room.updateNotificationSettings Updates the user’s notification settings for the current room. Updating this setting will change which [`inboxNotifications`](/docs/api-reference/liveblocks-client#Client.getInboxNotifications) the current user receives in the current room. ```ts const settings = await room.updateNotificationSettings({ threads: "replies_and_mentions", }); ``` Notification settings for Liveblocks products.
Returns the current room’s notification settings for threads. It can return one of three values:
- `"all"` Receive notifications for every activity. - `"replies_and_mentions"` Receive notifications for mentions and thread you’re participating in. - `"none"` No notifications are received.
Sets the current room’s notification settings for threads. It can be one of three values:
- `"all"` Receive notifications for every activity. - `"replies_and_mentions"` Receive notifications for mentions and thread you’re participating in. - `"none"` No notifications are received.
### Client.getNotificationSettings [@badge=Beta] Returns the user’s notification settings in the current project, in other words which [notification webhook events](/docs/platform/webhooks#NotificationEvent) will be sent for the current user. User notification settings are project-based, which means that this returns the current user’s settings for every room. ```ts const settings = await client.getNotificationSettings(); // { email: { thread: true, ... }, slack: { thread: false, ... }, ... } console.log(settings); ``` A user’s initial settings are set in the dashboard, and different kinds should be enabled there. If no kind is enabled on the current channel, `null` will be returned. For example, with the email channel: ```ts const settings = await client.getNotificationSettings(); // { email: null, ... } console.log(settings); ``` Current user’s notification settings. _None_ ### Client.updateNotificationSettings [@badge=Beta] Updates the current user’s notification settings, which affects which [notification webhook events](/docs/platform/webhooks#NotificationEvent) will be sent for the current user. User notification settings are project-based, which means that this modifies the current user’s settings in every room. Each notification `kind` must first be enabled on your project’s notification dashboard page before settings can be used. ```ts const settings = await client.updateNotificationSettings({ email: { thread: false }, slack: { textMention: true }, }); // { email: { thread: false, ... }, slack: { textMention: true, ... }, ... } console.log(settings); ``` Current user’s notification settings. A deep partial object containing the user notification settings to update. Custom notifications can be set too.
```js title="Examples" isCollapsable isCollapsed // You only need to pass partials await client.updateNotificationSettings({ email: { thread: true }, }); // Enabling a custom notification on the slack channel await client.updateNotificationSettings({ slack: { $myCustomNotification: true }, }); // Setting complex settings await client.updateNotificationSettings({ email: { thread: true, textMention: false, $newDocument: true, }, slack: { thread: false, $fileUpload: false, }, teams: { thread: true, }, }); ```
The email notification settings. The slack notification settings. The microsoft teams notification settings. The web push notification settings.
## Storage Each room contains Storage, a conflict-free data store that multiple users can edit at the same time. When users make edits simultaneously, conflicts are resolved automatically, and each user will see the same state. Storage is ideal for storing permanent document state, such as shapes on a canvas, notes on a whiteboard, or cells in a spreadsheet. ### Data structures Storage provides three different conflict-free data structures, which you can use to build your application. All structures are permanent and persist when all users have left the room, unlike [Presence](/docs/ready-made-features/presence) which is temporary. - [`LiveObject`][] - Similar to JavaScript object. Use this for storing records with fixed key names and where the values don’t necessarily have the same types. For example, a `Person` with a `name: string` and an `age: number` field. If multiple clients update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. - [`LiveList`][] - An ordered collection of items synchronized across clients. Even if multiple users add/remove/move elements simultaneously, LiveList will solve the conflicts to ensure everyone sees the same collection of items. - [`LiveMap`][] - Similar to a JavaScript Map. Use this for indexing values that all have the same structure. For example, to store an index of `Person` values by their name. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. ### Typing Storage [#typing-storage] To type the Storage values you receive, make sure to set your `Storage` type. ```ts file="liveblocks.config.ts" import { LiveList } from "@liveblocks/client"; declare global { interface Liveblocks { Storage: { animals: LiveList<{ name: string }>; }; } } ``` The type received in the callback will match the type passed. Learn more under [typing your data](#typing-your-data). ```ts const { root } = await room.getStorage(); const animals = root.get("animals"); const unsubscribe = room.subscribe(animals, (updatedAnimals) => { // LiveList<[{ name: "Fido" }, { name: "Felix" }]> console.log(updatedAnimals); }); ``` ### Nesting data structures All Storage data structures can be nested, allowing you to create complex trees of conflict-free data. ```ts file="liveblocks.config.ts" import { LiveObject, LiveList, LiveMap } from "@liveblocks/client"; type Person = LiveObject<{ name: string; pets: LiveList; }>; declare global { interface Liveblocks { Storage: { people: LiveMap; }; } } ``` ```ts import { LiveObject, LiveList, LiveMap } from "@liveblocks/client"; const pets = new LiveList(["Cat", "Dog"]); const person = new LiveObject({ name: "Alicia", pets }); const people = new LiveMap(); people.set("alicia", person); const { root } = await room.getStorage(); root.set(people); ``` Get the [Liveblocks DevTools extension](/devtools) to develop and debug your application as you build it. ### Room.getStorage Get the room’s Storage asynchronously (returns a Promise). The promise will resolve once the Storage’s root is loaded and available. The Storage’s root is always a [`LiveObject`][]. ```ts const { root } = await room.getStorage(); ``` The room’s Storage structures. `root` is a `LiveObject`, and is the root of your Storage. Learn more about [typing Storage](#typing-storage). _None_ ## LiveObject The `LiveObject` class is similar to a JavaScript object that is synchronized on all clients. Use this for storing records with fixed key names and where the values don’t necessarily have the same types. For example, a `Person` with `name` and `age` fields. To add typing, read more under [typing Storage](#typing-storage). ```ts type Person = LiveObject<{ name: string; age: number; }>; ``` Keys are strings, and values can contain other Storage structures, or JSON-serializable data. If multiple clients update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. ### new LiveObject [#LiveObject.constructor] Create an empty `LiveObject` ```ts import { LiveObject } from "@liveblocks/client"; const object = new LiveObject(); ``` Create a `LiveObject` with initial data. ```ts import { LiveObject } from "@liveblocks/client"; const object = new LiveObject({ firstName: "Margaret", lastName: "Hamilton" }); ``` The newly created `LiveObject`. The initial value for the `LiveObject`. Can contain JSON-serializable data and other Liveblocks conflict-free data structures. #### Add a LiveObject to Storage The Storage root is `LiveObject` itself, so you can use [`LiveObject.set`]() to add a new property to your root. If you’ve [typed Storage](#typing-storage) you’ll have type hints as you build. ```ts import { LiveObject } from "@liveblocks/client"; const { root } = await room.getStorage(); const person = new LiveObject({ name: "Alicia" }); root.set("person", person); ``` ### delete [#LiveObject.delete] Delete a property from the `LiveObject` ```ts const object = new LiveObject({ firstName: "Ada", lastName: "Lovelace" }); object.delete("lastName"); // { firstName: "Ada" } object.toImmutable(); ``` _Nothing_ The key of the property you’re deleting. If the property doesn’t exist, nothing will occur. ### get [#LiveObject.get] Get a property from the `LiveObject`. ```ts const object = new LiveObject({ firstName: "Ada", lastName: "Lovelace" }); // "Ada" object.get("firstName"); ``` The value of the property. Returns `undefined` if it doesn’t exist. The key of the property you’re getting. ### set [#LiveObject.set] Adds or updates a property with the specified key and a value. ```ts const object = new LiveObject({ firstName: "Marie" }); object.set("lastName", "Curie"); // { firstName: "Ada", lastName: "Curie" } object.toImmutable(); ``` _Nothing_ The key of the property you’re setting. The value of the property you’re setting. Can contain JSON-serializable data and other Liveblocks conflict-free data structures. ### update [#LiveObject.update] Adds or updates multiple properties at once. Nested changes to other Storage types will not be applied. ```ts const object = new LiveObject({ firstName: "Grace" }); object.update({ lastName: "Hopper", job: "Computer Scientist" }); // { firstName: "Grace", lastName: "Hopper", job: "Computer Scientist" } object.toImmutable(); ``` _Nothing_ The keys and values you’re updating. Can contain JSON-serializable data and other Liveblocks conflict-free data structures. Nested changes to other Storage types will not be applied. ### clone [#LiveObject.clone] Returns a deep copy of the `LiveObject` that can be inserted elsewhere in the Storage tree. ```ts const obj = new LiveObject(/* ... */); root.set("a", obj); root.set("b", obj.clone()); ``` The cloned `LiveObject`. _None_ ### toImmutable [#LiveObject.toImmutable] Returns an immutable JavaScript object that is equivalent to the `LiveObject`. Nested values will also be immutable. ```ts const liveObject = new LiveObject({ firstName: "Grace", lastName: "Hopper", hobbies: new LiveList(["reading", "piano"]), }); // { // firstName: "Grace", // lastName: "Hopper", // hobbies: ["reading", "piano"] // } liveObject.toImmutable(); ``` Returns a JavaScript object in the shape of your data structure. `LiveObject` is converted to an object, `LiveMap` to a map, and `LiveList` to an array. _None_ ### toObject [#LiveObject.toObject] Starting with 0.18, we recommend [`toImmutable`][] instead. It’s faster, cached, and leads to fewer surprises. Transform the `LiveObject` into a normal JavaScript object. ```ts const liveObject = new LiveObject({ firstName: "Grace", lastName: "Hopper" }); liveObject.toObject(); // { firstName: "Grace", lastName: "Hopper" } ``` Please note that this method won’t recursively convert Live structures, which may be surprising: ```ts const liveObject = new LiveObject({ animals: new LiveList(["🦁", "🦊", "🐵"]), }); liveObject.toObject(); // { animals: } // ❗️ ``` ## LiveMap The `LiveMap` class is similar to a [JavaScript Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) that is synchronized on all clients. Use this for indexing values that all have the same structure. For example, to store an index of `Person` values by their name. To add typing, read more under [typing Storage](#typing-storage). ```ts type Shapes = LiveMap>; ``` Keys are strings, and values can contain other Storage structures, or JSON-serializable data. If multiple clients update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. ### new LiveMap [#LiveMap.constructor] Create an empty `LiveMap`. ```ts const map = new LiveMap(); ``` Create a `LiveMap` with initial data. ```ts const map = new LiveMap([ ["nimesh", "developer"], ["pierre", "designer"], ]); ``` The newly created `LiveMap`. The initial value for the `LiveMap`. An array of tuples, each containing a key and a value. The values can contain JSON-serializable data and other Liveblocks conflict-free data structures. #### Add a LiveMap to Storage The Storage root is a `LiveObject`, so you can create a new `LiveMap` then use [`LiveObject.set`]() to add it to your root. If you’ve [typed Storage](#typing-storage) you’ll have type hints as you build. ```ts import { LiveMap } from "@liveblocks/client"; const { root } = await room.getStorage(); const people = new LiveMap([ ["vincent", "engineer"], ["marc", "designer"], ]); root.set("people", people); ``` ### delete [#LiveMap.delete] Removes the specified element by key. Returns true if an element existed and has been removed, or false if the element does not exist. ```ts const map = new LiveMap([ ["nimesh", "developer"], ["pierre", "designer"], ]); // true map.delete("nimesh"); // Map { "pierre" => "designer" } map.toImmutable(); ``` If the element existed and was removed. The key of the element you’re deleting. If the element doesn’t exist, nothing will occur. ### entries [#LiveMap.entries] Returns a new [Iterator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators) object that contains the `[key, value]` pairs for each element. ```ts for (const [key, value] of map.entries()) { // Iterate over all the keys and values of the map } ``` If your TypeScript project targets es5 or lower, you’ll need to enable the --downlevelIteration option to use this API. A new Iterator object for the `LiveMap`, containing the `[key, value]` pairs for each element. _None_ ### forEach [#LiveMap.forEach] Executes a provided function once per each key/value pair in the Map object, in insertion order. ```ts const map = new LiveMap([ ["nimesh", "developer"], ["pierre", "designer"], ]); // "developer", "designer" map.forEach((value, key, liveMap) => console.log(value)); ``` _Nothing_ A callback for each entry. The callback is passed the current `value`, `key`, and the `LiveMap`. Return values are ignored. ### get [#LiveMap.get] Returns a specified element from the `LiveMap`. Returns `undefined` if the key can’t be found. ```ts const map = new LiveMap([ ["nimesh", "developer"], ["pierre", "designer"], ]); // "developer" map.get("nimesh"); // undefined map.get("alicia"); ``` The value of the entry. Returns `undefined` if it doesn’t exist. The key of the entry you’re getting. ### has [#LiveMap.has] Returns a boolean indicating whether an element with the specified key exists or not. ```ts const map = new LiveMap([ ["nimesh", "developer"], ["pierre", "designer"], ]); // true map.has("nimesh"); // false map.has("alicia"); ``` Whether the entry exists. The key of the entry you’re getting. ### keys [#LiveMap.keys] Returns a new [Iterator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators) object that contains the keys for each element. ```ts for (const key of map.keys()) { // Iterate over all the keys and values of the map } ``` If your TypeScript project targets es5 or lower, you’ll need to enable the --downlevelIteration option to use this API. A new Iterator object for the `LiveMap`, containing the keys of each entry. _None_ ### set [#LiveMap.set] Adds or updates an element with a specified key and a value. ```ts const map = new LiveMap(); map.set("vincent", "engineer"); // Map { "vincent" => "engineer" } map.toImmutable(); ``` _Nothing_ The key of the entry you’re setting. The value of the entry you’re setting. Can contain JSON-serializable data and other Liveblocks conflict-free data structures. ### size [#LiveMap.size] Returns the number of elements in the `LiveMap`. ```ts const map = new LiveMap([ ["nimesh", "developer"], ["pierre", "designer"], ]); // 2 map.size; ``` The number of entries in the `LiveMap` _N/A_ ### values [#LiveMap.values] Returns a new [Iterator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators) object that contains the the values for each element. ```ts for (const value of map.values()) { // Iterate over all the values of the map } ``` If your TypeScript project targets es5 or lower, you’ll need to enable the --downlevelIteration option to use this API. A new Iterator object for the `LiveMap`, containing the values of each entry. _None_ ### clone [#LiveMap.clone] Returns a deep copy of the `LiveMap` that can be inserted elsewhere in the Storage tree. ```ts const map = new LiveMap(/* ... */); root.set("a", map); root.set("b", map.clone()); ``` The cloned `LiveMap`. _None_ ### toImmutable [#LiveMap.toImmutable] Returns an immutable ES6 Map that is equivalent to the `LiveMap`. Nested values will also be immutable. ```ts const map = new LiveMap([ ["florent", new LiveObject({ role: "engineer" })], ["marc", new LiveObject({ role: "designer" })], ]); // Map { // "florent" => { role: "engineer" }, // "marc" => { role: "designer" }, // } map.toImmutable(); ``` Returns a JavaScript object in the shape of your data structure. `LiveMap` is converted to a map, `LiveObject` to an object, and `LiveList` to an array. _None_ ## LiveList The `LiveList` class represents an ordered collection of items that is synchronized across clients. To add typing, read more under [typing Storage](#typing-storage). ```ts type Names = LiveList; ``` Items can contain other Storage structures, or JSON-serializable data. ### new LiveList [#LiveList.constructor] Create an empty `LiveList`. ```ts const list = new LiveList(); ``` Create a `LiveList` with initial data. ```ts const list = new LiveList(["adrien", "jonathan"]); ``` The newly created `LiveList`. The initial array of values for the `LiveList`. Can contain JSON-serializable data and other Liveblocks conflict-free data structures. ### clear [#LiveList.clear] Removes all the elements. ```ts const list = new LiveList(["adrien", "jonathan"]); list.clear(); // [] list.toImmutable(); ``` _Nothing_ _None_ ### delete [#LiveList.delete] Deletes an element at the specified index. If the index doesn’t exist, an `Error` is thrown. ```ts const list = new LiveList(["adrien", "jonathan"]); list.delete(1); // ["adrien"] list.toImmutable(); ``` _Nothing_ The index of the property you’re deleting. If the property doesn’t exist, an `Error` is thrown. ### every [#LiveList.every] Tests whether all elements pass the test implemented by the provided function. Returns true if the predicate function returns a truthy value for every element. Otherwise, false. ```ts const list = new LiveList([0, 2, 4]); // true list.every((i) => i % 2 === 0); list.push(5); // false list.every((i) => i % 2 === 0); ``` Whether all elements pass the test implemented by the provided function. A function to execute for each item in the array. It should return a truthy value to indicate the element passes the test, and a falsy value otherwise. The function is passed the `value` of the item and its current `index`. ### filter [#LiveList.filter] Creates an array with all elements that pass the test implemented by the provided function. ```ts const list = new LiveList([0, 1, 2, 3, 4]); // [0, 2, 4] list.filter((i) => i % 2 === 0); ``` An array containing each item of the `LiveList` that passed the test implemented by the provided function. A function to execute for each item in the array. It should return a truthy value to indicate the element passes the test, and a falsy value otherwise. The function is passed the `value` of the item and its current `index`. ### find [#LiveList.find] Returns the first element that satisfies the provided testing function. If no item passes the test, `undefined` is returned. ```ts const list = new LiveList(["apple", "lemon", "tomato"]); // "lemon" list.find((value, index) => value.startsWith("l")); ``` The item that has been found. If no item passes the test, `undefined` is returned. A function to execute for each item in the array. It should return a truthy value to indicate the element passes the test, and a falsy value otherwise. The function is passed the `value` of the item and its current `index`. ### findIndex [#LiveList.findIndex] Returns the index of the first element in the `LiveList` that satisfies the provided testing function. If no item passes the test, `-1` is returned. ```ts const list = new LiveList(["apple", "lemon", "tomato"]); // 1 list.findIndex((value, index) => value.startsWith("l")); ``` The index of the item that has been found. If no item passes the test, `-1` is returned. A function to execute for each item in the array. It should return a truthy value to indicate the element passes the test, and a falsy value otherwise. The function is passed the `value` of the item and its current `index`. ### forEach [#LiveList.forEach] Executes a provided function once for each element. ```ts const list = new LiveList(["adrien", "jonathan"]); // "adrien", "jonathan" list.forEach((item) => console.log(item)); ``` _Nothing_ A callback for each item. The callback is passed the current `value` and `index`. Return values are ignored. ### get [#LiveList.get] Get the element at the specified index. Returns `undefined` if the index doesn’t exist. ```ts const list = new LiveList(["adrien", "jonathan"]); // "jonathan" list.get(1); ``` The value of the item at the index. Returns `undefined` if it doesn’t exist. The index of the item you’re getting. ### indexOf [#LiveList.indexOf] Returns the first index at which a given element can be found in the `LiveList`. Returns `-1` if it is not present. ```ts const list = new LiveList(["adrien", "jonathan"]); // 1 list.indexOf("jonathan"); // undefined list.indexOf("chris"); ``` The index of the item. Returns `-1` if it doesn’t exist. The item you’re locating. The index to start the search at. ### insert [#LiveList.insert] Inserts one element at a specified index. Throws an `Error` if the index is out of bounds. ```ts const list = new LiveList(["adrien", "jonathan"]); list.insert("chris", 1); // ["adrien", "chris", "jonathan"] list.toImmutable(); ``` _Nothing_ The value of the item you’re inserting. The index to insert the item into. ### lastIndexOf [#LiveList.lastIndexOf] Returns the last index at which a given element can be found in the `LiveList`, or -1 if it is not present. The `LiveList` is searched backwards, starting at fromIndex. Returns `-1` if it is not present. ```ts const list = new LiveList(["adrien", "jonathan", "adrien"]); // 2 list.indexOf("adrien"); // undefined list.indexOf("chris"); ``` The index of the item. Returns `-1` if it doesn’t exist. The item you’re locating. The index at which to start searching backwards. ### length [#LiveList.length] Returns the number of elements. ```ts const list = new LiveList(["adrien", "jonathan"]); // 3 list.length; // equals ``` The number of items in the `LiveList`. _N/A_ ### map [#LiveList.map] Creates an array populated with the results of calling a provided function on every element. ```ts const list = new LiveList(["apple", "lemon", "tomato"]); // ["APPLE", "LEMON", "TOMATO"] list.map((value, index) => value.toUpperCase()); ``` The array of each item has been transformed by the callback function. A callback for each item. The callback is passed the current `value` and `index`. Return values are used in the returned array. ### move [#LiveList.move] Moves one element at a specified index. ```ts const list = new LiveList(["adrien", "chris", "jonathan"]); list.move(2, 0); // ["jonathan", "adrien", "chris"] list.toImmutable(); ``` _Nothing_ The index of the item to move. The index where the element should be after moving. ### push [#LiveList.push] Adds one element to the end of the `LiveList`. ```ts const list = new LiveList(["adrien", "jonathan"]); list.push("chris"); // ["adrien", "jonathan", "chris"] list.toImmutable(); ``` _Nothing_ The item to add to the end of the `LiveList`. ### set [#LiveList.set] Replace one element at the specified index. ```ts const list = new LiveList(["adrien", "jonathan"]); list.set(1, "chris"); // equals ["adrien", "chris"] list.toImmutable(); ``` ### some [#LiveList.some] Tests whether at least one element in the `LiveList` passes the test implemented by the provided function. ```ts const list = new LiveList(["apple", "lemon", "tomato"]); // true list.some((value, index) => value.startsWith("l")); // false list.some((value, index) => value.startsWith("x")); ``` Whether any elements pass the test implemented by the provided function. A function to execute for each item in the array. It should return a truthy value to indicate the element passes the test, and a falsy value otherwise. The function is passed the `value` of the item and its current `index`. ### clone [#LiveList.clone] Returns a deep copy of the `LiveList` that can be inserted elsewhere in the Storage tree. ```ts const list = new LiveList(/* ... */); root.set("a", list); root.set("b", list.clone()); ``` The cloned `LiveList`. _None_ ### toImmutable [#LiveList.toImmutable] Returns an immutable JavaScript array that is equivalent to the `LiveList`. Nested values will also be immutable. ```ts const list = new LiveList([ new LiveObject({ name: "Olivier" }), new LiveObject({ name: "Vincent" }), ]); // [ // { name: "Olivier" }, // { name: "Vincent" }, // ] list.toImmutable(); ``` Returns a JavaScript object in the shape of your data structure.`ListList` is converted to an array, `LiveObject` to an object, and `LiveMap` to a map. _None_ ### toArray [#LiveList.toArray] Starting with 0.18, we recommend [`toImmutable`][] instead. It’s faster, cached, and leads to fewer surprises. Transforms the `LiveList` into a normal JavaScript array. ```ts const list = new LiveList(["🦁", "🦊", "🐵"]); list.toArray(); // ["🦁", "🦊", "🐵"] ``` Please note that this method won’t recursively convert Live structures, which may be surprising: ```ts const list = new LiveList([ new LiveObject({ firstName: "Grace", lastName: "Hopper" }), ]); list.toArray(); // [ ] // ❗️ ``` ## Resolvers ### invalidateUsers `client.resolvers.invalidateUsers` can be used to invalidate some or all users that were previously cached by [`resolveUsers`](#createClientResolveUsers). It can be used when updating the current user’s avatar for example, to instantly refresh the user data everywhere without having to perform a page reload. ```tsx // Invalidate all users client.resolvers.invalidateUsers(); // Only invalidate "user-0" and "user-1" client.resolvers.invalidateUsers(["user-0", "user-1"]); ``` ### invalidateRoomsInfo `client.resolvers.invalidateRoomsInfo` can be used to invalidate some or all rooms that were previously cached by [`resolveRoomsInfo`](#createClientResolveRoomsInfo). It can be used when updating a room’s name for example, to instantly refresh the room info everywhere without having to perform a page reload. ```tsx // Invalidate all rooms client.resolvers.invalidateRoomsInfo(); // Only invalidate "room-0" and "room-1" client.resolvers.invalidateRoomsInfo(["room-0", "room-1"]); ``` ### invalidateMentionSuggestions `client.resolvers.invalidateRoomsInfo` can be used to invalidate all mention suggestions that were previously cached by [`resolveMentionSuggestions`](#createClientResolveMentionSuggestions). It can be used when updating a room’s list of users for example, to prevent creating out-of-date mentions without having to perform a page reload. ```tsx // Invalidate all mention suggestions client.resolvers.invalidateMentionSuggestions(); ``` ## Utilities ### getMentionedIdsFromCommentBody [#get-mentioned-ids-from-comment-body] Returns an array of each user’s ID that has been mentioned in a `CommentBody` (found under `comment.body`). ```ts import { getMentionedIdsFromCommentBody } from "@liveblocks/client"; const mentionedIds = getMentionedIdsFromCommentBody(comment.body); ``` Here’s an example with a custom `CommentBody`. ```ts import { CommentBody, getMentionedIdsFromCommentBody, } from "@liveblocks/client"; // Create a custom `CommentBody` const commentBody: CommentBody = { version: 1, content: [ { type: "paragraph", children: [ { text: "Hello " }, { type: "mention", id: "chris@example.com" }, ], }, ], }; // Get the mentions inside the comment's body const mentionedIds = getMentionedIdsFromCommentBody(commentBody); // ["chris@example.com"] console.log(mentionedIds); ``` If you’d like to use this on the server side, it's also available from [`@liveblocks/node`](/docs/api-reference/liveblocks-node#get-mentioned-ids-from-comment-body). ### stringifyCommentBody [#stringify-comment-body] Used to convert a `CommentBody` (found under `comment.body`) into either a plain string, Markdown, HTML, or a custom format. ```ts import { stringifyCommentBody } from "@liveblocks/client"; const stringComment = await stringifyCommentBody(comment.body); // "Hello marc@example.com from https://liveblocks.io" console.log(stringComment); ``` A number of options are available. ```ts import { stringifyCommentBody } from "@liveblocks/client"; const stringComment = await stringifyCommentBody(comment.body, { // Optional, convert to specific format, "plain" (default) | "markdown" | "html" format: "markdown", // Optional, supply a separator to be used between paragraphs separator: `\n\n`, // Optional, override any elements in the CommentBody with a custom string elements: { // Optional, override the `paragraph` element paragraph: ({ element, children }) => `

${children}

`, // Optional, override the `text` element text: ({ element }) => element.bold ? `${element.text}` : `${element.text}`, // Optional, override the `link` element link: ({ element, href }) => `${element.url}`, // Optional, override the `mention` element. `user` available if `resolveUsers` supplied mention: ({ element, user }) => `${element.id}`, }, // Optional, get your user's names and info from their ID to be displayed in mentions async resolveUsers({ userIds }) { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ // Name is inserted into the output instead of a user's ID name: userData.name, // Custom formatting in `elements.mention` allows custom properties to be used profileUrl: userData.profileUrl, })); }, }); ``` If you’d like to use this on the server side, it's also available from [`@liveblocks/node`](/docs/api-reference/liveblocks-node#stringify-comment-body). #### Formatting examples Here are a number of different formatting examples derived from the same `CommentBody`. ```ts // "Hello marc@example.com from https://liveblocks.io" await stringifyCommentBody(comment.body); // "Hello @Marc from https://liveblocks.io" await stringifyCommentBody(comment.body, { resolveUsers({ userIds }) { return [{ name: "Marc" }]; }, }); // "**Hello** @Marc from [https://liveblocks.io](https://liveblocks.io)" await stringifyCommentBody(comment.body, { format: "markdown", resolveUsers() { return [{ name: "Marc" }]; }, }); // "Hello @Marc from // https://liveblocks.io" await stringifyCommentBody(comment.body, { format: "html", resolveUsers() { return [{ name: "Marc" }]; }, }); // "Hello @Marc from // https://liveblocks.io" await stringifyCommentBody(comment.body, { format: "html", mention: ({ element, user }) => `${user.name}`, resolveUsers() { return [{ name: "Marc", profileUrl: "https://example.com" }]; }, }); ``` ## TypeScript ### Typing your data It’s possible to have automatic types flow through your application by defining a global `Liveblocks` interface. We recommend doing this in a `liveblocks.config.ts` file in the root of your app, so it’s easy to keep track of your types. Each type (`Presence`, `Storage`, etc.), is optional, but it’s recommended to make use of them. ```ts file="liveblocks.config.ts" declare global { interface Liveblocks { // Each user's Presence Presence: {}; // The Storage tree for the room Storage: {}; UserMeta: { id: string; // Custom user info set when authenticating with a secret key info: {}; }; // Custom events RoomEvent: {}; // Custom metadata set on threads ThreadMetadata: {}; // Custom room info set with resolveRoomsInfo RoomInfo: {}; // Custom activities data for custom notification kinds ActivitiesData: {}; } } // Necessary if you have no imports/exports export {}; ``` Here are some example values that might be used. ```ts file="liveblocks.config.ts" import { LiveList } from "@liveblocks/client"; declare global { interface Liveblocks { // Each user's Presence Presence: { // Example, real-time cursor coordinates cursor: { x: number; y: number }; }; // The Storage tree for the room Storage: { // Example, a conflict-free list animals: LiveList; }; UserMeta: { id: string; // Custom user info set when authenticating with a secret key info: { // Example properties name: string; avatar: string; }; }; // Custom events // Example has two events, using a union RoomEvent: { type: "PLAY" } | { type: "REACTION"; emoji: "🔥" }; // Custom metadata set on threads ThreadMetadata: { // Example, attaching coordinates to a thread x: number; y: number; }; // Custom room info set with resolveRoomsInfo RoomInfo: { // Example, rooms with a title and url title: string; url: string; }; // Custom activities data for custom notification kinds ActivitiesData: { // Example, a custom $alert kind $alert: { title: string; message: string; }; }; } } // Necessary if you have no imports/exports export {}; ``` ### Typing with client.enter Before Liveblocks 2.0, it was recommended to type your data by passing `Presence`, `Storage`, `UserMeta`, and `RoomEvents` types to [`client.enterRoom`][]. This is no longer [the recommended method](#Typing-your-data) for setting up Liveblocks, but it can still be helpful, for example you can use `client.enter` multiple times to create different room types, each with their own correctly typed hooks. ```ts import { LiveList } from "@liveblocks/client"; // Each user’s Presence type Presence = { cursor: { x: number; y: number }; }; // The Storage tree for the room type Storage = { animals: LiveList; }; // User information set when authenticating with a secret key type UserMeta = { id: string; info: { // Custom properties, corresponds with userInfo }; }; // Custom events that can be broadcast, use a union for multiple events type RoomEvent = { type: "REACTION"; emoji: "🔥"; }; const { room, leave } = client.enterRoom< Presence, Storage, UserMeta, RoomEvent >("my-room-id"); ``` You can also pass types to [`client.getRoom`](/docs/api-reference/liveblocks-client#Client.getRoom). ```ts const { room, leave } = client.getRoom( "my-room-id" ); ``` ### User [#user-type] `User` is a type that’s returned by [`room.getSelf`][], [`room.getOthers`][], and other functions. Some of its values are set when [typing your room](#typing-your-data), here are some example values: ```ts file="liveblocks.config.ts" declare global { interface Liveblocks { // Each user's Presence // +++ Presence: { cursor: { x: number; y: number }; }; // +++ UserMeta: { id: string; // Custom user info set when authenticating with a secret key // +++ info: { name: string; avatar: string; }; // +++ }; } } ``` ```ts const { room, leave } = client.enterRoom("my-room-id"); // { // connectionId: 52, // +++ // presence: { // cursor: { x: 263, y: 786 }, // }, // +++ // id: "mislav.abha@example.com", // +++ // info: { // name: "Mislav Abha", // avatar: "/mislav.png", // }, // +++ // canWrite: true, // canComment: true, // } const user = room.getSelf(); ``` The connection ID of the User. It is unique and increments with every new connection. The ID of the User that has been set in the authentication endpoint. Useful to get additional information about the connected user. Additional user information that has been set in the authentication endpoint. The user’s Presence data. True if the user can mutate the Room’s Storage and/or YDoc, false if they can only read but not mutate it. The ID of the User that has been set in the authentication endpoint. Useful to get additional information about the connected user. ## Deprecated ### Client.enter [@badge=Deprecated] This is no longer supported. We recommend using [`client.enterRoom`][] instead. Enters a room and returns its local `Room` instance. ```ts // ❌ This API was recommended before 1.5 const room = client.enter("my-room", { initialPresence: { cursor: null }, initialStorage: { todos: new LiveList() }, }); client.leave(roomId); // ✅ Prefer this instead const { room, leave } = client.enterRoom("my-room", { initialPresence: { cursor: null }, initialStorage: { todos: new LiveList() }, }); leave(); ``` ### Client.leave [@badge=Deprecated] This is no longer supported. We recommend using [`client.enterRoom`][] instead. Leaves a room. ```ts // ❌ This API was recommended before 1.5 client.leave("my-room"); // ✅ Prefer this instead const { room, leave } = client.enterRoom("my-room" /* options */); leave(); ``` [`atob`]: https://developer.mozilla.org/en-US/docs/Web/API/atob [`base-64`]: https://www.npmjs.com/package/base-64 [`client.enterRoom`]: /docs/api-reference/liveblocks-client#Client.enterRoom [`client.getroom`]: /docs/api-reference/liveblocks-client#Client.getRoom [`createclient`]: /docs/api-reference/liveblocks-client#createClient [`fetch`]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`lostconnectiontimeout`]: /docs/api-reference/liveblocks-client#createClientLostConnectionTimeout [`toimmutable`]: /docs/api-reference/liveblocks-client#LiveObject.toImmutable [`node-fetch`]: https://npmjs.com/package/node-fetch [`room.broadcastevent`]: /docs/api-reference/liveblocks-client#Room.broadcastEvent [`room.getstorage`]: /docs/api-reference/liveblocks-client#Room.getStorage [`room.reconnect`]: /docs/api-reference/liveblocks-client#Room.reconnect [`room.getSelf`]: /docs/api-reference/liveblocks-client#Room.getSelf [`room.getOthers`]: /docs/api-reference/liveblocks-client#Room.getOthers [`room.getStorage`]: /docs/api-reference/liveblocks-client#Room.getStorage [`room.history`]: /docs/api-reference/liveblocks-client#Room.history [`room.subscribe("event")`]: /docs/api-reference/liveblocks-client#Room.subscribe.event [`room.subscribe("status")`]: /docs/api-reference/liveblocks-client#Room.subscribe.status [`room.subscribe("lost-connection")`]: /docs/api-reference/liveblocks-client#Room.subscribe.lost-connection [`room.subscribe("storage-status")`]: /docs/api-reference/liveblocks-client#Room.subscribe.storage-status [`room.updatepresence`]: /docs/api-reference/liveblocks-client#Room.updatePresence [`websocket`]: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket [`ws`]: https://www.npmjs.com/package/ws [connection status example]: https://liveblocks.io/examples/connection-status/nextjs --- meta: title: "@liveblocks/emails" parentTitle: "API Reference" description: "API Reference for the @liveblocks/emails package" alwaysShowAllNavigationLevels: false --- `@liveblocks/emails` provides a set of functions that simplifies sending styled emails with [Notifications](/docs/ready-made-features/notifications) and [webhooks](/docs/platform/webhooks). This library is only intended for use in your Node.js back end. ## Requirements `@liveblocks/emails` requires the [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package to be installed and for [`react`](https://react.dev/) to be a peer dependency in your project. ## Setup This package exposes functions that enable easy creation of styled emails with React and HTML. Each method is designed to be used with our [webhooks](/docs/platform/webhooks) which means you must [set them up](/docs/guides/how-to-test-webhooks-on-localhost) first. Webhooks require an API endpoint in your application, and this is typically what they will look like. ```tsx title="Next.js route handler for webhooks" import { isThreadNotificationEvent, WebhookHandler } from "@liveblocks/node"; import { Liveblocks } from "@liveblocks/node"; // +++ import { prepareThreadNotificationEmailAsReact } from "@liveblocks/emails"; // +++ const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const webhookHandler = new WebhookHandler( process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY as string ); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // +++ // Using `@liveblocks/emails` to create an email if (isThreadNotificationEvent(event)) { const emailData = await prepareThreadNotificationEmailAsReact( liveblocks, event ); if (emailData.type === "unreadMention") { const email = (
@{emailData.comment.author.id} at {emailData.comment.createdAt}
{emailData.comment.reactBody}
); // Send unread mention email // ... } } // +++ return new Response(null, { status: 200 }); } ``` **We’ll only show the highlighted part below**, as it’s assumed you’ve set this already, and your file contains `liveblocks` and `event`. ### End-to-end guides We have two guides that take you through every step of setting up your email notifications, including setting up webhooks: - [How to send email notifications of unread comments](/docs/guides/how-to-send-email-notifications-of-unread-comments). - [How to send email notifications for unread text editor mentions ](/docs/guides/how-to-send-email-notifications-for-unread-text-editor-mentions). ### Ready-made email templates We have a number of examples that show you how to set up emails with your Comments or Text Editor application. Each [Resend](https://resend.com) example has full ready-made email templates inside, which are a great starting point for your application. - [Comments + Resend](/examples/comments-emails/nextjs-comments-emails-resend). - [Comments + SendGrid](/examples/comments-emails/nextjs-comments-emails-sendgrid). - [Text Editor/Tiptap + Resend](/examples/collaborative-text-editor-emails/nextjs-tiptap-emails-resend). - [Text Editor/Lexical + Resend](/examples/collaborative-text-editor-emails/nextjs-lexical-emails-resend). ## Thread notification emails [#thread-notification-emails] These functions help you create emails to notify users of _unread comments_ in threads. They fetch each relevant comment, filtering out any that have already been read, and help you style each comment’s body with either [React](#prepare-thread-notification-email-as-react) or [HTML](#prepare-thread-notification-email-as-html).
An email showing 7 new comments, with comment bodies and links to each comment
This screenshot shows a ready-made template from our [Comments + Resend](/examples/comments-emails/nextjs-comments-emails-resend) example. These functions also help you distinguish between _unread mentions_ and _unread replies_. A thread has _unread replies_ if a comment was created after the `readAt` date on the notification, and created before or at the same time as the `notifiedAt` date. All unread replies are returned in an array. ```js { type: "unreadReplies", roomId: "my-room-id", comments: [ {/* Comment data */}, // ... ], } ``` A thread has an _unread mention_ if it has unread replies, and one of the replies mentions the user. A single comment with the latest mention is returned. ```js { type: "unreadMention", roomId: "my-room-id", comment: {/* Comment data */}, } ``` ### Limitations Before you get started, there are some limitations with thread notifications that you should be aware of. #### Duplicated emails for unread mentions If a user is mentioned (e.g `@username`) in a thread, all further notifications received for this thread will be `unreadMention` types, _until the user reads the thread again_. This means that you will receive duplicated `unreadMention` notifications, even if a notification should be an `unreadReplies` type. After the thread has been read by the user, `unreadReplies` will be sent for the thread again if the user isn’t mentioned another time in the thread. This is due to the architecture of our notifications database, and we’re investigating solutions to move past this limitation. ### prepareThreadNotificationEmailAsReact [#prepare-thread-notification-email-as-react] Takes a [thread notification webhook event](/docs/platform/webhooks#Thread-notification) and returns unread comment body(s) related to the notification, as React nodes. It can return one of three formats, an `unreadMention` type containing one comment, an `unreadReplies` type returning multiple comments, or `null` if there are no unread mentions/replies. You can also [resolve user & room data](#prepare-thread-notification-email-as-react-resolving-data) and [customize the components](#prepare-thread-notification-email-as-react-customizing-components). ```tsx import { prepareThreadNotificationEmailAsReact } from "@liveblocks/emails"; import { isThreadNotificationEvent } from "@liveblocks/node"; // Get `liveblocks` and `event` (see "Setup" section) // ... if (isThreadNotificationEvent(event)) { // +++ const emailData = await prepareThreadNotificationEmailAsReact( liveblocks, event ); // +++ let email; switch (emailData.type) { case "unreadMention": { email = (
@{emailData.comment.author.id} at {emailData.comment.createdAt}
{emailData.comment.reactBody}
); break; } case "unreadReplies": { email = (
{emailData.comments.map((comment) => (
@{comment.author.id} at {comment.createdAt}
{comment.reactBody}
))}
); break; } } } // Send your email // ... ``` It’s designed to be used in a webhook event, which requires a [`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js client and a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler). Check for the correct webhook event using [`isThreadNotificationEvent`](/docs/api-reference/liveblocks-node#isThreadNotificationEvent) before running the function, such as in this Next.js route handler. ```tsx title="Full Next.js route handler example" isCollapsed isCollapsable import { isThreadNotificationEvent, WebhookHandler } from "@liveblocks/node"; import { Liveblocks } from "@liveblocks/node"; // +++ import { prepareThreadNotificationEmailAsReact } from "@liveblocks/emails"; // +++ const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const webhookHandler = new WebhookHandler( process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY as string ); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // +++ if (isThreadNotificationEvent(event)) { const emailData = await prepareThreadNotificationEmailAsReact( liveblocks, event ); let email; switch (emailData.type) { case "unreadMention": { email = (
@{emailData.comment.author.id} at {emailData.comment.createdAt}
{emailData.comment.reactBody}
); break; } case "unreadReplies": { email = (
{emailData.comments.map((comment) => (
@{comment.author.id} at {comment.createdAt}
{comment.reactBody}
))}
); break; } } // Send your email // ... } // +++ return new Response(null, { status: 200 }); } ``` Returns comment information, and a formatted React body, ready for use in emails. Returns `null` if there are no unread mentions or replies. The result has two formats depending on whether this notification is for a *single unread mention*, or for *multiple unread replies*: ```js title="Unread mention" isCollapsable isCollapsed { type: "unreadMention", roomId: "my-room-id", // An unread mention has just one comment comment: { id: "cm_asfs8f...", threadId: "th_sj30as..." createdAt: Date , // The formatted comment, pass it to React `children` reactBody: { /* ... */ }, author: { id: "aurélien@example.com", info: { /* Custom user info you have resolved */ }, }, }, } ```
```js title="Unread replies" isCollapsable isCollapsed { type: "unreadReplies", roomId: "my-room-id", // Unread replies means multiple comments comments: [ { id: "cm_asfs8f...", threadId: "th_sj30as..." createdAt: Date , // The formatted comment, pass it to React `children` reactBody: { /* ... */ }, author: { id: "aurélien@example.com", info: { /* Custom user info you have resolved */ }, }, }, // More comments //... ], } ```
A [`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js client. An object passed from a webhook event, specifically the [`ThreadNotificationEvent`](/docs/platform/webhooks#Thread-notification). [Learn more about setting this up](#Setup). A number of options to customize the format of the comments, adding user info, room info, and styles. A function that resolves user information in [Comments](/docs/ready-made-features/comments). Return an array of `UserMeta["info"]` objects in the same order they arrived. Works similarly to the [resolver on the client](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers). [Learn more](#prepare-thread-notification-email-as-react-resolving-data). A function that resolves room information. Return a `RoomInfo` object, as matching your types. Works similarly to the [resolver on the client](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveRoomsInfo) but for one room. [Learn more](#prepare-thread-notification-email-as-react-resolving-data). Pass different React components to customize the elements in the comment bodies. Five components can be passed to the object: `Container`, `Paragraph`, `Text`, `Link`, `Mention`. [Learn more](#prepare-thread-notification-email-as-react-customizing-components). The comment body container. The paragraph block. The text element. The link element. ReactNode`} > The mention element. #### Resolving data [#prepare-thread-notification-email-as-react-resolving-data] Similarly to on the client, you can resolve [users](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers) and [room info](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveRoomsInfo), making it easier to render your emails. For example, you can resolve a user’s ID into their name, and show their name in the email. ```tsx const emailData = await prepareThreadNotificationEmailAsReact( liveblocks, webhookEvent, { // +++ resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, // "Nimesh" avatar: userData.avatar.src, // "https://..." })); }, resolveRoomInfo({ roomId }) { return { name: roomId, // "my-room-name" url: `https://example.com/${roomId}`, }; }, // +++ } ); // { type: "unreadMention", comment: { ... }, ... } console.log(emailData); // { name: "Nimesh", avatar: "https://..." } console.log(emailData.comment.author.info); // { name: "my-room-name", url: "https://example.com/my-room-name" } console.log(emailData.roomInfo); ``` #### Customizing components [#prepare-thread-notification-email-as-react-customizing-components] Each React component in the comment body can be replaced with a custom React component, if you wish to apply different styles. Five components are available: `Container`, `Paragraph`, `Text`, `Link`, `Mention`. ```tsx const emailData = await prepareThreadNotificationEmailAsReact( liveblocks, webhookEvent, { // +++ components: { Paragraph: ({ children }) =>

{children}

, // `react-email` components are supported Text: ({ children }) => ( {children} ), // `user` is the optional data returned from `resolveUsers` Mention: ({ element, user }) => ( @{user?.name ?? element.id} ), // If the link is rich-text render it, otherwise use the URL Link: ({ element, href }) => {element?.text ?? href}, }, // +++ } ); // { type: "unreadMention", comment: { ... }, ... } console.log(emailData); // The components are now used in this React body console.log(emailData.comment.reactBody); ``` ### prepareThreadNotificationEmailAsHtml [#prepare-thread-notification-email-as-html] Takes a [thread notification webhook event](/docs/platform/webhooks#Thread-notification) and returns unread comment body(s) related to the notification, as an HTML-safe string. It can return one of three formats, an `unreadMention` type containing one comment, an `unreadReplies` type returning multiple comments, or `null` if there are no unread mentions/replies. You can also [resolve user & room data](#prepare-thread-notification-email-as-html-resolving-data) and [customize the styles](#prepare-thread-notification-email-as-html-styling-elements). ```ts import { prepareThreadNotificationEmailAsHtml } from "@liveblocks/emails"; import { isThreadNotificationEvent } from "@liveblocks/node"; // Get `liveblocks` and `event` (see "Setup" section) // ... if (isThreadNotificationEvent(event)) { // +++ const emailData = await prepareThreadNotificationEmailAsHtml( liveblocks, event ); // +++ let email; switch (emailData.type) { case "unreadMention": { email = `
@${emailData.comment.author.id} at ${emailData.comment.createdAt}
${emailData.comment.htmlBody}
`; break; } case "unreadReplies": { email = `
${emailData.comments .map( (comment) => `
@${comment.author.id} at ${comment.createdAt}
${comment.htmlBody}
` ) .join("")}
`; break; } } } // Send your email // ... ``` It’s designed to be used in a webhook event, which requires a [`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js client, a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler). Check for the correct webhook event using [`isThreadNotificationEvent`](/docs/api-reference/liveblocks-node#isThreadNotificationEvent) before running the function, such as in this Next.js route handler. ```tsx title="Full Next.js route handler example" isCollapsed isCollapsable import { isThreadNotificationEvent, WebhookHandler } from "@liveblocks/node"; import { Liveblocks } from "@liveblocks/node"; // +++ import { prepareThreadNotificationEmailAsHtml } from "@liveblocks/emails"; // +++ const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const webhookHandler = new WebhookHandler( process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY as string ); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // +++ if (isThreadNotificationEvent(event)) { const emailData = await prepareThreadNotificationEmailAsHtml( liveblocks, event ); let email; switch (emailData.type) { case "unreadMention": { email = `
@${emailData.comment.author.id} at ${emailData.comment.createdAt}
${emailData.comment.htmlBody}
`; break; } case "unreadReplies": { email = `
${emailData.comments .map( (comment) => `
@${comment.author.id} at ${comment.createdAt}
${comment.htmlBody}
` ) .join("")}
`; break; } } // Send your email // ... } // +++ return new Response(null, { status: 200 }); } ``` Returns comment information, and a formatted HTML body, ready for use in emails. Returns `null` if there are no unread mentions or comments. The result has two formats depending on whether this notification is for a *single unread mention*, or for *multiple unread replies*: ```js title="Unread mention" isCollapsable isCollapsed { type: "unreadMention", roomId: "my-room-id", // An unread mention has just one comment comment: { id: "cm_asfs8f...", threadId: "th_sj30as..." createdAt: Date , // The formatted comment, as an HTML string htmlBody: "
...
", author: { id: "aurélien@example.com", info: { /* Custom user info you have resolved */ }, }, }, } ```
```js title="Unread replies" isCollapsable isCollapsed { type: "unreadReplies", roomId: "my-room-id", // Unread replies means multiple comments comments: [ { id: "cm_asfs8f...", threadId: "th_sj30as..." createdAt: Date , // The formatted comment, as an HTML string htmlBody: "
...
", author: { id: "aurélien@example.com", info: { /* Custom user info you have resolved */ }, }, }, // More comments //... ], } ```
A [`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js client. An object passed from a webhook event, specifically the [`ThreadNotificationEvent`](/docs/platform/webhooks#Thread-notification). [Learn more about setting this up](#Setup). A number of options to customize the format of the comments, adding user info, room info, and styles. A function that resolves user information in [Comments](/docs/ready-made-features/comments). Return an array of `UserMeta["info"]` objects in the same order they arrived. Works similarly to the [resolver on the client](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers). [Learn more](#prepare-thread-notification-email-as-html-resolving-data). A function that resolves room information. Return a `RoomInfo` object, as matching your types. Works similarly to the [resolver on the client](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveRoomsInfo) but for one room. [Learn more](#prepare-thread-notification-email-as-html-resolving-data). Pass CSS properties to style the different HTML elements in the comment bodies. Five elements can be styled: `paragraph`, `code`, `strong`, `link`, `mention`. [Learn more](#prepare-thread-notification-email-as-html-styling-elements). Inline styles to apply to the comment body container. Inline styles to apply to the paragraph block. Inline styles to apply to the code element. Inline styles to apply to the strong element. Inline styles to apply to the mention element. Inline styles to apply to the link element. #### Resolving data [#prepare-thread-notification-email-as-html-resolving-data] Similarly to on the client, you can resolve [users](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers) and [room info](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveRoomsInfo), making it easier to render your emails. For example, you can resolve a user’s ID into their name, and show their name in the email. ```tsx const emailData = await prepareThreadNotificationEmailAsHtml( liveblocks, webhookEvent, { // +++ resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, // "Nimesh" avatar: userData.avatar.src, // "https://..." })); }, resolveRoomInfo({ roomId }) { return { name: roomId, // "my-room-name" url: `https://example.com/${roomId}`, }; }, // +++ } ); // { type: "unreadMention", comment: { ... }, ... } console.log(emailData); // { name: "Nimesh", avatar: "https://..." } console.log(emailData.comment.author.info); // { name: "my-room-name", url: "https://example.com/my-room-name" } console.log(emailData.roomInfo); ``` #### Styling elements [#prepare-thread-notification-email-as-html-styling-elements] Each element in the comment body can be styled with custom CSS properties, if you would like to change the appearance. Five elements are available: `paragraph`, `code`, `strong`, `mention`, `link`. ```tsx const emailData = await prepareThreadNotificationEmailAsHtml( liveblocks, webhookEvent, { // +++ styles: { paragraph: { margin: "12px 0" }, mention: { fontWeight: "bold", color: "red", }, link: { textDecoration: "underline", }, }, // +++ } ); // { type: "unreadMention", comment: { ... }, ... } console.log(emailData); // The elements in the comment body are now styled console.log(emailData.comment.htmlBody); ``` ## Text Mention notification emails [#text-mention-notification-emails] These functions help you create emails to notify users when they have an _unread mention_ in a [Text Editor](/docs/ready-made-features/text-editor) document. In this case, a mention is not related to comments, but is instead an inline mention inside the text editor itself. If the mention has not been read, the functions fetch a text mention and its surrounding text, giving you more context, and helping you style the mention content with either [React](#prepare-text-mention-notification-email-as-react) or [HTML](#prepare-text-mention-notification-email-as-html).
An email showing a text mention in a text editor document
This screenshot shows a ready-made template from our [Text Editor + Resend](/examples/collaborative-text-editor-emails/nextjs-tiptap-emails-resend) examples. The functions helps to determine if the mention still exists in the document and will indicate that there’s no email to send in this case. Currently, only mentions in paragraph blocks create notifications, as there are limitations around retrieving mentions in plugins. ### Limitations Before you get started, there are some limitations with text mentions that you should be aware of. #### Mentions in plugins If a user is mentioned in a plugin or extension, a text mention notification is not sent. This is because Liveblocks doesn’t know the exact schema of your editor and all its plugins, and we can’t extract the data correctly. This means that _only mentions in paragraph blocks are sent_, and mentions in lists, checkboxes, etc., are not, as they are all powered by plugins. We’re investigating solutions for this, and we’d like to [hear from you](/contact/support) if you have any thoughts. #### Multiple Tiptap editors Tiptap optionally allows you to [render multiple editors per page](/docs/ready-made-features/text-editor/tiptap#Multiple-editors), instead of just one. For now, these functions only support one editor per room, but we’ll be looking to add support for more later. ### prepareTextMentionNotificationEmailAsReact [#prepare-text-mention-notification-email-as-react] Takes a [text mention notification webhook event](/docs/platform/webhooks#TextMention-notification) and returns an unread text mention with its surrounding text as React nodes. It can also return `null` if the text mention does not exist anymore or has been already been read. You can also [resolve user & room data](#prepare-text-mention-notification-email-as-react-resolving-data) and [customize the components](#prepare-text-mention-notification-email-as-react-customizing-components). ```tsx import { prepareTextMentionNotificationEmailAsReact } from "@liveblocks/emails"; import { isTextMentionNotificationEvent } from "@liveblocks/node"; // Get `liveblocks` and `event` (see "Setup" section) // ... if (isTextMentionNotificationEvent(event)) { // +++ const emailData = await prepareTextMentionNotificationEmailAsReact( liveblocks, event ); // +++ const email = (
@{emailData.mention.author.id} at {emailData.mention.createdAt}
{emailData.mention.reactContent}
); } // Send your email // ... ``` It’s designed to be used in a webhook event, which requires a [`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js client and a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler). Check for the correct webhook event using [`isTextMentionNotificationEvent`](/docs/api-reference/liveblocks-node#isTextMentionNotificationEvent) before running the function, such as in this Next.js route handler. ```tsx title="Full Next.js route handler example" isCollapsed isCollapsable import { isTextMentionNotificationEvent, WebhookHandler, } from "@liveblocks/node"; import { Liveblocks } from "@liveblocks/node"; // +++ import { prepareTextMentionNotificationEmailAsReact } from "@liveblocks/emails"; // +++ const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const webhookHandler = new WebhookHandler( process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY as string ); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // +++ if (isTextMentionNotificationEvent(event)) { const emailData = await prepareTextMentionNotificationEmailAsReact( liveblocks, event ); const email = (
@{emailData.mention.author.id} at {emailData.mention.createdAt}
{emailData.mention.reactContent}
); // Send your email // ... } // +++ return new Response(null, { status: 200 }); } ``` Returns text mention information, and a formatted React content ready for use in emails. Returns `null` if the text mention does not exist anymore or has been already been read. ```js title="Unread text mention" { roomInfo: { name: "my room name" url: "https://my-room-url.io" }, mention: { id: "in_oiujhdg...", roomId: "my-room-id", createdAt: Date , userId: "user_0" // The formatted content, pass it to React `children` reactContent: { /* ... */} author: { id: "vincent@example.com", info: { /* Custom user info you have resolved */ } } }, } ``` A [`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js client. An object passed from a webhook event, specifically the [`TextMentionNotificationEvent`](/docs/platform/webhooks#TextMention-notification). [Learn more about setting this up](#Setup). A number of options to customize the format of the content, adding user info, room info, and styles. A function that resolves user information in [Comments](/docs/ready-made-features/comments). Return an array of `UserMeta["info"]` objects in the same order they arrived. Works similarly to the [resolver on the client](/docs/ready-made-features/text-editor/lexical#Users-and-mentions). [Learn more](#prepare-text-mention-notification-email-as-react-resolving-data). A function that resolves room information. Return a `RoomInfo` object, as matching your types. Works similarly to the [resolver on the client](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveRoomsInfo) but for one room. [Learn more](#prepare-text-mention-notification-email-as-react-resolving-data). Pass different React components to customize the elements in the mention content. Three components can be passed to the object: `Container`, `Text`, and `Mention`. [Learn more](#prepare-text-mention-notification-email-as-react-customizing-components). The mention and its surrounding text container The text element. ReactNode`} > The mention element. #### Resolving data [#prepare-text-mention-notification-email-as-react-resolving-data] Similarly to on the client, you can resolve [users](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers) and [room info](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveRoomsInfo), making it easier to render your emails. For example, you can resolve a user’s ID into their name, and show their name in the email. ```tsx const emailData = await prepareTextMentionNotificationEmailAsReact( liveblocks, webhookEvent, { // +++ resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, // "Nimesh" avatar: userData.avatar.src, // "https://..." })); }, resolveRoomInfo({ roomId }) { return { name: roomId, // "my-room-name" url: `https://example.com/${roomId}`, }; }, // +++ } ); // { mention: { ... }, ... } console.log(emailData); // { name: "Nimesh", avatar: "https://..." } console.log(emailData.mention.author.info); // { name: "my-room-name", url: "https://example.com/my-room-name" } console.log(emailData.roomInfo); ``` #### Customizing components [#prepare-text-mention-notification-email-as-react-customizing-components] Each React component in the mention context can be replaced with a custom React component, if you wish to apply different styles. Three components are available: `Container`, `Text`, and `Mention`. ```tsx const emailData = await prepareThreadNotificationEmailAsReact( liveblocks, webhookEvent, { // +++ components: { // `react-email` components are supported Container: ({ children }) =>
{children}
, Text: ({ children }) => ( {children} ), // `user` is the optional data returned from `resolveUsers` Mention: ({ element, user }) => ( @{user?.name ?? element.id} ), }, // +++ } ); // { mention: { ... }, ... } console.log(emailData); // The components are now used in this React content console.log(emailData.mention.reactContent); ``; ``` ### prepareTextMentionNotificationEmailAsHtml [#prepare-text-mention-notification-email-as-html] Takes a [text mention notification webhook event](/docs/platform/webhooks#TextMention-notification) and returns an unread text mention with its surrounding text as an HTML string. It can also return `null` if the text mention does not exist anymore or has been already been read. You can also [resolve user & room data](#prepare-text-mention-notification-email-as-html-resolving-data) and [customize the styles](#prepare-text-mention-notification-email-as-html-styling-elements). ```tsx import { prepareTextMentionNotificationEmailAsHtml } from "@liveblocks/emails"; import { isTextMentionNotificationEvent } from "@liveblocks/node"; // Get `liveblocks` and `event` (see "Setup" section) // ... if (isTextMentionNotificationEvent(event)) { // +++ const emailData = await prepareTextMentionNotificationEmailAsHtml( liveblocks, event ); // +++ const email = (
@{emailData.mention.author.id} at {emailData.mention.createdAt}
{emailData.mention.htmlContent}
); } // Send your email // ... ``` It’s designed to be used in a webhook event, which requires a [`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js client and a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler). Check for the correct webhook event using [`isTextMentionNotificationEvent`](/docs/api-reference/liveblocks-node#isTextMentionNotificationEvent) before running the function, such as in this Next.js route handler. ```tsx title="Full Next.js route handler example" isCollapsed isCollapsable import { isTextMentionNotificationEvent, WebhookHandler, } from "@liveblocks/node"; import { Liveblocks } from "@liveblocks/node"; // +++ import { prepareTextMentionNotificationEmailAsHtml } from "@liveblocks/emails"; // +++ const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const webhookHandler = new WebhookHandler( process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY as string ); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // +++ if (isTextMentionNotificationEvent(event)) { const emailData = await prepareTextMentionNotificationEmailAsHtml( liveblocks, event ); const email = `
@${emailData.mention.author.id} at ${emailData.mention.createdAt}
${emailData.mention.htmlContent}
`; // Send your email // ... } // +++ return new Response(null, { status: 200 }); } ``` Returns text mention information, and formatted HTML content ready for use in emails. Returns `null` if the text mention does not exist anymore or has already been read. ```js title="Unread text mention" { roomInfo: { name: "my room name" url: "https://my-room-url.io" }, mention: { id: "in_oiujhdg...", roomId: "my-room-id", createdAt: Date , userId: "user_0" // The formatted content, as an HTML string htmlContent: { /* ... */} author: { id: "vincent@example.com", info: { /* Custom user info you have resolved */ } } }, } ``` A [`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js client. An object passed from a webhook event, specifically the [`TextMentionNotificationEvent`](/docs/platform/webhooks#TextMention-notification). [Learn more about setting this up](#Setup). A number of options to customize the format of the content, adding user info, room info, and styles. A function that resolves user information in [Comments](/docs/ready-made-features/comments). Return an array of `UserMeta["info"]` objects in the same order they arrived. Works similarly to the [resolver on the client](/docs/ready-made-features/text-editor/lexical#Users-and-mentions). [Learn more](#prepare-text-mention-notification-email-as-html-resolving-data). A function that resolves room information. Return a `RoomInfo` object, as matching your types. Works similarly to the [resolver on the client](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveRoomsInfo) but for one room. [Learn more](#prepare-text-mention-notification-email-as-html-resolving-data). Pass CSS properties to style the different HTML elements in the mention content. Four elements can be styled: `paragraph`, `code`, `strong`, `mention`, and, `link`. [Learn more](#prepare-text-mention-notification-email-as-html-styling-elements). Inline styles to apply to the mention container container. It's a `
` element under the hood. Inline styles to apply to the code element. Inline styles to apply to the strong element. Inline styles to apply to the mention element. Inline styles to apply to the link element. #### Resolving data [#prepare-text-mention-notification-email-as-html-resolving-data] Similarly to on the client, you can resolve [users](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers) and [room info](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveRoomsInfo), making it easier to render your emails. For example, you can resolve a user’s ID into their name, and show their name in the email. ```tsx const emailData = await prepareTextMentionNotificationEmailAsHtml( liveblocks, webhookEvent, { // +++ resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, // "Nimesh" avatar: userData.avatar.src, // "https://..." })); }, resolveRoomInfo({ roomId }) { return { name: roomId, // "my-room-name" url: `https://example.com/${roomId}`, }; }, // +++ } ); // { mention: { ... }, ... } console.log(emailData); // { name: "Nimesh", avatar: "https://..." } console.log(emailData.mention.author.info); // { name: "my-room-name", url: "https://example.com/my-room-name" } console.log(emailData.roomInfo); ``` #### Styling elements [#prepare-text-mention-notification-email-as-html-styling-elements] Each element in the comment body can be styled with custom CSS properties, if you would like to change the appearance. Five elements are available: `paragraph`, `code`, `strong`, `mention`, and `link`. ```tsx const emailData = await prepareTextMentionNotificationEmailAsHtml( liveblocks, webhookEvent, { // +++ styles: { paragraph: { margin: "12px 0" }, mention: { fontWeight: "bold", color: "red", }, }, // +++ } ); // { mention: { ... }, ... } console.log(emailData); // The elements in the mention content are now styled console.log(emailData.mention.htmlContent); ``` --- meta: title: "@liveblocks/node-lexical" parentTitle: "API Reference" description: "API Reference for the @liveblocks/node-lexical package" alwaysShowAllNavigationLevels: false --- `@liveblocks/node-lexical` provides a Node.js package to export and modify [Lexical](https://lexical.dev/) documents on the server. ## withLexicalDocument `withLexicalDocument` is the main entry point to modifying a document on the server. It takes a room ID and a [Liveblocks Node client](/docs/api-reference/liveblocks-node#Liveblocks-client), and returns a callback used to work with Lexical documents stored in Liveblocks. ```ts highlight="8-14" import { Liveblocks } from "@liveblocks/node"; import { withLexicalDocument } from "@liveblocks/node-lexical"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); await withLexicalDocument( { roomId: "your-room-id", client: liveblocks }, async (doc) => { // Modify your Lexical `doc` // ... } ); ``` Returns the value you return from the `doc` callback. The ID of the room to use. The [Liveblocks client](/docs/api-reference/liveblocks-node#Liveblocks-client) to use. Optional. The Lexical nodes used in the document. Will extend the default schema which uses Liveblocks mentions and Liveblocks comments. ### Returning data Get your editor’s text content by returning `doc.getTextContent` inside the callback. ```ts const textContent = await withLexicalDocument( { roomId: "my-room-id", client: liveblocks }, // +++ async (doc) => { return doc.getTextContent(); } // +++ ); // "My content" console.log(TextContent); ``` ### Custom nodes If your Lexical document has custom nodes, they must be passed into the `withLexicalDocument`, similarly to with a front end Lexical client. ```ts highlight="4" import { CodeNode } from "@lexical/code"; await withLexicalDocument( { roomId: "my-room-id", client: liveblocks, nodes: [CodeNode] }, async (doc) => { // Modify your Lexical `doc` // ... } ); ``` ### Lexical document API You can easily modify your document with the Lexical document API. #### doc.update Liveblocks provides `doc.update` which is a callback function similar to Lexical’s `editor.update`. This makes it easy to use Lexical’s editor functions. Any edits will be persisted and appear in realtime to connected users as soon as the `update` promise resolves. Unlike Lexical’s `editor.update`, this change is always discrete. The callback can also be an `async` function. ```ts await withLexicalDocument( { roomId: "my-room-id", client: liveblocks }, async (doc) => { // +++ await doc.update(() => { // Make your modifications // ... }); // +++ } ); ``` _Nothing_ Callback function where you should handle your modifications. ##### Example usage Here’s an example of some modifications to a Lexical document. ```ts import { $getRoot } from "lexical"; import { $createParagraphNode, $createTextNode } from "lexical/nodes"; await withLexicalDocument( { roomId: "my-room-id", client: liveblocks }, async (doc) => { await doc.update(() => { // Adding a paragraph node with contained text node // +++ const root = $getRoot(); const paragraphNode = $createParagraphNode(); const textNode = $createTextNode("Hello world"); paragraphNode.append(textNode); root.append(paragraphNode); // +++ }); } ); ``` #### doc.getTextContent Returns the text content from the root node as a `string`. ```ts const textContent = await withLexicalDocument( { roomId: "my-room-id", client: liveblocks }, // +++ async (doc) => { return doc.getTextContent(); } // +++ ); ``` Returns the text retrieved from the document. _None_ #### doc.getEditorState Returns Lexical’s [editorState](https://lexical.dev/docs/concepts/editor-state). ```ts const editorState = await withLexicalDocument( { roomId: "my-room-id", client: liveblocks }, // +++ async (doc) => { return doc.getEditorState(); } // +++ ); ``` Your editor’s Lexical state. _None_ #### doc.getLexicalEditor Returns a headless Lexical editor. [@lexical/headless](https://lexical.dev/docs/packages/lexical-headless). ```ts const headlessEditor = await withLexicalDocument( { roomId: "my-room-id", client: liveblocks }, // +++ async (doc) => { return doc.getLexicalEditor(); } // +++ ); ``` Your headless Lexical editor. _None_ #### doc.toJSON Returns a serialized JSON object representation of your document. See Lexical’s [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization) page for more information. ```ts const docAsJSON = await withLexicalDocument( { roomId: "my-room-id", client: liveblocks }, // +++ async (doc) => { return doc.toJson(); } // +++ ); ``` A serialized JSON object representation of your document. _None_ #### doc.toMarkdown Returns a markdown `string` of your document. See Lexical’s [@lexical/markdown](https://lexical.dev/docs/concepts/serialization) page for more information. ```ts const markdown = await withLexicalDocument( { roomId: "my-room-id", client: liveblocks }, // +++ async (doc) => { return doc.toMarkdown(); } // +++ ); ``` Returns the markdown string. _None_ --- meta: title: "@liveblocks/node-prosemirror" parentTitle: "API Reference" description: "API Reference for the @liveblocks/node-prosemirror package" alwaysShowAllNavigationLevels: false --- `@liveblocks/node-prosemirror` provides a Node.js package to export and modify [ProseMirror](https://prosemirror.net/). Because Tiptap uses ProseMirror under the hood, this package can be used to modify [Tiptap](/docs/api-reference/liveblocks-react-tiptap) documents as well. ## withProsemirrorDocument `withProsemirrorDocument` is the main entry point to modifying a document on the server. It takes a room ID and a [Liveblocks Node client](/docs/api-reference/liveblocks-node#Liveblocks-client), and returns a callback used to work with ProseMirror documents stored in Liveblocks. ```ts highlight="8-14" import { Liveblocks } from "@liveblocks/node"; import { withProsemirrorDocument } from "@liveblocks/node-prosemirror"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); await withProsemirrorDocument( { roomId: "your-room-id", client: liveblocks }, (api) => { // Modify your document with the api // ... } ); ``` Returns the value you return from the `api` callback. The ID of the room to use. The [Liveblocks client](/docs/api-reference/liveblocks-node#Liveblocks-client) to use. Optional. The ProseMirror schema to use for the document. If no schema is provided, the default schema is [Tiptap StarterKit](https://tiptap.dev/docs/editor/extensions/functionality/starterkit), Liveblocks mentions, and Liveblocks comments. Optional. The [field](/docs/api-reference/liveblocks-react-tiptap#Multiple-editors) to use for the document. Defaults to `default`. ### Returning data Get your editor’s text content by returning `api.getText()` inside the callback. ```ts const textContent = await withProsemirrorDocument( { roomId: "my-room-id", client: liveblocks }, (api) => api.getText() ); // "My content" console.log(textContent); ``` ### ProseMirror document API You can easily modify your document with the ProseMirror document API. #### api.update Liveblocks provides `api.update` which is a callback that provides the current document and a ProseMirror transaction. This makes it easy to use ProseMirror’s built in functions. When you've finished, return the transaction and any changes will be persisted, and appear in realtime to connected users as soon as the `update` promise resolves. ```ts await withProsemirrorDocument( { client, roomId: "test-room", }, async (api) => { // +++ await api.update((doc, tr) => { return tr.insertText("Hello world"); }); // +++ } ); ``` _Nothing_ `doc` is the ProseMirror document. `tr` is an editor state transaction. Transaction is a subclass of ProseMirror’s Transforms. On the ProseMirror website you can find a full list of [transforms](https://prosemirror.net/docs/ref/#transform.Document_transforms) and [transactions functions](https://prosemirror.net/docs/ref/#state.Transaction). #### api.getText Returns the text content of the document.: This api uses Tiptap’s getText internally. TextSerializers are a concept from [Tiptap](https://github.com/ueberdosis/tiptap/blob/3e59097b34ce8bc8c39e1def67eb31a1d9f9e5c2/packages/core/src/types.ts#L357). If you are having trouble with a ProseMirror document, you may want to use `api.getEditorState().doc.textBetween()` instead. ```ts const textContent = await withProsemirrorDocument( { roomId: "my-room-id", client: liveblocks }, async (api) => { // +++ return api.getText({ // Options // ... }); // +++ } ); ``` Returns the text retrieved from the document. Optional. The separator to use for blocks, e.g. `
`. Defaults to `\n\n`.
Optional. The serializers to use for text. Defaults to `{}`.
#### api.setContent For convenience, some methods such as `setContent` are provided at the API level. Here’s an example that sets a document and returns the JSON content after it has been updated. ```ts const exampleDoc = { type: "doc", content: [ { type: "paragraph", content: [ { type: "text", text: "Example Text", }, ], }, ], }; const json = await withProsemirrorDocument( { client, roomId: "test-room", }, async (api) => { // +++ await api.setContent(exampleDoc); // +++ return JSON.stringify(api.toJSON()); } ); ``` _Nothing_ The content to replace your document. #### doc.getEditorState Returns the current ProseMirror state. ```ts const editorState = await withProsemirrorDocument( { roomId: "my-room-id", client: liveblocks }, async (api) => { // +++ return api.getEditorState(); // +++ } ); ``` Your editor’s ProseMirror state. _None_ #### api.toJSON Returns a serialized JSON object representation of your document. See ProseMirror’s [.toJSON](https://prosemirror.net/docs/ref/#state.EditorState.toJSON) documentation for more information. ```ts const docAsJSON = await withProsemirrorDocument( { roomId: "my-room-id", client: liveblocks }, async (api) => { // +++ return api.toJSON(); // +++ } ); ``` Your editor’s serialized JSON state. _None_ #### api.clearContent Clears the content of the document. ```ts await withProsemirrorDocument( { roomId: "my-room-id", client: liveblocks }, async (api) => { // +++ return api.clearContent(); // +++ } ); ``` _Nothing_ _None_ #### api.toMarkdown Returns a markdown `string` of your document. ```ts const markdown = await withProsemirrorDocument( { roomId: "my-room-id", client: liveblocks }, async (api) => { // +++ return api.toMarkdown(); // +++ } ); ``` Returns the markdown string. Optional. A markdown serializer to use. By default it uses the `defaultMarkdownSerializer` from [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown). ##### Custom markdown serializer You can use a custom markdown serializer. ```ts import { defaultMarkdownSerializer } from "prosemirror-markdown"; const mySerializer = new MarkdownSerializer({ marks: { ...defaultMarkdownSerializer.marks, em: { open: "*", close: "*", mixable: true, expelEnclosingWhitespace: true, }, }, }); const markdown = await withProsemirrorDocument( { roomId: "my-room-id", client: liveblocks }, async (api) => { // +++ return api.toMarkdown(mySerializer); // +++ } ); ``` --- meta: title: "@liveblocks/node" parentTitle: "API Reference" description: "API Reference for the @liveblocks/node package" alwaysShowAllNavigationLevels: false --- `@liveblocks/node` provides you with Node.js APIs for [authenticating Liveblocks users](#Liveblocks-client) and for [implementing webhook handlers](#WebhookHandler). This library is only intended for use in your Node.js back end. ## Liveblocks client [#Liveblocks-client] The `Liveblocks` client is new in 1.2, and offers access to our REST API. ```ts showLineNumbers={false} import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); ``` ### Authorization To authorize your users with Liveblocks, you have the choice between two different APIs. - [`Liveblocks.prepareSession`](#access-tokens) is recommended for most applications. - [`Liveblocks.identifyUser`](#id-tokens) is best if you’re using fine-grained permissions with our REST API. #### Liveblocks.prepareSession [#access-tokens] The purpose of this API is to help you implement your custom authentication back end (i.e. the _server_ part of the diagram). You use the `liveblocks.prepareSession()` API if you’d like to issue [access tokens](/docs/authentication/access-token) from your back end. Issuing access tokens is like issuing _hotel key cards_ from a hotel’s front desk (your back end). Any client with a key card can enter any room that the card gives access to. It’s easy to give out those key cards right from your back end.
Auth diagram
To implement your back end, follow these steps: Create a session ```ts showLineNumbers={false} const session = liveblocks.prepareSession( "marie@example.com", // Required, user ID from your DB { // Optional, custom static metadata for the session userInfo: { name: "Marie", avatar: "https://example.com/avatar/marie.jpg", }, } ); ``` The `userId` (required) is an identifier to uniquely identifies your user with Liveblocks. This value will be used when counting unique MAUs in your Liveblocks dashboard. The `userInfo` (optional) is any custom JSON value, which can be attached to static metadata to this user’s session. This will be publicly visible to all other people in the room. Useful for metadata like the user’s full name, or their avatar URL. Decide which permissions to allow this session ```ts showLineNumbers={false} session.allow("my-room-1", session.FULL_ACCESS); session.allow("my-room-2", session.FULL_ACCESS); session.allow("my-room-3", session.FULL_ACCESS); session.allow("my-team:*", session.READ_ACCESS); ``` You’re specifying what’s going to be allowed so be careful what permissions you’re giving your users. You’re responsible for this part. Authorize the session Finally, authorize the session. This step makes the HTTP call to the Liveblocks servers. Liveblocks will return a signed **access token** that you can return to your client. ```ts showLineNumbers={false} // Requests the Liveblocks servers to authorize this session const { body, status } = await session.authorize(); return new Response(body, { status }); ``` ##### Access tokens example [#access-token-example] Here’s a real-world example of access tokens in a Next.js route handler/endpoint. You can find examples for other frameworks in our [authentication section](/docs/authentication/access-token). ```ts file="route.ts" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST(request: Request) { /** * Implement your own security here. * * It's your responsibility to ensure that the caller of this endpoint * is a valid user by validating the cookies or authentication headers * and that it has access to the requested room. */ // Get the current user from your database const user = __getUserFromDB__(request); // Start an auth session inside your endpoint const session = liveblocks.prepareSession( user.id, { userInfo: user.metadata } // Optional ); // Implement your own security, and give the user access to the room const { room } = await request.json(); if (room && __shouldUserHaveAccess__(user, room)) { session.allow(room, session.FULL_ACCESS); } // Retrieve a token from the Liveblocks servers and pass it to the // requesting client const { body, status } = await session.authorize(); return new Response(body, { status }); } ``` #### Liveblocks.identifyUser [#id-tokens] The purpose of this API is to help you implement your custom authentication back end (i.e. the _server_ part of the diagram). You use the `liveblocks.identifyUser()` API if you’d like to issue [ID tokens](/docs/authentication/id-token) from your back end. An ID token does not grant any permissions in the token directly. Instead, it only securely identifies your user, and then uses any permissions set via the [Permissions REST API][] to decide whether to allow the user on a room-by-room basis. Use this approach if you’d like Liveblocks to be the source of truth for your user’s permissions. Issuing identity tokens is like issuing _membership cards_. Anyone with a membership card can try to enter a room, but your permissions will be checked at the door. The Liveblocks servers perform this authorization, so your permissions need to be set up front using the Liveblocks REST API.
Auth diagram
Implement your back end endpoint as follows: ```ts showLineNumbers={false} const { body, status } = await liveblocks.identifyUser( { userId: "marie@example.com", // Required, user ID from your DB groupIds: ["marketing", "engineering"], }, // Optional { userInfo: { name: "Marie", avatar: "https://example.com/avatar/marie.jpg", }, } ); return new Response(body, { status }); ``` `userId` (required) is a string identifier to uniquely identify your user with Liveblocks. This value will be used when counting unique MAUs in your Liveblocks dashboard. You can refer to these user IDs in the [Permissions REST API][] when assigning group permissions. `groupIds` (optional) can be used to specify which groups this user belongs to. These are arbitrary identifiers that make sense to your app, and that you can refer to in the [Permissions REST API][] when assigning group permissions. `userInfo` (optional) is any custom JSON value, which you can use to attach static metadata to this user’s session. This will be publicly visible to all other people in the room. Useful for metadata like the user’s full name, or their avatar URL. ##### ID tokens example Here’s a real-world example of ID tokens in a Next.js route handler/endpoint. You can find examples for other frameworks in our [authentication section](/docs/authentication/id-token). ```ts file="Next.js" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export default async function auth(req, res) { /** * Implement your own security here. * * It's your responsibility to ensure that the caller of this endpoint * is a valid user by validating the cookies or authentication headers * and that it has access to the requested room. */ // Get the current user from your database const user = __getUserFromDB__(req); // Create an ID token for the user const { body, status } = await liveblocks.identifyUser( { userId: user.id, }, { userInfo: { name: user.fullName, color: user.favoriteColor, }, } ); return new Response(body, { status }); } ``` ### Room #### Liveblocks.getRooms [#get-rooms] Returns a list of rooms that are in the current project. The project is determined by the secret key you’re using. Rooms are sorted by creation time, with the newest room at index `0`. This is a wrapper around the [Get Rooms API](/docs/api-reference/rest-api-endpoints#get-rooms) and returns the same response. ```ts const { data: rooms, nextCursor, nextPage } = await liveblocks.getRooms(); // A list of rooms // [{ type: "room", id: "my-room-id", ... }, ...] console.log(rooms); // A pagination cursor used for retrieving the next page of results with `startingAfter` // "L3YyL3Jvb21z..." console.log(nextCursor); // A pagination URL used for retrieving the next page of results with the REST API // "/v2/rooms?startingAfter=L3YyL3Jvb21z..." console.log(nextPage); ``` A number of options are also available, enabling you to filter for certain rooms. ```ts const { data: rooms, nextCursor, nextPage, } = await liveblocks.getRooms({ // Optional, the amount of rooms to load, between 1 and 100, defaults to 20 limit: 20, // Optional, filter for rooms that allow entry to group ID(s) in `groupsAccesses` groupIds: ["engineering", "design"], // Optional, filter for rooms that allow entry to a user's ID in `usersAccesses` userId: "my-user-id", // Optional, use advanced filtering query: { // Optional, filter for rooms with an ID that starts with specific string roomId: { startsWith: "liveblocks:", }, // Optional, filter for rooms with custom metadata in `metadata` metadata: { roomType: "whiteboard", }, }, // Optional, cursor used for pagination, use `nextCursor` from the previous page's response startingAfter: "L3YyL3Jvb21z...", }); ``` The `query` option also allows you to pass a [query language](/docs/guides/how-to-filter-rooms-using-query-language) string instead of a `query` object. ##### Pagination You can use `nextCursor` to paginate rooms. In this example, when `getNextPage` is called, the next set of rooms is added to `pages`. ```ts import { RoomData } from "@liveblocks/node"; // An array of pages, each containing a list of retrieved rooms const pages: RoomData[][] = []; // Holds the pagination cursor for the next set of rooms let startingAfter; // Call to get the next page of rooms async function getNextPage() { const { data, nextCursor } = await liveblocks.getRooms({ startingAfter }); pages.push(data); startingAfter = nextCursor; } ``` #### Liveblocks.createRoom [#post-rooms] Programmatically creates a new room from a room ID. The `defaultAccesses` option is required. Setting `defaultAccesses` to `["room:write"]` creates a public room, whereas setting it to `[]` will create a private room that needs [ID token permission to enter](/docs/authentication/id-token). This is a wrapper around the [Create Room API](/docs/api-reference/rest-api-endpoints#post-rooms) and returns the same response. ```ts const room = await liveblocks.createRoom("my-room-id", { defaultAccesses: ["room:write"], }); // { type: "room", id: "my-room-id", ... } console.log(room); ``` A number of room creation options are available, allowing you to set permissions and attach custom metadata. ```ts const room = await liveblocks.createRoom("my-room-id", { // The default room permissions. `[]` for private, `["room:write"]` for public. defaultAccesses: [], // Optional, the room's group ID permissions groupsAccesses: { design: ["room:write"], engineering: ["room:presence:write", "room:read"], }, // Optional, the room's user ID permissions usersAccesses: { "my-user-id": ["room:write"], }, // Optional, custom metadata to attach to the room metadata: { myRoomType: "whiteboard", }, }); ``` Group and user permissions are only used with [ID token authorization](/docs/api-reference/liveblocks-node#id-tokens), learn more about [managing permission with ID tokens](/docs/authentication/id-token). #### Liveblocks.getRoom [#get-rooms-roomId] Returns a room. Throws an error if the room isn’t found. This is a wrapper around the [Get Room API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId) and returns the same response. ```ts const room = await liveblocks.getRoom("my-room-id"); // { type: "room", id: "my-room-id", ... } console.log(room); ``` #### Liveblocks.updateRoom [#post-rooms-roomId] Updates properties on a room. Throws an error if the room isn’t found. This is a wrapper around the [Update Room API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId) and returns the same response. ```ts const room = await liveblocks.updateRoom("my-room-id", { // The metadata or permissions you're updating // ... }); // { type: "room", id: "my-room-id", ... } console.log(room); ``` Permissions and metadata properties can be updated on the room. Note that you need only pass the properties you’re updating. Setting a property to `null` will delete the property. ```ts const room = await liveblocks.updateRoom("my-room-id", { // Optional, update the default room permissions. `[]` for private, `["room:write"]` for public. defaultAccesses: [], // Optional, update the room's group ID permissions groupsAccesses: { design: ["room:write"], engineering: ["room:presence:write", "room:read"], }, // Optional, update the room's user ID permissions usersAccesses: { "my-user-id": ["room:write"], }, // Optional, custom metadata to update on the room metadata: { myRoomType: "whiteboard", }, }); ``` Group and user permissions are only used with [ID token authorization](/docs/api-reference/liveblocks-node#id-tokens), learn more about [managing permission with ID tokens](/docs/authentication/id-token). #### Liveblocks.deleteRoom [#delete-rooms-roomId] Deletes a room. Throws an error if the room isn’t found. This is a wrapper around the [Delete Room API](/docs/api-reference/rest-api-endpoints#delete-rooms-roomId) and returns no response. ```ts await liveblocks.deleteRoom("my-room-id"); ``` #### Liveblocks.updateRoomId [#post-rooms-update-roomId] Permanently updates a room’s ID. `newRoomId` will replace `roomId`. Note that this will disconnect connected users from the room, but this can be worked around. Throws an error if the room isn’t found. This is a wrapper around the [Update Room API](/docs/api-reference/rest-api-endpoints#post-rooms-update-roomId) and returns the same response. ```ts const room = await liveblocks.updateRoomId({ roomId: "my-room-id", newRoomId: "new-room-id", }); // { type: "room", id: "new-room-id", ... } console.log(room); ``` ##### Redirect connected users to the new room When a room’s ID is changed it disconnects all users that are currently connected. To redirect connected users to the new room you can use [`useErrorListener`](/docs/api-reference/liveblocks-react#useErrorListener) or [`room.subscribe("error")`](/docs/api-reference/liveblocks-client#Room.subscribe.error) in your application to get the new room’s ID, and redirect users to the renamed room. ```tsx import { useErrorListener } from "@liveblocks/react/suspense"; function App() { useErrorListener((error) => { if (error.code === 4006) { // Room ID has been changed, get the new ID and redirect const newRoomId = error.message; __redirect__(`https://example.com/document/${newRoomId}}`); } }); } ``` #### Liveblocks.getActiveUsers [#get-rooms-roomId-active-users] Returns a list of users that are currently present in the room. Throws an error if the room isn’t found. This is a wrapper around the [Get Active Users API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-active-users) and returns the same response. ```ts const activeUsers = await liveblocks.getActiveUsers("my-room-id"); // { data: [{ type: "user", id: "my-user-id", ... }, ...] } console.log(activeUsers); ``` #### Liveblocks.broadcastEvent [#post-broadcast-event] Broadcasts a custom event to the room. Throws an error if the room isn’t found. This is a wrapper around the [Broadcast Event API](/docs/api-reference/rest-api-endpoints#post-broadcast-event) and returns no response. ```ts const customEvent = { type: "EMOJI", emoji: "🔥", }; await liveblocks.broadcastEvent("my-room-id", customEvent); ``` You can respond to custom events on the front end with [`useBroadcastEvent`](/docs/api-reference/liveblocks-react#useBroadcastEvent) and [`room.subscribe("event")`](/docs/api-reference/liveblocks-client#Room.subscribe.event). When receiving an event sent with `Liveblocks.broadcastEvent`, `user` will be `null` and `connectionId` will be `-1`. ```tsx import { useEventListener } from "@liveblocks/react/suspense"; // When receiving an event sent from `@liveblocks/node` useEventListener(({ event, user, connectionId }) => { // `null` console.log(user); // `-1` console.log(connectionId); }); ``` ### Storage #### Liveblocks.getStorageDocument [#get-rooms-roomId-storage] Returns the contents of a room’s Storage tree. By default, returns Storage in LSON format. Throws an error if the room isn’t found. This is a wrapper around the [Get Storage Document API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-storage) and returns the same response. ```ts const storage = await liveblocks.getStorageDocument("my-room-id"); ``` LSON is a custom Liveblocks format that preserves information about the conflict-free data types used. By default, `getStorageDocument` returns Storage in this format. This is the same as using `"plain-json"` in the second argument. ```ts highlight="2" // Retrieve LSON Storage data const storage = await liveblocks.getStorageDocument("my-room-id", "plain-lson"); // If this were your Storage type... declare global { interface Liveblocks { Storage: { names: LiveList; }; } } // { // liveblocksType: "LiveObject", // data: { // names: { // liveblocksType: "LiveList", // data: ["Olivier", "Nimesh"], // } // } // } console.log(storage); ``` You can also retrieve Storage as JSON by passing `"json"` into the second argument. ```ts highlight="2" // Retrieve JSON Storage data const storage = await liveblocks.getStorageDocument("my-room-id", "json"); // If this were your Storage type... declare global { interface Liveblocks { Storage: { names: LiveList; }; } } // { // names: ["Olivier", "Nimesh"] // } console.log(storage); ``` #### Liveblocks.initializeStorageDocument [#post-rooms-roomId-storage] Initializes a room’s Storage tree with given LSON data. To use this, the room must have [already been created](#post-rooms) and have empty Storage. Throws an error if the room isn’t found. Calling this will disconnect all active users from the room. This is a wrapper around the [Initialize Storage Document API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-storage) and returns the same response. ```ts // Create a new room const room = await liveblocks.createRoom("my-room-id", { defaultAccesses: ["room:write"], }); // Initialize Storage const storage = await liveblocks.initializeStorageDocument("my-room-id", { // Your LSON Storage value // ... }); ``` LSON is a custom Liveblocks format that preserves information about conflict-free data types. The easiest way to create it is using the `toPlainLson` helper provided by `@liveblocks/client`. Note that your Storage root should always be a `LiveObject`. ```ts highlight="11-13,18-20,25" import { toPlainLson, LiveList, LiveObject } from "@liveblocks/client"; // Create a new room const room = await liveblocks.createRoom("my-room-id", { defaultAccesses: ["room:write"], }); // If this were your Storage type... declare global { interface Liveblocks { Storage: { names: LiveList; }; } } // Create the initial conflict-free data const initialStorage: LiveObject = new LiveObject({ names: new LiveList(["Olivier", "Nimesh"]), }); // Convert to LSON and create Storage const storage = await liveblocks.initializeStorageDocument( "my-room-id", toPlainLson(initialStorage) ); ``` It’s also possible to create plain LSON manually, without the helper function. ```ts highlight="9-11,17-23" // Create a new room const room = await liveblocks.createRoom("my-room-id", { defaultAccesses: ["room:write"], }); // If this were your Storage type... declare global { interface Liveblocks { Storage: { names: LiveList; }; } } // Create this Storage and add names to the LiveList const storage = await liveblocks.initializeStorageDocument("my-room-id", { liveblocksType: "LiveObject", data: { names: { liveblocksType: "LiveList", data: ["Olivier", "Nimesh"], }, }, }); ``` #### Liveblocks.deleteStorageDocument [#delete-rooms-roomId-storage] Deletes a room’s Storage data. Calling this will disconnect all active users from the room. Throws an error if the room isn’t found. This is a wrapper around the [Delete Storage Document API](/docs/api-reference/rest-api-endpoints#delete-rooms-roomId-storage) and returns no response. ```ts await liveblocks.deleteStorageDocument("my-room-id"); ``` ### Yjs #### Liveblocks.getYjsDocument [#get-rooms-roomId-ydoc] Returns a JSON representation of a room’s Yjs document. Throws an error if the room isn’t found. This is a wrapper around the [Get Yjs Document API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc) and returns the same response. ```ts const yjsDocument = await liveblocks.getYjsDocument("my-room-id"); // { yourYText: "...", yourYArray: [...], ... } console.log(yjsDocument); ``` A number of options are available. ```ts const yjsDocument = await liveblocks.getYjsDocument("my-room-id", { // Optional, if true, `yText` values will return formatting format: true, // Optional, return a single key's value, e.g. `yDoc.get("my-key-id").toJson()` key: "my-key-id", // Optional, override the inferred `key` type, e.g. "ymap" for `doc.get(key, Y.Map)` type: "ymap", }); ``` #### Liveblocks.sendYjsBinaryUpdate [#put-rooms-roomId-ydoc] Send a Yjs binary update to a room’s Yjs document. You can use this to update or initialize the room’s Yjs document. Throws an error if the room isn’t found. This is a wrapper around the [Send a Binary Yjs Update API](/docs/api-reference/rest-api-endpoints#put-rooms-roomId-ydoc) and returns no response. ```ts await liveblocks.sendYjsBinaryUpdate("my-room-id", update); ``` Here’s an example of how to update a room’s Yjs document with your changes. ```ts import * as Y from "yjs"; // Create a Yjs document const yDoc = new Y.Doc(); // Create your data structures and make your update // If you're using a text editor, you need to match its format const yText = yDoc.getText("text"); yText.insert(0, "Hello world"); // Encode the document state as an update const update = Y.encodeStateAsUpdate(yDoc); // Send update to Liveblocks await liveblocks.sendYjsBinaryUpdate("my-room-id", update); ``` To update a subdocument instead of the main document, pass its `guid`. ```ts await liveblocks.sendYjsBinaryUpdate("my-room-id", update, { // Optional, update a subdocument instead. guid is its unique identifier guid: "c4a755...", }); ``` To create a new room and initialize its Yjs document, call [`liveblocks.createRoom`](#post-rooms) before sending the binary update. ```ts highlight="1-2" // Create new room const room = await liveblocks.createRoom("my-room-id"); // Set initial Yjs document value await liveblocks.sendYjsBinaryUpdate("my-room-id", state); ``` ##### Different editors Note that each text and code editor handles binary updates in a different way, and may use a different Yjs shared type, for example [`Y.XmlFragment`](https://docs.yjs.dev/api/shared-types/y.xmlfragment) instead of [`Y.Text`](https://docs.yjs.dev/api/shared-types/y.text). Create a binary update with [Slate](https://www.slatejs.org/): ```ts title="Slate binary update" highlight="3,13-17,19-21" isCollapsed isCollapsable import { Liveblocks } from "@liveblocks/node"; import * as Y from "yjs"; import { slateNodesToInsertDelta } from "@slate-yjs/core"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST() { // Create a Yjs document const yDoc = new Y.Doc(); // Create Slate document state const slateDoc = { type: "paragraph", children: [{ text: "Hello world" }], }; // Create your data structures and make your update const insertDelta = slateNodesToInsertDelta(slateDoc); (yDoc.get("content", Y.XmlText) as Y.XmlText).applyDelta(insertDelta); // Encode the document state as an update const update = Y.encodeStateAsUpdate(yDoc); // Send update to Liveblocks await liveblocks.sendYjsBinaryUpdate("my-room-id", update); } ``` Create a binary update with [Tiptap](https://tiptap.dev/docs/editor/api/extensions/collaboration): ```ts title="Tiptap binary update" highlight="12-14,16-18" isCollapsed isCollapsable import * as Y from "yjs"; import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST() { // Create a Yjs document const yDoc = new Y.Doc(); // Create Tiptap Yjs state const yXmlElement = new Y.XmlElement("paragraph"); yXmlElement.insert(0, [new Y.XmlText("Hello world")]); // Create your data structures and make your update const yXmlFragment = yDoc.getXmlFragment("default"); yXmlFragment.insert(0, [yXmlElement]); // Encode the document state as an update message const yUpdate = Y.encodeStateAsUpdate(yDoc); // Initialize the Yjs document with the update await liveblocks.sendYjsBinaryUpdate("my-room-id", { update: yUpdate, }); } ``` Read the [Yjs documentation](https://docs.yjs.dev/api/document-updates) to learn more about creating binary updates. #### Liveblocks.getYjsDocumentAsBinaryUpdate [#get-rooms-roomId-ydoc-binary] Return a room’s Yjs document as a single binary update. You can use this to get a copy of your Yjs document in your back end. Throws an error if the room isn’t found. This is a wrapper around the [Get Yjs Document Encoded as a Binary Yjs Update API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc-binary) and returns the same response. ```ts const binaryYjsUpdate = await liveblocks.getYjsDocumentAsBinaryUpdate("my-room-id"); ``` To return a subdocument instead of the main document, pass its `guid`. ```ts const binaryYjsUpdate = await liveblocks.getYjsDocumentAsBinaryUpdate( "my-room-id", { // Optional, return a subdocument instead. guid is its unique identifier guid: "c4a755...", } ); ``` Read the [Yjs documentation](https://docs.yjs.dev/api/document-updates) to learn more about using binary updates. ### Schema validation #### Liveblocks.createSchema [#post-create-new-schema] Creates a schema that can be used to enforce a room’s Storage data structure. The schema consists of a unique name, and a body which specifies the data shape of the room in Liveblocks [schema syntax](/docs/platform/schema-validation/syntax). This is a wrapper around the [Create Schema API](/docs/api-reference/rest-api-endpoints#post-create-new-schema) and returns the same response. ```ts const schemaBody = ` type Storage { names: LiveList } `; const schema = await liveblocks.createSchema("my-schema-name", schemaBody); // { id: "my-schema-name@1", name: "my-schema-name", version: 1, ... } console.log(schema); ``` Read the [schema validation](/docs/platform/schema-validation) page to learn more. #### Liveblocks.getSchema [#get-create-new-schema] Returns a schema from its ID. A schema’s ID is a combination of its name and version, for example the ID for version `1` of `my-schema-name` is `my-schema-name@1`. This is a wrapper around the [Get Schema API](/docs/api-reference/rest-api-endpoints#get-create-new-schema) and returns the same response. ```ts const updatedBody = ` type Storage { names: LiveMap } `; const schema = await liveblocks.getSchema("my-schema-name@1", updatedBody); // { id: "my-schema-name@1", name: "my-schema-name", version: 1, ... } console.log(schema); ``` Read the [schema validation](/docs/platform/schema-validation) page to learn more. #### Liveblocks.updateSchema [#put-update-new-schema] Updates a schema’s body and increments its version. A schema’s body specifies the data shape of the room in Liveblocks [schema syntax](/docs/platform/schema-validation/syntax). Find the schema by its ID, a combination of its name and version, for example the ID for version `1` of `my-schema-name` is `my-schema-name@1`. This is a wrapper around the [Update Schema API](/docs/api-reference/rest-api-endpoints#put-update-new-schema) and returns the same response. ```ts const schema = await liveblocks.updateSchema("my-schema-name@1"); // { id: "my-schema-name@1", name: "my-schema-name", version: 1, ... } console.log(schema); ``` Read the [schema validation](/docs/platform/schema-validation) page to learn more. #### Liveblocks.deleteSchema [#delete-a-schema] Deletes a schema. This is only allowed if the schema is not attached to a room. Find the schema by its ID, a combination of its name and version, for example the ID for version `1` of `my-schema-name` is `my-schema-name@1`. This is a wrapper around the [Delete Schema API](/docs/api-reference/rest-api-endpoints#delete-a-schema) and returns no response. ```ts await liveblocks.deleteSchema("my-schema-name@1"); ``` Read the [schema validation](/docs/platform/schema-validation) page to learn more. #### Liveblocks.getSchemaByRoomId [#get-new-schema] Returns the schema attached to a room. Throws an error if the room isn’t found. This is a wrapper around the [Get Schema By Room API](/docs/api-reference/rest-api-endpoints#get-new-schema) and returns the same response. ```ts const schema = await liveblocks.getSchemaByRoomId("my-room-id"); // { id: "my-schema-name@1", name: "my-schema-name", version: 1, ... } console.log(schema); ``` Read the [schema validation](/docs/platform/schema-validation) page to learn more. #### Liveblocks.attachSchemaToRoom [#post-attach-schema-to-room] Attaches a schema to a room, and instantly enables runtime schema validation in it. Attach the schema by its ID, a combination of its name and version, for example the ID for version `1` of `my-schema-name` is `my-schema-name@1`. This is a wrapper around the [Attach Schema to a Room API](/docs/api-reference/rest-api-endpoints#post-attach-schema-to-room) and returns the same response. If the current contents of the room’s Storage do not match the schema, attaching will fail and the error message will give details on why the schema failed to attach. It’ll also throw an error if the room isn’t found. ```ts const schema = await liveblocks.attachSchemaToRoom( "my-room-id", "my-schema-name@1" ); // { id: "my-schema-name@1", name: "my-schema-name", version: 1, ... } console.log(schema); ``` Read the [schema validation](/docs/platform/schema-validation) page to learn more. #### Liveblocks.detachSchemaFromRoom [#delete-detach-schema-to-room] Detaches a schema from a room. This is a wrapper around the [Detach Schema from a Room API](/docs/api-reference/rest-api-endpoints#post-detach-schema-to-room) and returns no response. ```ts await liveblocks.detachSchemaFromRoom("my-room-id"); ``` ### Comments #### Liveblocks.getThreads [#get-rooms-roomId-threads] Returns a list of threads found inside a room. Throws an error if the room isn’t found. This is a wrapper around the [Get Room Threads API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-threads) and returns the same response. ```ts const { data: threads } = await liveblocks.getThreads({ roomId: "my-room-id", }); // [{ type: "thread", id: "th_d75sF3...", ... }, ...] console.log(threads); ``` It’s also possible to filter threads by their string, boolean, and number metadata using a query parameter. You can also pass `startsWith` to match the start of a string. ```ts const { data: threads } = await liveblocks.getThreads({ roomId: "my-room-id", // Optional, use advanced filtering query: { // Optional, filter based on resolved status resolved: false, // Optional, filter for metadata values metadata: { status: "open", pinned: true, priority: 3, // You can match the start of a metadata string organization: { startsWith: "liveblocks:", }, }, }, }); ``` You can also pass a [query language](/docs/guides/how-to-filter-threads-using-query-language) string instead of a `query` object. #### Liveblocks.createThread [#post-rooms-roomId-threads] Creates a new thread within a specific room, using room ID and thread data. This is a wrapper around the [Create Thread API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads) and returns the new thread. ```ts const thread = await liveblocks.createThread({ roomId: "my-room-id", data: { comment: { userId: "florent@example.com", body: { version: 1, content: [ /* The comment's body text goes here, see below */ ], }, }, }, }); // { type: "thread", id: "th_d75sF3...", ... } console.log(thread); ``` A comment’s body is an array of paragraphs, each containing child nodes. Here’s an example of how to construct a comment’s body, which can be submitted under `data.comment.body`. ```tsx highlight="3-11,20" import { CommentBody } from "@liveblocks/node"; const body: CommentBody = { version: 1, content: [ { type: "paragraph", children: [{ text: "Hello " }, { text: "world", bold: true }], }, ], }; const thread = await liveblocks.createThread({ roomId: "my-room-id", data: { // ... comment: { // The comment's body, uses the `CommentBody` type body, // ... }, }, }); ``` This method has a number of options, allowing for custom metadata and a creation date for the comment. ```ts const thread = await liveblocks.createThread({ roomId: "my-room-id", data: { // Optional, custom metadata properties metadata: { color: "blue", page: 3, pinned: true, }, // Data for the first comment in the thread comment: { // The ID of the user that created the comment userId: "florent@example.com", // Optional, when the comment was created. createdAt: new Date(), // The comment's body, uses the `CommentBody` type body: { version: 1, content: [ /* The comment's body text goes here, see above */ ], }, }, }, }); // { type: "thread", id: "th_d75sF3...", ... } console.log(thread); ``` #### Liveblocks.getThread [#get-rooms-roomId-threads-threadId] Returns a thread. Throws an error if the room or thread isn’t found. This is a wrapper around the [Get Thread API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-threads-threadId) and returns the same response. ```ts const thread = await liveblocks.getThread({ roomId: "my-room-id", threadId: "th_d75sF3...", }); // { type: "thread", id: "th_d75sF3...", ... } console.log(thread); ``` #### Liveblocks.getThreadParticipants [#get-rooms-roomId-threads-threadId-participants] Returns a list of participants found inside a thread. A participant is a user who has commented or been mentioned in the thread. Throws an error if the room or thread isn’t found. This is a wrapper around the [Get Thread Participants API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-threads-threadId-participants) and returns the same response. ```ts const { participantIds } = await liveblocks.getThreadParticipants({ roomId: "my-room-id", threadId: "th_d75sF3...", }); // ["chris@example.com", "nimesh@example.com", ...] console.log(participantIds); ``` #### Liveblocks.editThreadMetadata [#post-rooms-roomId-threads-threadId-metadata] Updates the metadata of a specific thread within a room. This method allows you to modify the metadata of a thread, including user information and the date of the last update. Throws an error if the room or thread isn’t found. This is a wrapper around the [Update Thread Metadata API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads-threadId-metadata) and returns the updated metadata. ```ts const editedMetadata = await liveblocks.editThreadMetadata({ roomId: "my-room-id", threadId: "th_d75sF3...", data: { metadata: { color: "yellow", }, userId: "marc@example.com", updatedAt: new Date(), // Optional }, }); // { color: "yellow", page: 3, pinned: true } console.log(editedMetadata); ``` Metadata can be a `string`, `number`, or `boolean`. You can also use `null` to remove metadata from a thread. Here’s an example using every option. ```ts const editedMetadata = await liveblocks.editThreadMetadata({ roomId: "my-room-id", threadId: "th_d75sF3...", data: { // Custom metadata metadata: { // Metadata can be a string, number, or boolean title: "My thread title", page: 3, pinned: true, // Remove metadata with null color: null, }, // The ID of the user that updated the metadata userId: "marc@example.com", // Optional, the time the user updated the metadata updatedAt: new Date(), }, }); // { title: "My thread title", page: 3, pinned: true } console.log(editedMetadata); ``` #### Liveblocks.markThreadAsResolved [#post-rooms-roomId-threads-threadId-mark-as-resolved] Marks a thread as resolved, which means it sets the `resolved` property on the specified thread to `true`. Takes a `userId`, which is the ID of the user that resolved the thread. Throws an error if the room or thread isn’t found. This is a wrapper around the [Mark Thread As Resolved API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads-threadId-mark-as-resolved) and returns the same response. ```ts const thread = await liveblocks.markThreadAsResolved({ roomId: "my-room-id", threadId: "th_d75sF3...", data: { userId: "steven@example.com", }, }); // { type: "thread", id: "th_d75sF3...", ... } console.log(thread); ``` #### Liveblocks.markThreadAsUnresolved [#post-rooms-roomId-threads-threadId-mark-as-unresolved] Marks a thread as unresolved, which means it sets the `resolved` property on the specified thread to `false`. Takes a `userId`, which is the ID of the user that unresolved the thread. Throws an error if the room or thread isn’t found. This is a wrapper around the [Mark Thread As Unresolved API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads-threadId-mark-as-unresolved) and returns the same response. ```ts const thread = await liveblocks.markThreadAsUnresolved({ roomId: "my-room-id", threadId: "th_d75sF3...", data: { userId: "steven@example.com", }, }); // { type: "thread", id: "th_d75sF3...", ... } console.log(thread); ``` #### Liveblocks.deleteThread [#delete-rooms-roomId-threads-threadId] Deletes a thread. Throws an error if the room or thread isn’t found. This is a wrapper around the [Delete Thread API](/docs/api-reference/rest-api-endpoints#delete-rooms-roomId-threads-threadId) and returns no response. ```ts await liveblocks.deleteThread({ roomId: "my-room-id", threadId: "th_d75sF3...", }); ``` #### Liveblocks.createComment [#post-rooms-roomId-threads-threadId-comments] Creates a new comment in a specific thread within a room. This method allows users to add comments to a conversation thread, specifying the user who made the comment and the content of the comment. This method is a wrapper around the [Get Comment API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads-threadId-comments) and returns the new comment. ```ts const comment = await liveblocks.createComment({ roomId: "my-room-id", threadId: "th_d75sF3...", data: { body: { version: 1, content: [ /* The comment's body text goes here, see below */ ], }, userId: "pierre@example.com", createdAt: new Date(), // Optional }, }); ``` A comment’s body is an array of paragraphs, each containing child nodes. Here’s an example of how to construct a comment’s body, which can be submitted under `data.body`. ```tsx highlight="3-11,19" import { CommentBody } from "@liveblocks/node"; const body: CommentBody = { version: 1, content: [ { type: "paragraph", children: [{ text: "Hello " }, { text: "world", bold: true }], }, ], }; const comment = await liveblocks.createComment({ roomId: "my-room-id", threadId: "th_d75sF3...", data: { // The comment's body, uses the `CommentBody` type body, // ... }, }); ``` This method has a number of options, including the option to add a custom creation date to the comment. ```ts const comment = await liveblocks.createComment({ roomId: "my-room-id", threadId: "th_d75sF3...", data: { // The comment's body, uses the `CommentBody` type body: { version: 1, content: [ /* The comment's body text goes here, see above */ ], }, // The ID of the user that created the comment userId: "adrien@example.com", // The time the comment was created createdAt: new Date(), }, }); ``` #### Liveblocks.getComment [#get-rooms-roomId-threads-threadId-comments-commentId] Returns a comment. Throws an error if the room, thread, or comment isn’t found. This is a wrapper around the [Get Comment API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-threads-threadId-comments-commentId) and returns the same response. ```ts const comment = await liveblocks.getComment({ roomId: "my-room-id", threadId: "th_d75sF3...", commentId: "cm_agH76a...", }); // { type: "comment", threadId: "th_d75sF3...", ... } console.log(comment); ``` #### Liveblocks.editComment [#post-rooms-roomId-threads-threadId-comments-commentId] Edits an existing comment in a specific thread within a room. This method allows users to update the content of their previously posted comments, with the option to specify the time of the edit. Throws an error if the comment isn’t found. This is a wrapper around the [Edit Comment API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads-threadId-comments-commentId) and returns the updated comment. ```ts const editedComment = await liveblocks.editComment({ roomId: "my-room-id", threadId: "th_d75sF3...", commentId: "cm_agH76a...", data: { body: { version: 1, content: [ /* The comment's body text goes here, see below */ ], }, userId: "alicia@example.com", createdAt: new Date(), // Optional }, }); // { type: "comment", threadId: "th_d75sF3...", ... } console.log(editedComment); ``` A comment’s body is an array of paragraphs, each containing child nodes. Here’s an example of how to construct a comment’s body, which can be submitted under `data.body`. ```tsx highlight="3-11,19" import { CommentBody } from "@liveblocks/node"; const body: CommentBody = { version: 1, content: [ { type: "paragraph", children: [{ text: "Hello " }, { text: "world", bold: true }], }, ], }; const comment = await liveblocks.createComment({ roomId: "my-room-id", threadId: "th_d75sF3...", data: { // The comment's body, uses the `CommentBody` type body, // ... }, }); ``` ```ts const editedComment = await liveblocks.editComment({ roomId: "my-room-id", threadId: "th_d75sF3...", commentId: "cm_agH76a...", data: { // The comment's body, uses the `CommentBody` type body: { version: 1, content: [ /* The comment's body text goes here, see above */ ], }, // The ID of the user that edited the comment userId: "alicia@example.com", // Optional, the time the comment was edited editedAt: new Date(), }, }); // { type: "comment", threadId: "th_d75sF3...", ... } console.log(editedComment); ``` #### Liveblocks.deleteComment [#delete-rooms-roomId-threads-threadId-comments-commentId] Deletes a specific comment from a thread within a room. If there are no remaining comments in the thread, the thread is also deleted. This method throws an error if the comment isn’t found. This is a wrapper around the [Delete Comment API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads-threadId-comments-commentId) and returns no response. ```ts await liveblocks.deleteComment({ roomId: "my-room-id", threadId: "th_d75sF3...", commentId: "cm_agH76a...", }); ``` #### Liveblocks.addCommentReaction [#post-rooms-roomId-threads-threadId-comments-commentId-add-reaction] Adds a reaction to a specific comment in a thread within a room. Throws an error if the comment isn’t found or if the user has already added the same reaction on the comment. This is a wrapper around the [Add Comment Reaction API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads-threadId-comments-commentId-add-reaction) and returns the new reaction. ```ts const reaction = await liveblocks.addCommentReaction({ roomId: "my-room-id", threadId: "th_d75sF3...", commentId: "cm_agH76a...", data: { emoji: "👨‍👩‍👧", userId: "guillaume@example.com", createdAt: new Date(), // Optional, the time the reaction was added }, }); // { emoji: "👨‍👩‍👧", userId "guillaume@example.com", ... } console.log(reaction); ``` #### Liveblocks.removeCommentReaction [#post-rooms-roomId-threads-threadId-comments-commentId-remove-reaction] Removes a reaction from a specific comment in a thread within a room. Throws an error if the comment reaction isn’t found. This is a wrapper around the [Remove Comment Reaction API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads-threadId-comments-commentId-remove-reaction) and returns no response. ```ts await liveblocks.removeCommentReaction({ roomId: "my-room-id", threadId: "th_d75sF3...", commentId: "cm_agH76a...", data: { emoji: "👨‍👩‍👧", userId: "steven@example.com", removedAt: new Date(), // Optional, the time the reaction is to be removed }, }); ``` #### Liveblocks.getRoomNotificationSettings [#get-rooms-roomId-users-userId-notification-settings] Returns a user’s notification settings for a specific room. This is a wrapper around the [Get Room Notification Settings API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-users-userId-notification-settings). ```ts const notificationSettings = await liveblocks.getRoomNotificationSettings({ roomId: "my-room-id", userId: "steven@example.com", }); // { threads: "all", ... } console.log(notificationSettings); ``` #### Liveblocks.updateRoomNotificationSettings [#post-rooms-roomId-users-userId-notification-settings] Updates a user’s notification settings for a specific room. This is a wrapper around the [Update Room Notification Settings API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-users-userId-notification-settings). ```ts const updatedNotificationSettings = await liveblocks.updateRoomNotificationSettings({ roomId: "my-room-id", userId: "steven@example.com", data: { threads: "replies_and_mentions", }, }); // { threads: "replies_and_mentions", ... } console.log(updatedNotificationSettings); ``` #### Liveblocks.deleteRoomNotificationSettings [#delete-rooms-roomId-users-userId-notification-settings] Deletes a user’s notification settings for a specific room. This is a wrapper around the [Delete Room Notification Settings API](/docs/api-reference/rest-api-endpoints#delete-rooms-roomId-users-userId-notification-settings). ```ts await liveblocks.deleteRoomNotificationSettings({ roomId: "my-room-id", userId: "steven@example.com", }); ``` ### Notifications #### Liveblocks.getInboxNotifications [#get-users-userId-inboxNotifications] Returns a list of a user’s inbox notifications. This is a wrapper around the [Get Inbox Notifications API](/docs/api-reference/rest-api-endpoints#get-users-userId-inboxNotifications). It also provides an unread query parameter to filter unread notifications. ```ts const { data: inboxNotifications } = await liveblocks.getInboxNotifications({ userId: "steven@example.com", }); // [{ id: "in_3dH7sF3...", kind: "thread", ... }, { id: "in_3dH7sF3...", kind: "textMention", ... }, ...] console.log(inboxNotifications); // Filter unread notifications const { data: unreadInboxNotifications } = await liveblocks.getInboxNotifications({ userId: "steven@example.com", query: { unread: true, }, }); ``` #### Liveblocks.getInboxNotification [#get-users-userId-inboxNotifications-inboxNotificationId] Returns a user’s inbox notification. This is a wrapper around the [Get Inbox Notification API](/docs/api-reference/rest-api-endpoints#get-users-userId-inboxNotifications-inboxNotificationId). ```ts const inboxNotification = await liveblocks.getInboxNotification({ userId: "steven@example.com", inboxNotificationId: "in_3dH7sF3...", }); // { id: "in_3dH7sF3...", kind: "thread", ... } // or { id: "in_3dH7sF3...", kind: "textMention", ... } // or { id: "in_3dH7sF3...", kind: "$yourKind", ... } console.log(inboxNotification); ``` #### Liveblocks.triggerInboxNotification [#post-inbox-notifications-trigger] Triggers a custom inbox notification. `kind` must start with a `$`, and represents the type of notification. `activityData` is used to send custom data with the notification, and properties can have `string`, `number`, or `boolean` values. This is a wrapper around the [Trigger Inbox Notification API](/docs/api-reference/rest-api-endpoints#post-inbox-notifications-trigger). ```ts await liveblocks.triggerInboxNotification({ // The ID of the user that will receive the inbox notification userId: "steven@example.com", // The custom notification kind, must start with a $ kind: "$fileUploaded", // Custom ID for this specific notification subjectId: "my-file", // Custom data related to the activity that you need to render the inbox notification activityData: { // Data can be a string, number, or boolean file: "https://example.com/my-file.zip", size: 256, success: true, }, // Optional, define the room ID the notification was sent from roomId: "my-room-id", }); ``` ##### Typing custom notifications To type custom notifications, edit the `ActivitiesData` type in your config file. ```ts file="liveblocks.config.ts" highlight="4-10" declare global { interface Liveblocks { // Custom activities data for custom notification kinds ActivitiesData: { // Example, a custom $alert kind $alert: { title: string; message: string; }; }; // Other kinds // ... } } ``` #### Liveblocks.deleteInboxNotification [#delete-users-userId-inboxNotifications-inboxNotificationId] Deletes a user’s inbox notification. This is a wrapper around the [Delete Inbox Notification API](/docs/api-reference/rest-api-endpoints#delete-users-userId-inboxNotifications-inboxNotificationId). ```ts await liveblocks.deleteInboxNotification({ userId: "steven@example.com", inboxNotificationId: "in_3dH7sF3...", }); ``` #### Liveblocks.deleteAllInboxNotifications [#delete-users-userId-inboxNotifications] Deletes all the user’s inbox notifications. This is a wrapper around the [Delete Inbox Notifications API](/docs/api-reference/rest-api-endpoints#delete-users-userId-inboxNotifications). ```ts await liveblocks.deleteAllInboxNotifications({ userId: "steven@example.com", }); ``` #### Liveblocks.getNotificationSettings [#get-users-userId-notification-settings] [@badge=Beta] Returns a user’s notification settings in the current project, in other words which [notification webhook events](/docs/platform/webhooks#NotificationEvent) will be sent for the user. User notification settings are project-based, which means that this returns the user’s settings for every room. This a wrapper around the [Get User Notification Settings API](/docs/api-reference/rest-api-endpoints#get-users-userId-notification-settings). ```ts const settings = await liveblocks.getNotificationSettings({ userId: "guillaume@liveblocks.io", }); // { email: { thread: true, ... }, slack: { thread: false, ... }, ... } console.log(settings); ``` A user’s initial settings are set in the dashboard, and different kinds should be enabled there. If no kind is enabled on the current channel, `null` will be returned. For example, with the email channel: ```ts const settings = await liveblocks.getNotificationSettings({ userId: "guillaume@liveblocks.io", }); // { email: null, ... } console.log(settings); ``` #### Liveblocks.updateNotificationSettings [#post-users-userId-notification-settings] [@badge=Beta] Updates a user’s notification settings, which affects which [notification webhook events](/docs/platform/webhooks#NotificationEvent) will be sent for the user. User notification settings are project-based, which means that this modifies the user’s settings in every room. Each notification `kind` must first be enabled on your project’s notification dashboard page before settings can be used. This a wrapper around the [Update User Notification Settings API](/docs/api-reference/rest-api-endpoints#post-users-userId-notification-settings). ```ts const updatedSettings = await liveblocks.updateNotificationSettings({ userId: "steven@example.com", data: { email: { thread: false }, slack: { textMention: true }, }, }); // { email: { thread: false, ... }, slack: { textMention: true, ... }, ... } console.log(updatedSettings); ``` You can pass a partial object, or many settings at once. ```ts // You only need to pass partials await liveblocks.updateNotificationSettings({ userId: "steven@example.com", email: { thread: true }, }); // Enabling a custom notification on the slack channel await liveblocks.updateNotificationSettings({ userId: "steven@example.com", slack: { $myCustomNotification: true }, }); // Setting complex settings await liveblocks.updateNotificationSettings({ userId: "steven@example.com", email: { thread: true, textMention: false, $newDocument: true, }, slack: { thread: false, $fileUpload: false, }, teams: { thread: true, }, }); ``` #### Liveblocks.deleteNotificationSettings [#delete-users-userId-notification-settings] [@badge=Beta] Deletes the user’s notification settings, resetting them to the default values. The default values can be adjusted in a project’s notification dashboard page. This a wrapper around the [Delete User Notification Settings API](/docs/api-reference/rest-api-endpoints#delete-users-userId-notification-settings). ```ts await liveblocks.deleteNotificationSettings({ userId: "adri@example.com", }); ``` ### Error handling [#error-handling] Errors in our API methods, such as network failures, invalid arguments, or server-side issues, are reported through the `LiveblocksError` class. This custom error class extends the standard JavaScript [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) and includes a `status` property, which provides the HTTP status code for the error, such as 404 for not found or 500 for server errors. Example of handling errors in a typical API call: ```ts try { const room = await liveblocks.getRoom("my-room-id"); // Process room } catch (error) { if (error instanceof LiveblocksError) { // Handle specific LiveblocksError cases console.error(`Error fetching room: ${error.status} - ${error.message}`); switch ( error.status // Specific cases based on status codes ) { } } else { // Handle general errors console.error(`Unexpected error: ${error.message}`); } } ``` ## Utilities ### getMentionedIdsFromCommentBody [#get-mentioned-ids-from-comment-body] Returns an array of each user’s ID that has been mentioned in a `CommentBody` (found under `comment.body`). ```ts import { getMentionedIdsFromCommentBody } from "@liveblocks/node"; const mentionedIds = getMentionedIdsFromCommentBody(comment.body); ``` This is most commonly used in combination with the [Comments API functions](/docs/api-reference/liveblocks-node#Comments), for example [`getComment`](/docs/api-reference/liveblocks-node#get-comment). ```ts import { Liveblocks, getMentionedIdsFromCommentBody } from "@liveblocks/node"; // Create a node client const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); // Retrieve a comment const comment = await liveblocks.getComment({ roomId: "my-room-id", threadId: "my-thread-id", commentId: "my-comment-id", }); // Get the mentions inside the comment's body const mentionedIds = getMentionedIdsFromCommentBody(comment.body); // ["marc@example.com", "vincent@example.com", ...] console.log(mentionedIds); ``` Here’s an example with a custom `CommentBody`. ```ts import { CommentBody, getMentionedIdsFromCommentBody } from "@liveblocks/node"; // Create a custom `CommentBody` const commentBody: CommentBody = { version: 1, content: [ { type: "paragraph", children: [ { text: "Hello " }, { type: "mention", id: "chris@example.com" }, ], }, ], }; // Get the mentions inside the comment's body const mentionedIds = getMentionedIdsFromCommentBody(commentBody); // ["chris@example.com"] console.log(mentionedIds); ``` If you’d like to use this on the client side, it's also available from [`@liveblocks/client`](/docs/api-reference/liveblocks-client#get-mentioned-ids-from-comment-body). ### stringifyCommentBody [#stringify-comment-body] Used to convert a `CommentBody` (found under `comment.body`) into either a plain string, Markdown, HTML, or a custom format. ```ts import { stringifyCommentBody } from "@liveblocks/node"; const stringComment = await stringifyCommentBody(comment.body); ``` This is most commonly used in combination with the [Comments API functions](/docs/api-reference/liveblocks-node#Comments), for example [`getComment`](/docs/api-reference/liveblocks-node#get-comment). ```ts import { Liveblocks, stringifyCommentBody } from "@liveblocks/node"; // Create a node client const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); // Retrieve a comment const comment = await liveblocks.getComment({ roomId: "my-room-id", threadId: "my-thread-id", commentId: "my-comment-id", }); // Convert CommentBody to plain string const stringComment = await stringifyCommentBody(comment.body); // "Hello marc@example.com from https://liveblocks.io" console.log(stringComment); ``` A number of options are also available. ```ts import { stringifyCommentBody } from "@liveblocks/node"; const stringComment = await stringifyCommentBody(comment.body, { // Optional, convert to specific format, "plain" (default) | "markdown" | "html" format: "markdown", // Optional, supply a separator to be used between paragraphs separator: `\n\n`, // Optional, override any elements in the CommentBody with a custom string elements: { // Optional, override the `paragraph` element paragraph: ({ element, children }) => `

${children}

`, // Optional, override the `text` element text: ({ element }) => element.bold ? `${element.text}` : `${element.text}`, // Optional, override the `link` element link: ({ element, href }) => `${element.url}`, // Optional, override the `mention` element. `user` available if `resolveUsers` supplied mention: ({ element, user }) => `${element.id}`, }, // Optional, get your user's names and info from their ID to be displayed in mentions async resolveUsers({ userIds }) { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ // Name is inserted into the output instead of a user's ID name: userData.name, // Custom formatting in `elements.mention` allows custom properties to be used profileUrl: userData.profileUrl, })); }, }); ``` If you’d like to use this on the client side, it's also available from [`@liveblocks/client`](/docs/api-reference/liveblocks-client#stringify-comment-body). #### Formatting examples Here are a number of different formatting examples derived from the same `CommentBody`. ```ts // "Hello marc@example.com from https://liveblocks.io" await stringifyCommentBody(comment.body); // "Hello @Marc from https://liveblocks.io" await stringifyCommentBody(comment.body, { resolveUsers({ userIds }) { return [{ name: "Marc" }]; }, }); // "**Hello** @Marc from [https://liveblocks.io](https://liveblocks.io)" await stringifyCommentBody(comment.body, { format: "markdown", resolveUsers() { return [{ name: "Marc" }]; }, }); // "Hello @Marc from // https://liveblocks.io" await stringifyCommentBody(comment.body, { format: "html", resolveUsers() { return [{ name: "Marc" }]; }, }); // "Hello @Marc from // https://liveblocks.io" await stringifyCommentBody(comment.body, { format: "html", mention: ({ element, user }) => `${user.name}`, resolveUsers() { return [{ name: "Marc", profileUrl: "https://example.com" }]; }, }); ``` ### WebhookHandler [#WebhookHandler] Read the [Webhooks guide](/docs/platform/webhooks) to learn how to use them within your product, allowing you to react to Liveblocks events as they happen. The `WebhookHandler` class is a helper to handle webhook requests from Liveblocks. It’s initialized with a signing secret that you can find in your project’s webhook page. ```js const webhookHandler = new WebhookHandler(process.env.WEBHOOK_SECRET); ``` #### verifyRequest [#verifyRequest] Verifies the request and returns the event. Note that `rawBody` takes the body as a `string`. ```js const event = webhookHandler.verifyRequest({ headers: req.headers, rawBody: req.body, }); ``` Some frameworks parse request bodies into objects, which means using `JSON.stringify` may be necessary. ```js highlight="3" const event = webhookHandler.verifyRequest({ headers: req.headers, rawBody: JSON.stringify(req.body), }); ``` ##### Example using Next.js [#webhook-example] ```js import { WebhookHandler } from "@liveblocks/node"; // Will fail if not properly initialized with a secret // Obtained from the Webhooks section of your project dashboard // https://liveblocks.io/dashboard const webhookHandler = new WebhookHandler(process.env.WEBHOOK_SECRET); export function POST(request) { try { const event = webhookHandler.verifyRequest({ headers: req.headers, rawBody: JSON.stringify(req.body), }); // Handle `WebhookEvent` if (event.type === "storageUpdated") { // Handle `StorageUpdatedEvent` } else if (event.type === "userEntered") { // Handle `UserEnteredEvent` } else if (event.type === "userLeft") { // Handle `UserLeftEvent` } } catch (error) { console.error(error); return new Response(error, { status: 400 }); } } ``` ### isThreadNotificationEvent [#isThreadNotificationEvent] Type guard to check if a received webhook event is a [`ThreadNotificationEvent`](/docs/platform/webhooks#Thread-notification) send from Comments. Particularly helpful when creating [thread notification emails](/docs/api-reference/liveblocks-emails#thread-notification-emails) with webhooks. ```js import { isThreadNotificationEvent } from "@liveblocks/node"; const event = webhookHandler.verifyRequest({ headers: req.headers, rawBody: req.body, }); // +++ if (isThreadNotificationEvent(event)) { // Handle `ThreadNotificationEvent` } // +++ ``` The check is made against the event type and event data kind. ### isTextMentionNotificationEvent [#isTextMentionNotificationEvent] Type guard to check if a received webhook event is a [`TextMentionNotificationEvent`](/docs/platform/webhooks#TextMention-notification) sent from Text Editor. Particularly helpful for identifying text mentions when sending email notifications. ```js import { isTextMentionNotificationEvent } from "@liveblocks/node"; const event = webhookHandler.verifyRequest({ headers: req.headers, rawBody: req.body, }); // +++ if (isTextMentionNotificationEvent(event)) { // Handle `TextMentionNotificationEvent` } // +++ ``` ### isCustomNotificationEvent [#isCustomNotificationEvent] Type guard to check if a received webhook event is a [`CustomNotificationEvent`](/docs/platform/webhooks#Custom-notification) sent from [`triggerInboxNotification`](/docs/api-reference/liveblocks-node#post-inbox-notifications-trigger). Particularly helpful for identifying custom notifications when sending email notifications. ```js import { isCustomNotificationEvent } from "@liveblocks/node"; const event = webhookHandler.verifyRequest({ headers: req.headers, rawBody: req.body, }); // +++ if (isCustomNotificationEvent(event)) { // Handle `CustomNotificationEvent` } // +++ ``` The check is made against the event type and event data kind. ## Deprecated ### authorize [#authorize] [@badge=Deprecated] This is no longer supported. Adopt [`Liveblocks.prepareSession`](#access-tokens) or [`Liveblocks.identifyUser`](#id-tokens) APIs instead. The purpose of `authorize()` was to help you implement your custom authentication back end. It generates old-style single-room tokens. Please refer to [our upgrade guide](/docs/platform/upgrading/1.2) if you’re using the `authorize` function in your back end, and adopt [`Liveblocks.prepareSession`](#access-tokens) or [`Liveblocks.identifyUser`](#id-tokens) APIs instead. [`room.getothers`]: /docs/api-reference/liveblocks-client#Room.getOthers [Permissions REST API]: /docs/authentication/id-token --- meta: title: "@liveblocks/react-lexical" parentTitle: "API Reference" description: "API Reference for the @liveblocks/react-lexical package" alwaysShowAllNavigationLevels: false --- `@liveblocks/react-lexical` provides you with a [React](https://react.dev/) plugin that adds collaboration to any [Lexical](https://lexical.dev/) text editor. It also adds realtime cursors, document persistence on the cloud, comments, and mentions. Read our [get started guides](/docs/get-started/text-editor/lexical) to learn more. ## Setup To set up your collaborative Lexical editor, you must use [`LiveblocksPlugin`](#LiveblocksPlugin) and [`liveblocksConfig`](#liveblocksConfig). ### LiveblocksPlugin Liveblocks plugin for Lexical that adds collaboration to your editor. ```tsx highlight="2" ``` {/* TODO: Image */} `LiveblocksPlugin` should always be nested inside [`LexicalComposer`][], and each [Lexical default component](#Default-components) you’re using should be placed inside the plugin. ```tsx import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; // +++ import { liveblocksConfig, LiveblocksPlugin } from "@liveblocks/react-lexical"; // +++ const initialConfig = liveblocksConfig({ namespace: "MyEditor", theme: {}, nodes: [], onError: (err) => console.error(err), }); function Editor() { return ( // +++ // +++ } placeholder={
Enter some text...
} ErrorBoundary={LexicalErrorBoundary} />
); } ``` Annotations associated with resolved threads are hidden by default on the editor. Learn more in our [get started guides](/docs/get-started/text-editor/lexical). ### liveblocksConfig Function that takes a Lexical editor config and modifies it to add the necessary `nodes` and `theme` to make [`LiveblocksPlugin`][] works correctly. ```tsx import { liveblocksConfig } from "@liveblocks/react-lexical"; const initialConfig = liveblocksConfig({ namespace: "MyEditor", theme: {}, nodes: [], onError: (err) => console.error(err), }); ``` The config created by `liveblocksConfig` should be passed to `initialConfig` in [`LexicalComposer`][]. ```tsx highlight="7-12,16" import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { liveblocksConfig, LiveblocksPlugin } from "@liveblocks/react-lexical"; const initialConfig = liveblocksConfig({ namespace: "MyEditor", theme: {}, nodes: [], onError: (err) => console.error(err), }); function Editor() { return ( } placeholder={
Enter some text...
} ErrorBoundary={LexicalErrorBoundary} />
); } ``` Note that `liveblocksConfig` sets `editorState` to `null` because `LiveblocksPlugin` is responsible for initializing it on the server. ## Default components ### Toolbar Displays a toolbar, allowing you to change the styles of selected text. You can add content [before or after](#toolbar-extending-the-defaults), or the toolbar’s options can be [customized](#creating-a-custom-toolbar). A [floating toolbar](#FloatingToolbar) also exists. ```tsx // +++ // +++ ```
Toolbar
By default, one of the toolbar buttons can create comment threads—to enable this add [`FloatingComposer`][] and display threads with [`AnchoredThreads`][] or [`FloatingThreads`][]. Should be nested inside [`LiveblocksPlugin`][]. ```tsx import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { useThreads } from "@liveblocks/react/suspense"; import { liveblocksConfig, LiveblocksPlugin, FloatingComposer, // +++ Toolbar, //+++ } from "@liveblocks/react-lexical"; const initialConfig = liveblocksConfig({ namespace: "MyEditor", theme: {}, nodes: [], onError: (err) => console.error(err), }); function Editor() { const { threads } = useThreads(); return ( // +++ // +++ } placeholder={
Enter some text...
} ErrorBoundary={LexicalErrorBoundary} />
); } ``` #### Extending the defaults [#toolbar-extending-the-defaults] You can insert content `before` the first button and `after` the last button using `before` and `after`. Components such as [`Toolbar.Button`][] and [`Toolbar.Toggle`][] can be used to create new buttons. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; import { Icon } from "@liveblocks/react-ui"; I'm at the start} after={ } shortcut="CMD-H" onClick={() => console.log("help")} /> } // +++ />; ``` For more complex customization, instead read [creating a custom floating toolbar](#creating-a-custom-toolbar). #### Creating a custom toolbar [#creating-a-custom-toolbar] By passing elements as children, it’s possible to create a fully custom toolbar. ```tsx highlight="6" import { Toolbar } from "@liveblocks/react-lexical"; function CustomToolbar() { return ( Hello world ); } ``` Each part of our default toolbar is available as blocks which can be slotted together. This is how the default toolbar is constructed: ```tsx import { Toolbar } from "@liveblocks/react-lexical"; function CustomToolbar() { return ( // +++ // +++ ); } ``` You can mix these default components with any custom ones of your own. Below the [`Toolbar.SectionHistory`][] component is added alongside some custom buttons created with [`Toolbar.Button`][], [`Toolbar.Toggle`][], and [`Icon`][]. ```tsx import { FORMAT_TEXT_COMMAND } from "lexical"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { isTextFormatActive, Toolbar } from "@liveblocks/react-lexical"; import { Icon } from "@liveblocks/react-ui"; function CustomToolbar() { const [editor] = useLexicalComposerContext(); return ( // +++ } shortcut="CMD-H" onClick={() => console.log("help")} /> B️} active={isTextFormatActive(editor, "bold")} onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")} /> // +++ ); } ``` To learn more about the different components, read more below. #### Props [#Toolbar-props] The Lexical editor. The content of the toolbar, overriding the default content. Use the `before` and `after` props if you want to keep and extend the default content. Any `ReactNode` or `Toolbar.*` components work inside. The content to display at the start of the toolbar. Any `ReactNode` or `Toolbar.*` components work inside. The content to display at the end of the toolbar. Any `ReactNode` or `Toolbar.*` components work inside. #### Toolbar.Button A button for triggering actions. The `name` is displayed in a tooltip. Props such as `onClick` will be passed to the underlying `button` element. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; console.log("Clicked")} /> ; ``` Optionally takes an icon which will visually replace the `name`. Also optionally accepts a shortcut, which is displayed in the tooltip. Comment key names are converted to symbols. Here are various examples. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; import { Icon } from "@liveblocks/react-ui"; // Button says "Question" // Tooltip says "Question [⌘+Q]" // Custom icon, replaces the name in the button ?
} onClick={/* ... */} /> // Using a Liveblocks icon, replaces the name in the button } onClick={/* ... */} /> // Passing children visually replaces the `name` and `icon` ? Ask a question // Props are passed to the inner `button` console.log("Hovered")} /> ``` ##### Props [#ToolbarButton-props] The name of this button displayed in its tooltip. Will also be displayed in the button if no `icon` or `children` are passed. An optional icon displayed in this button. An optional keyboard shortcut displayed in this button’s tooltip. Common shortcuts such will be replaced by their symbols, for example `CMD` → `⌘`. #### Toolbar.Toggle A toggle button for values that can be active or inactive. Best used with text editor commands. The `name` is displayed in a tooltip. Props will be passed to the underlying `button` element. ```tsx import { FORMAT_TEXT_COMMAND } from "lexical"; import { isTextFormatActive, Toolbar } from "@liveblocks/react-lexical"; editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")} /> ; ``` The snippet above shows how to toggle bold styling. The toggle button can also be toggled with `useState`. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; import { useState } from "react"; function CustomToggle() { const [active, setActive] = useState(false); return ( setActive(!active)} /> ); } ``` `Toolbar.Toggle` optionally takes an icon which will visually replace the `name`. Also optionally accepts a shortcut, which is displayed in the tooltip. Comment key names are converted to symbols. Here are various examples. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; import { Icon } from "@liveblocks/react-ui"; // Button says "Highlight" // Tooltip says "Highlight [⌘+H]" // Custom icon, replaces the name in the button 🖊} active={/* ... */} onClick={/* ... */} /> // Using a Liveblocks icon, replaces the name in the button } active={/* ... */} onClick={/* ... */} /> // Passing children visually replaces the `name` and `icon` 🖊️Highlight // Props are passed to the inner `button` console.log("Hovered")} /> ``` ##### Props [#ToolbarToggle-props] The name of this button displayed in its tooltip. Will also be displayed in the button if no `icon` or `children` are passed. Whether the button is toggled. An optional icon displayed in this button. An optional keyboard shortcut displayed in this button’s tooltip. Common shortcuts such will be replaced by their symbols, for example `CMD` → `⌘`. #### Toolbar.BlockSelector Adds a dropdown selector for switching between different block types, such as _text_, _heading 1_, _blockquote_. Props will be passed to the inner `button` element. Can also be placed inside [`FloatingToolbar`][]. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; ; ``` ##### Use custom item options If you’d like to change the items shown in the dropdown menu, you can pass a custom `items` array. Below a code block item ([Lexical extension](https://lexical.dev/docs/api/modules/lexical_code)) is added after the default options. ```tsx import { isBlockNodeActive, Toolbar } from "@liveblocks/react-lexical"; import { $setBlocksType } from "@lexical/selection"; import { $isCodeNode } from "@lexical/code"; import { $getSelection } from "lexical"; [ ...defaultItems, { name: "Code block", icon:
❮ ❯
, // Optional isActive: (editor) => isBlockNodeActive(editor, $isCodeNode), setActive: (editor) => $setBlocksType($getSelection(), () => $createCodeNode()), }, ]} />
; ``` ##### Customize item styles By passing a `label` property, you can overwrite the styles of the dropdown items. The toolbar button will still display the `name`, but in the dropdown, the `label` will be used instead of the `name` and `icon`. Below, a new item is added and its `label` is customized. ```tsx import { isBlockNodeActive, Toolbar } from "@liveblocks/react-lexical"; import { $setBlocksType } from "@lexical/selection"; import { $isCodeNode } from "@lexical/code"; import { $getSelection } from "lexical"; [ ...defaultItems, { name: "Code block", // +++ label:
Code
, // Optional, overwrites `icon` + `name` // +++ isActive: (editor) => isBlockNodeActive(editor, $isCodeNode), setActive: (editor) => $setBlocksType($getSelection(), () => $createCodeNode()), }, ]} />
; ``` You can also customize the default items. Below each item is styled to represent the effect each block applies to the document. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; defaultItems.map((item) => { let label; if (item.name === "Text") { label = Regular text; } if (item.name === "Heading 1") { label = ( Heading 1 ); } if (item.name === "Heading 2") { label = ( Heading 2 ); } if (item.name === "Heading 3") { label = ( Heading 3 ); } if (item.name === "Blockquote") { label = ( Blockquote ); } return { ...item, label, icon: null, // Hide all icons }; }) } />; ``` ##### Props [#ToolbarBlockSelector-props] The items displayed in this block selector. When provided as an array, the default items are overridden. To avoid this, a function can be provided instead and it will receive the default items. #### Toolbar.Separator Adds a visual, and accessible, separator used to separate sections in the toolbar. Props will be passed to the inner `div` element. Can also be placed inside [`FloatingToolbar`][]. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; // +++ // +++ ; ``` #### Toolbar.SectionHistory Adds a section containing _undo_ and _redo_ buttons. Can also be placed inside [`FloatingToolbar`][]. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; // +++ // +++ ; ``` #### Toolbar.SectionInline Adds a section containing inline formatting actions such as _bold_, _italic_, _underline_. Can also be placed inside [`FloatingToolbar`][]. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; // +++ // +++ ; ``` #### Toolbar.SectionCollaboration Adds a section containing an _add comment_ button. Can also be placed inside [`FloatingToolbar`][]. ```tsx import { Toolbar } from "@liveblocks/react-lexical"; // +++ // +++ ; ``` ### FloatingToolbar Displays a floating toolbar near the current Lexical selection, allowing you to change styles. You can add content [before or after](#floating-toolbar-extending-the-defaults), or the toolbar’s options can be [customized](#creating-a-custom-floating-toolbar). A [static toolbar](#Toolbar) also exists. ```tsx // +++ // +++ ```
FloatingToolbar
By default, one of the toolbar buttons can create comment threads—to enable this add [`FloatingComposer`][] and display threads with [`AnchoredThreads`][] or [`FloatingThreads`][]. Should be nested inside [`LiveblocksPlugin`][]. ```tsx import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { useThreads } from "@liveblocks/react/suspense"; import { liveblocksConfig, LiveblocksPlugin, FloatingComposer, // +++ FloatingToolbar, //+++ } from "@liveblocks/react-lexical"; const initialConfig = liveblocksConfig({ namespace: "MyEditor", theme: {}, nodes: [], onError: (err) => console.error(err), }); function Editor() { const { threads } = useThreads(); return ( // +++ // +++ } placeholder={
Enter some text...
} ErrorBoundary={LexicalErrorBoundary} />
); } ``` #### Changing float position Using `position` and `offset` you can reposition the toolbar relative to the current selection. `position` can be set to `"top"` or `"bottom"`, and `offset` defines the vertical distance in pixels from the selection. ```tsx ``` #### Extending the defaults [#floating-toolbar-extending-the-default] You can insert custom content `before` the first button and `after` the last button using `before` and `after`. Components such as [`Toolbar.Button`][] and [`Toolbar.Toggle`][] can be used to create new buttons. ```tsx import { FloatingToolbar } from "@liveblocks/react-lexical"; import { Icon } from "@liveblocks/react-ui"; I'm at the start} after={ } shortcut="CMD-H" onClick={() => console.log("help")} /> } // +++ />; ``` For more complex customization, instead read [creating a custom floating toolbar](#creating-a-custom-floating-toolbar). #### Creating a custom floating toolbar [#creating-a-custom-floating-toolbar] By passing elements as children, it’s possible to create a fully custom floating toolbar. ```tsx highlight="7" import { FloatingToolbar } from "@liveblocks/react-lexical"; function CustomToolbar() { return ( Hello world ); } ``` Each part of our default toolbar is available as blocks which can be slotted together. This is how the default floating toolbar is constructed: ```tsx import { FloatingToolbar } from "@liveblocks/react-lexical"; function CustomToolbar() { return ( // +++ // +++ ); } ``` You can mix these default components with any custom ones of your own. Below the [`Toolbar.SectionHistory`][] component is added alongside some custom buttons created with [`Toolbar.Button`][], [`Toolbar.Toggle`][], and [`Icon`][]. ```tsx import { FORMAT_TEXT_COMMAND } from "lexical"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { isTextFormatActive, FloatingToolbar } from "@liveblocks/react-lexical"; import { Icon } from "@liveblocks/react-ui"; function CustomToolbar() { const [editor] = useLexicalComposerContext(); return ( // +++ } shortcut="CMD-H" onClick={() => console.log("help")} /> B️} active={isTextFormatActive(editor, "bold")} onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")} /> // +++ ); } ``` To learn more about the different components, read more under [`Toolbar`][]. #### Props [#FloatingToolbar-props] The Lexical editor. The vertical position of the floating toolbar. The vertical offset of the floating toolbar from the selection. The content of the toolbar, overriding the default content. Use the `before` and `after` props if you want to keep and extend the default content. Any `ReactNode` or `Toolbar.*` components work inside. The content to display at the start of the toolbar. Any `ReactNode` or `Toolbar.*` components work inside. The content to display at the end of the toolbar. Any `ReactNode` or `Toolbar.*` components work inside. ### FloatingComposer Displays a [`Composer`][] near the current Lexical selection, allowing you to create threads. ```tsx highlight="3" ```
FloatingComposer
Submitting a comment will attach an annotation thread at the current selection. Should be nested inside [`LiveblocksPlugin`][]. ```tsx highlight="8,22" import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { liveblocksConfig, LiveblocksPlugin, FloatingComposer, } from "@liveblocks/react-lexical"; const initialConfig = liveblocksConfig({ namespace: "MyEditor", theme: {}, nodes: [], onError: (err) => console.error(err), }); function Editor() { return ( } placeholder={
Enter some text...
} ErrorBoundary={LexicalErrorBoundary} />
); } ``` Display created threads with [`AnchoredThreads`][] or [`FloatingThreads`][]. #### Opening the composer To open the `FloatingComposer`, you need to dispatch the `OPEN_FLOATING_COMPOSER_COMMAND` [Lexical command](https://lexical.dev/docs/concepts/commands). ```tsx highlight="10" import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { OPEN_FLOATING_COMPOSER_COMMAND } from "@liveblocks/react-lexical"; function Toolbar() { const [editor] = useLexicalComposerContext(); return ( ); } ``` #### Props [#FloatingComposer-props] The metadata of the thread to create. The event handler called when the composer is submitted. The composer’s initial value. Whether the composer is collapsed. Setting a value will make the composer controlled. The event handler called when the collapsed state of the composer changes. Whether the composer is initially collapsed. Setting a value will make the composer uncontrolled. Whether the composer is disabled. Whether to focus the composer on mount. Override the component’s strings. ### FloatingThreads Displays floating [`Thread`][] components below text highlights in the editor. ```tsx highlight="3" ```
FloatingThreads
Takes a list of threads retrieved from [`useThreads`][] and renders them to the page. Each thread is opened by clicking on its corresponding text highlight. ```tsx import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { useThreads } from "@liveblocks/react/suspense"; import { liveblocksConfig, LiveblocksPlugin, FloatingComposer, // +++ FloatingThreads, // +++ } from "@liveblocks/react-lexical"; const initialConfig = liveblocksConfig({ namespace: "MyEditor", theme: {}, nodes: [], onError: (err) => console.error(err), }); function Editor() { // +++ const { threads } = useThreads(); // +++ return ( // +++ // +++ } placeholder={
Enter some text...
} ErrorBoundary={LexicalErrorBoundary} />
); } ``` Should be nested inside [`LiveblocksPlugin`][]. The `FloatingThreads` component automatically excludes resolved threads from display. Any resolved threads passed in the threads list will not be shown. #### Recommended usage [#FloatingThreads-recommended-usage] [`FloatingThreads`][] and [`AnchoredThreads`][] have been designed to work together to provide the optimal experience on mobile and desktop. We generally recommend using both components, hiding one on smaller screens, as we are below with Tailwind classes. Most apps also don’t need to display resolved threads, so we can filter those out with a [`useThreads`][] option. ```tsx import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingThreads } from "@liveblocks/react-lexical"; function ThreadOverlay() { const { threads } = useThreads({ query: { resolved: false } }); return ( <> ); } ``` ```tsx title="Alternatively use a media query hook" isCollapsed isCollapsable import { useSyncExternalStore } from "react"; import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingThreads } from "@liveblocks/react-lexical"; function ThreadOverlay() { const { threads } = useThreads({ query: { resolved: false } }); // +++ const isMobile = useIsMobile(); // +++ // +++ if (isMobile) { return ; } // +++ // +++ return ; //+++ } export function useIsMobile() { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } function subscribe(callback: () => void) { const query = window.matchMedia("(max-width: 1024px)"); query.addEventListener("change", callback); return () => query.removeEventListener("change", callback); } function getSnapshot() { const query = window.matchMedia("(max-width: 1024px)"); return query.matches; } ``` We can place this component inside [`ClientSideSuspense`][] to prevent it rendering until threads have loaded. ```tsx // +++ // +++ ``` #### Customization [#FloatingThreads-customization] The `FloatingThreads` component acts as a wrapper around each individual [`Thread`][]. You can treat the component like you would a `div`, using classes, listeners, and more. ```tsx ``` To apply styling to each [`Thread`][], you can pass a custom `Thread` property to `components` and modify this in any way. This is the best way to modify a thread’s width. ```tsx import { Thread } from "@liveblocks/react-ui"; ( ), }} // +++ />; ``` You can return any custom `ReactNode` here, including anything from a simple wrapper around `Thread`, up to a full custom `Thread` component built using our [Comment primitives](/docs/api-reference/liveblocks-react-ui#primitives-Comment). ```tsx import { Comment } from "@liveblocks/react-ui/primitives"; ( // +++
{props.thread.comments.map((comment) => ( ))}
// +++ ), }} />; ``` #### Props [#FloatingThreads-props] The threads to display. Override the component’s components. Override the [`Thread`](/docs/api-reference/liveblocks-react-ui#Thread) component. ### AnchoredThreads Displays a list of [`Thread`][] components vertically alongside the editor. ```tsx highlight="3" ```
AnchoredThreads
Takes a list of threads retrieved from [`useThreads`][] and renders them to the page. Each thread is displayed at the same vertical coordinates as its corresponding text highlight. If multiple highlights are in the same location, each thread is placed in order below the previous thread. ```tsx import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { useThreads } from "@liveblocks/react/suspense"; import { liveblocksConfig, LiveblocksPlugin, FloatingComposer, // +++ AnchoredThreads, // +++ } from "@liveblocks/react-lexical"; const initialConfig = liveblocksConfig({ namespace: "MyEditor", theme: {}, nodes: [], onError: (err) => console.error(err), }); function Editor() { // +++ const { threads } = useThreads(); // +++ return ( // +++ // +++ } placeholder={
Enter some text...
} ErrorBoundary={LexicalErrorBoundary} />
); } ``` Should be nested inside [`LiveblocksPlugin`][]. The `AnchoredThreads` component automatically excludes resolved threads from display. Any resolved threads passed in the threads list will not be shown. #### Recommended usage [#AnchoredThreads-recommended-usage] [`FloatingThreads`][] and [`AnchoredThreads`][] have been designed to work together to provide the optimal experience on mobile and desktop. We generally recommend using both components, hiding one on smaller screens, as we are below with Tailwind classes. Most apps also don’t need to display resolved threads, so we can filter those out with a [`useThreads`][] option. ```tsx import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingThreads } from "@liveblocks/react-lexical"; function ThreadOverlay() { const { threads } = useThreads({ query: { resolved: false } }); return ( <> ); } ``` ```tsx title="Alternatively use a media query hook" isCollapsed isCollapsable import { useSyncExternalStore } from "react"; import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingThreads } from "@liveblocks/react-lexical"; function ThreadOverlay() { const { threads } = useThreads({ query: { resolved: false } }); // +++ const isMobile = useIsMobile(); // +++ // +++ if (isMobile) { return ; } // +++ // +++ return ; //+++ } export function useIsMobile() { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } function subscribe(callback: () => void) { const query = window.matchMedia("(max-width: 1024px)"); query.addEventListener("change", callback); return () => query.removeEventListener("change", callback); } function getSnapshot() { const query = window.matchMedia("(max-width: 1024px)"); return query.matches; } ``` We can place this component inside [`ClientSideSuspense`][] to prevent it rendering until threads have loaded. ```tsx // +++ // +++ ``` #### Customization [#AnchoredThreads-customization] The `AnchoredThreads` component acts as a wrapper around each [`Thread`][]. It has no width, so setting this is required, and each thread will take on the width of the wrapper. You can treat the component like you would a `div`, using classes, listeners, and more. ```tsx ``` To apply styling to each [`Thread`][], you can pass a custom `Thread` property to `components` and modify this in any way. ```tsx import { Thread } from "@liveblocks/react-ui"; ( ), }} // +++ />; ``` You can return any custom `ReactNode` here, including anything from a simple wrapper around `Thread`, up to a full custom `Thread` component built using our [Comment primitives](/docs/api-reference/liveblocks-react-ui#primitives-Comment). ```tsx import { Comment } from "@liveblocks/react-ui/primitives"; ( // +++
{props.thread.comments.map((comment) => ( ))}
// +++ ), }} />; ``` ##### Modifying thread floating positions Using CSS variables you can modify the gap between threads, and the horizontal offset that’s added when a thread is selected. ```css .lb-lexical-anchored-threads { /* Minimum gap between threads */ --lb-lexical-anchored-threads-gap: 8px; /* How far the active thread is offset to the left */ --lb-lexical-anchored-threads-active-thread-offset: 12px; } ``` #### Props [#AnchoredThreads-props] The threads to display. Override the component’s components. Override the [`Thread`](/docs/api-reference/liveblocks-react-ui#Thread) component. ### HistoryVersionPreview Version history is currently in private beta. If you would like access to the beta, please [contact us](https://liveblocks.io/contact/sales). We’d love to hear from you. The `HistoryVersionPreview` component allows you to display a preview of a specific version of your Lexical editor's content. It also contains a button and logic for restoring. It must be used inside the `` context. To render a list of versions, see [`VersionHistory`](/docs/api-reference/liveblocks-react-ui#Version-History). #### Usage ```tsx import { HistoryVersionPreview } from "@liveblocks/react-lexical"; function VersionPreview({ selectedVersion, onVersionRestore }) { return ( ); } ``` #### Props The version of the editor content to preview. Callback function called when the user chooses to restore this version. The `HistoryVersionPreview` component renders a read-only view of the specified version of the editor content. It also provides a button for users to restore the displayed version. ## Hooks ### useIsEditorReady Used to check if the editor content has been loaded or not, helpful for displaying a loading skeleton. ```ts import { useIsEditorReady } from "@liveblocks/react-lexical"; const status = useIsEditorReady(); ``` Here’s how it can be used in the context of your editor. ```tsx import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { liveblocksConfig, LiveblocksPlugin, // +++ useIsEditorReady, // +++ } from "@liveblocks/react-lexical"; const initialConfig = liveblocksConfig({ namespace: "MyEditor", theme: {}, nodes: [], onError: (err) => console.error(err), }); function Editor() { // +++ const ready = useIsEditorReady(); // +++ return ( // +++ {!ready ? (
Loading...
) : ( } placeholder={
Enter some text...
} ErrorBoundary={LexicalErrorBoundary} /> )} // +++
); } ``` ### useEditorStatus Deprecated. Starting with 2.12.0, we recommend using [`useSyncStatus`][] instead for tracking sync status, because it will reflect sync status of all parts of Liveblocks, not just Storage. Returns the current editor status. ```ts import { useEditorStatus } from "@liveblocks/react-lexical"; const status = useEditorStatus(); ``` The possible values are: - `not-loaded`: Initial editor state when entering the room. - `loading`: Once the editor state has been requested by `LiveblocksPlugin`. - `synchronized`: The editor state is sync with Liveblocks servers. ### useIsThreadActive Accepts a thread id and returns whether the thread annotation for this thread is selected or not in the Lexical editor. This hook must be used in a component nested inside [`LiveblocksPlugin`][]. ```ts import { useIsThreadActive } from "@liveblocks/react-lexical"; const isActive = useIsThreadActive(thread.id); ``` The ID of the thread. This hook can be useful to style threads based on whether their associated thread annotations are selected or not in the editor. ## Utilities ### isTextFormatActive Checks if a text format (bold, italic, etc.) is active in the current selection. Takes a Lexical editor, and returns a `boolean`. ```tsx import { isTextFormatActive } from "@liveblocks/react-lexical"; // "true" | "false" const isActive = isTextFormatActive(editor, "bold"); ``` The Lexical editor. The Lexical text format to check for in the current selection. #### Creating toggle buttons The `isTextFormatActive` helper is particularly useful for creating buttons with [`Toolbar.Toggle`][]. ```tsx import { FORMAT_TEXT_COMMAND } from "lexical"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; // +++ import { isTextFormatActive, Toolbar } from "@liveblocks/react-lexical"; // +++ function CustomToggleButton() { const [editor] = useLexicalComposerContext(); return ( B️} // +++ active={isTextFormatActive(editor, "bold")} // +++ onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")} /> ); } ``` ### isBlockNodeActive Checks if a block node is active in the current selection. If the selection contains multiple block nodes, it will only return `true` if all of them are of the same type. ```tsx import { isBlockNodeActive } from "@liveblocks/react-lexical"; import { $isTextNode } from "lexical"; // Checking if text node is currently active const isActive = isBlockNodeActive(editor, $isTextNode); ``` The Lexical editor. Function that passes the current node, helping you check if the current block node is active. Helpful in combination with `$is___Node` functions. #### Creating custom block selector items The `isBlockNodeActive` helper is particularly useful for adding custom [`Toolbar.BlockSelector`][] items. ```tsx //+++ import { isBlockNodeActive, Toolbar } from "@liveblocks/react-lexical"; // +++ import { $setBlocksType } from "@lexical/selection"; import { $isCodeNode } from "@lexical/code"; import { $getSelection } from "lexical"; [ ...defaultItems, { name: "Code block", icon:
❮ ❯
, // +++ isActive: (editor) => isBlockNodeActive(editor, $isCodeNode), // +++ setActive: (editor) => $setBlocksType($getSelection(), () => $createCodeNode()), }, ]} />
; ``` ## Stylesheets React Lexical comes with default styles, and these can be imported into the root of your app or directly into a CSS file with `@import`. Note that you must also install and import a stylesheet from [`@liveblocks/react-ui`](/docs/api-reference/liveblocks-react-ui) to use these styles. ```tsx import "@liveblocks/react-ui/styles.css"; import "@liveblocks/react-lexical/styles.css"; ``` ### Customizing your styles Adding dark mode and customizing your styles is part of `@liveblocks/react-ui`, learn how to do this under [styling and customization](/docs/api-reference/liveblocks-react-ui#Styling-and-customization). [`LiveblocksPlugin`]: #LiveblocksPlugin [`LexicalComposer`]: https://lexical.dev/docs/react/plugins [`Thread`]: /docs/api-reference/liveblocks-react-ui#Thread [`Composer`]: /docs/api-reference/liveblocks-react-ui#Composer [`useThreads`]: /docs/api-reference/liveblocks-react#useThreads [`Icon`]: /docs/api-reference/liveblocks-react-ui#Icon [`Toolbar`]: #Toolbar [`Toolbar.Button`]: #Toolbar.Button [`Toolbar.Toggle`]: #Toolbar.Toggle [`Toolbar.BlockSelector`]: #Toolbar.BlockSelector [`Toolbar.Separator`]: #Toolbar.Separator [`Toolbar.SectionHistory`]: #Toolbar.SectionHistory [`Toolbar.SectionInline`]: #Toolbar.SectionInline [`Toolbar.SectionCollaboration`]: #Toolbar.SectionCollaboration [`FloatingToolbar`]: #FloatingToolbar [`FloatingComposer`]: #FloatingComposer [`FloatingThreads`]: #FloatingThreads [`AnchoredThreads`]: #AnchoredThreads [`ClientSideSuspense`]: /docs/api-reference/liveblocks-react#ClientSideSuspense [`useSyncStatus`]: /docs/api-reference/liveblocks-react#useSyncStatus --- meta: title: "@liveblocks/react-tiptap" parentTitle: "API Reference" description: "API Reference for the @liveblocks/react-tiptap package" alwaysShowAllNavigationLevels: false --- `@liveblocks/react-tiptap` provides you with a [React](https://react.dev/) plugin that adds collaboration to any [Tiptap](https://tiptap.dev/) text editor. It also adds realtime cursors, document persistence on the cloud, comments, and mentions. Read our [get started guides](/docs/get-started/text-editor/tiptap) to learn more. Use [`@liveblocks/node-prosemirror`](/docs/api-reference/liveblocks-node-prosemirror) for server-side editing. ## Setup To set up your collaborative Tiptap editor, add [`useLiveblocksExtension`](#useLiveblocksExtension) to your editor, passing the return value `useEditor` extension array. ```tsx import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; function TextEditor() { // +++ const liveblocks = useLiveblocksExtension(); // +++ const editor = useEditor({ extensions: [ // +++ liveblocks, // +++ // ... ], }); return (
); } ``` Liveblocks Tiptap components should be passed `editor` to enable them. ```tsx import { useLiveblocksExtension, // +++ FloatingComposer, // +++ } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; function TextEditor() { const liveblocks = useLiveblocksExtension(); const editor = useEditor({ extensions: [ liveblocks, // ... ], }); return (
// +++ // +++
); } ``` Learn more in our [get started guides](/docs/get-started/text-editor/tiptap). ## Default components ### Toolbar Displays a toolbar, allowing you to change the styles of selected text. You can add content [before or after](#toolbar-extending-the-defaults), or the toolbar’s options can be [customized](#creating-a-custom-toolbar). A [floating toolbar](#FloatingToolbar) also exists. ```tsx ```
Toolbar
Pass your Tiptap `editor` to use the component. By default, one of the toolbar buttons can create comment threads—to enable this add [`FloatingComposer`][] and display threads with [`AnchoredThreads`][] or [`FloatingThreads`][]. ```tsx import { useLiveblocksExtension, // +++ Toolbar, // +++ FloatingComposer, FloatingThreads, } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; function TextEditor() { const liveblocks = useLiveblocksExtension(); const editor = useEditor({ extensions: [ liveblocks, // ... ], }); return (
// +++ // +++
); } ``` #### Extending the defaults [#toolbar-extending-the-defaults] You can insert content `before` the first button and `after` the last button using `before` and `after`. Components such as [`Toolbar.Button`][] and [`Toolbar.Toggle`][] can be used to create new buttons. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; import { Icon } from "@liveblocks/react-ui"; I'm at the start} after={ } shortcut="CMD-H" onClick={() => console.log("help")} /> } // +++ />; ``` For more complex customization, instead read [creating a custom floating toolbar](#creating-a-custom-toolbar). #### Creating a custom toolbar [#creating-a-custom-toolbar] By passing elements as children, it’s possible to create a fully custom toolbar. ```tsx highlight="7" import { Toolbar } from "@liveblocks/react-lexical"; import { Editor } from "@tiptap/react"; function CustomToolbar({ editor }: { editor: Editor | null }) { return ( Hello world ); } ``` Each part of our default toolbar is available as blocks which can be slotted together. This is how the default toolbar is constructed: ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; import { Editor } from "@tiptap/react"; function CustomToolbar({ editor }: { editor: Editor | null }) { return ( // +++ // +++ ); } ``` You can mix these default components with any custom ones of your own. Below the [`Toolbar.SectionHistory`][] component is added alongside some custom buttons created with [`Toolbar.Button`][], [`Toolbar.Toggle`][], and [`Icon`][]. The highlight toggle button requires a [Tiptap extension](https://tiptap.dev/docs/editor/extensions/marks/highlight). ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; import { Icon } from "@liveblocks/react-ui"; import { Editor } from "@tiptap/react"; function CustomToolbar({ editor }: { editor: Editor | null }) { return ( // +++ } shortcut="CMD-H" onClick={() => console.log("help")} /> 🖊️} active={editor?.isActive("highlight") ?? false} onClick={() => editor?.chain().focus().toggleHighlight().run()} disabled={!editor?.can().chain().focus().toggleHighlight().run()} /> // +++ ); } ``` To learn more about the different components, read more below. #### Props [#Toolbar-props] The Tiptap editor. The content of the toolbar, overriding the default content. Use the `before` and `after` props if you want to keep and extend the default content. Any `ReactNode` or `Toolbar.*` components work inside. The content to display at the start of the toolbar. Any `ReactNode` or `Toolbar.*` components work inside. The content to display at the end of the toolbar. Any `ReactNode` or `Toolbar.*` components work inside. #### Toolbar.Button A button for triggering actions. The `name` is displayed in a tooltip. Props such as `onClick` will be passed to the underlying `button` element. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; console.log("Clicked")} /> ; ``` Optionally takes an icon which will visually replace the `name`. Also optionally accepts a shortcut, which is displayed in the tooltip. Comment key names are converted to symbols. Here are various examples. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; import { Icon } from "@liveblocks/react-ui"; // Button says "Question" // Tooltip says "Question [⌘+Q]" // Custom icon, replaces the name in the button ?} onClick={/* ... */} /> // Using a Liveblocks icon, replaces the name in the button } onClick={/* ... */} /> // Passing children visually replaces the `name` and `icon` ? Ask a question // Props are passed to the inner `button` console.log("Hovered")} /> ``` ##### Props [#ToolbarButton-props] The name of this button displayed in its tooltip. Will also be displayed in the button if no `icon` or `children` are passed. An optional icon displayed in this button. An optional keyboard shortcut displayed in this button’s tooltip. Common shortcuts such will be replaced by their symbols, for example `CMD` → `⌘`. #### Toolbar.Toggle A toggle button for values that can be active or inactive. Best used with text editor commands. The `name` is displayed in a tooltip. Props will be passed to the underlying `button` element. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; editor?.chain().focus().toggleHighlight().run()} /> ; ``` The snippet above shows how to use the Toggle with the [Tiptap highlight extension](https://tiptap.dev/docs/editor/extensions/marks/highlight). The toggle button can also be toggled with `useState`. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; import { Editor } from "@tiptap/react"; import { useState } from "react"; function CustomToggle({ editor }: { editor: Editor | null }) { const [active, setActive] = useState(false); return ( setActive(!active)} /> ); } ``` `Toolbar.Toggle` optionally takes an icon which will visually replace the `name`. Also optionally accepts a shortcut, which is displayed in the tooltip. Comment key names are converted to symbols. Here are various examples. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; import { Icon } from "@liveblocks/react-ui"; // Button says "Highlight" // Tooltip says "Highlight [⌘+H]" // Custom icon, replaces the name in the button 🖊} active={/* ... */} onClick={/* ... */} /> // Using a Liveblocks icon, replaces the name in the button } active={/* ... */} onClick={/* ... */} /> // Passing children visually replaces the `name` and `icon` 🖊️Highlight // Props are passed to the inner `button` console.log("Hovered")} /> ``` ##### Props [#ToolbarToggle-props] The name of this button displayed in its tooltip. Will also be displayed in the button if no `icon` or `children` are passed. Whether the button is toggled. An optional icon displayed in this button. An optional keyboard shortcut displayed in this button’s tooltip. Common shortcuts such will be replaced by their symbols, for example `CMD` → `⌘`. #### Toolbar.BlockSelector Adds a dropdown selector for switching between different block types, such as _text_, _heading 1_, _blockquote_. Props will be passed to the inner `button` element. Can also be placed inside [`FloatingToolbar`][]. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; ; ``` ##### Use custom item options If you’d like to change the items shown in the dropdown menu, you can pass a custom `items` array. Below a code block item ([Tiptap extension](https://tiptap.dev/docs/editor/extensions/nodes/code-block)) is added after the default options. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; [ ...defaultItems, { name: "Code block", icon:
❮ ❯
, // Optional isActive: (editor) => editor.isActive("codeBlock"), setActive: (editor) => editor.chain().focus().clearNodes().toggleCodeBlock().run(), }, ]} />
; ``` ##### Customize item styles By passing a `label` property, you can overwrite the styles of the dropdown items. The toolbar button will still display the `name`, but in the dropdown, the `label` will be used instead of the `name` and `icon`. Below, a new item is added and its `label` is customized. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; [ ...defaultItems, { name: "Code block", // +++ label:
Code
, // Optional, overwrites `icon` + `name` // +++ isActive: (editor) => editor.isActive("codeBlock"), setActive: (editor) => editor.chain().focus().clearNodes().toggleCodeBlock().run(), }, ]} />
; ``` You can also customize the default items. Below each item is styled to represent the effect each block applies to the document. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; defaultItems.map((item) => { let label; if (item.name === "Text") { label = Regular text; } if (item.name === "Heading 1") { label = ( Heading 1 ); } if (item.name === "Heading 2") { label = ( Heading 2 ); } if (item.name === "Heading 3") { label = ( Heading 3 ); } if (item.name === "Blockquote") { label = ( Blockquote ); } return { ...item, label, icon: null, // Hide all icons }; }) } />; ``` ##### Props [#ToolbarBlockSelector-props] The items displayed in this block selector. When provided as an array, the default items are overridden. To avoid this, a function can be provided instead and it will receive the default items. #### Toolbar.Separator Adds a visual, and accessible, separator used to separate sections in the toolbar. Props will be passed to the inner `div` element. Can also be placed inside [`FloatingToolbar`][]. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; // +++ // +++ ; ``` #### Toolbar.SectionHistory Adds a section containing _undo_ and _redo_ buttons. Can also be placed inside [`FloatingToolbar`][]. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; // +++ // +++ ; ``` #### Toolbar.SectionInline Adds a section containing inline formatting actions such as _bold_, _italic_, _underline_. Can also be placed inside [`FloatingToolbar`][]. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; // +++ // +++ ; ``` #### Toolbar.SectionCollaboration Adds a section containing an _add comment_ button. Can also be placed inside [`FloatingToolbar`][]. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; // +++ // +++ ; ``` ### FloatingToolbar Displays a floating toolbar near the current Tiptap selection, allowing you to change styles. You can add content [before or after](#floating-toolbar-extending-the-defaults), or the toolbar’s options can be [customized](#creating-a-custom-floating-toolbar). A [static toolbar](#Toolbar) also exists. ```tsx ```
FloatingToolbar
Pass your Tiptap `editor` to use the component. By default, one of the toolbar buttons can create comment threads—to enable this add [`FloatingComposer`][] and display threads with [`AnchoredThreads`][] or [`FloatingThreads`][]. ```tsx import { useLiveblocksExtension, // +++ FloatingToolbar, // +++ FloatingComposer, FloatingThreads, } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; function TextEditor() { const liveblocks = useLiveblocksExtension(); const editor = useEditor({ extensions: [ liveblocks, // ... ], }); return (
// +++ // +++
); } ``` #### Changing float position Using `position` and `offset` you can reposition the toolbar relative to the current selection. `position` can be set to `"top"` or `"bottom"`, and `offset` defines the vertical distance in pixels from the selection. ```tsx ``` #### Extending the defaults [#floating-toolbar-extending-the-default] You can insert custom content `before` the first button and `after` the last button using `before` and `after`. Components such as [`Toolbar.Button`][] and [`Toolbar.Toggle`][] can be used to create new buttons. ```tsx import { Toolbar } from "@liveblocks/react-tiptap"; import { Icon } from "@liveblocks/react-ui"; I'm at the start} after={ } shortcut="CMD-H" onClick={() => console.log("help")} /> } // +++ />; ``` For more complex customization, instead read [creating a custom floating toolbar](#creating-a-custom-floating-toolbar). #### Creating a custom floating toolbar [#creating-a-custom-floating-toolbar] By passing elements as children, it’s possible to create a fully custom floating toolbar. ```tsx highlight="7" import { FloatingToolbar } from "@liveblocks/react-tiptap"; import { Editor } from "@tiptap/react"; function CustomToolbar({ editor }: { editor: Editor | null }) { return ( Hello world ); } ``` Each part of our default toolbar is available as blocks which can be slotted together. This is how the default floating toolbar is constructed: ```tsx import { FloatingToolbar, Toolbar } from "@liveblocks/react-tiptap"; import { Editor } from "@tiptap/react"; function CustomToolbar({ editor }: { editor: Editor | null }) { return ( // +++ // +++ ); } ``` You can mix these default components with any custom ones of your own. Below the [`Toolbar.SectionHistory`][] component is added alongside some custom buttons created with [`Toolbar.Button`][], [`Toolbar.Toggle`][], and [`Icon`][]. The highlight toggle button requires a [Tiptap extension](https://tiptap.dev/docs/editor/extensions/marks/highlight). ```tsx import { FloatingToolbar, Toolbar } from "@liveblocks/react-tiptap"; import { Icon } from "@liveblocks/react-ui"; import { Editor } from "@tiptap/react"; function CustomToolbar({ editor }: { editor: Editor | null }) { return ( // +++ } shortcut="CMD-H" onClick={() => console.log("help")} /> 🖊️} active={editor?.isActive("highlight") ?? false} onClick={() => editor?.chain().focus().toggleHighlight().run()} disabled={!editor?.can().chain().focus().toggleHighlight().run()} /> // +++ ); } ``` To learn more about the different components, read more under [`Toolbar`][]. #### Props [#FloatingToolbar-props] The Tiptap editor. The vertical position of the floating toolbar. The vertical offset of the floating toolbar from the selection. The content of the toolbar, overriding the default content. Use the `before` and `after` props if you want to keep and extend the default content. Any `ReactNode` or `Toolbar.*` components work inside. The content to display at the start of the toolbar. Any `ReactNode` or `Toolbar.*` components work inside. The content to display at the end of the toolbar. Any `ReactNode` or `Toolbar.*` components work inside. ### FloatingComposer Displays a [`Composer`][] near the current Tiptap selection, allowing you to create threads. ```tsx highlight="3" ```
FloatingComposer
Submitting a comment will attach an annotation thread at the current selection. Should be passed your Tiptap `editor`, and it’s recommended you set a width value. Display created threads with [`AnchoredThreads`][] or [`FloatingThreads`][]. ```tsx import { useLiveblocksExtension, // +++ FloatingComposer, // +++ FloatingThreads, } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; function TextEditor() { const liveblocks = useLiveblocksExtension(); const editor = useEditor({ extensions: [ liveblocks, // ... ], }); return (
// +++ // +++
); } ``` #### Opening the composer To open the `FloatingComposer`, you need to call the `addPendingComment` [command](https://tiptap.dev/docs/editor/api/commands) added by Liveblocks. You can use `liveblocksCommentMark` to check if the current selection is a comment. ```tsx import { Editor } from "@tiptap/react"; function Toolbar({ editor }: { editor: Editor | null }) { if (!editor) { return null; } return ( ); } ``` #### Props [#FloatingComposer-props] The metadata of the thread to create. The event handler called when the composer is submitted. The composer’s initial value. Whether the composer is collapsed. Setting a value will make the composer controlled. The event handler called when the collapsed state of the composer changes. Whether the composer is initially collapsed. Setting a value will make the composer uncontrolled. Whether the composer is disabled. Whether to focus the composer on mount. Override the component’s strings. ### FloatingThreads Displays floating [`Thread`][] components below text highlights in the editor. ```tsx highlight="3" ```
FloatingThreads
Takes a list of threads retrieved from [`useThreads`][] and renders them to the page. Each thread is opened by clicking on its corresponding text highlight. Should be passed your Tiptap `editor`, and it’s recommended you set a width value. ```tsx // +++ import { useThreads } from "@liveblocks/react/suspense"; // +++ import { useLiveblocksExtension, FloatingComposer, // +++ FloatingThreads, // +++ } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; function TextEditor() { const liveblocks = useLiveblocksExtension(); const editor = useEditor({ extensions: [ liveblocks, // ... ], }); // +++ const { threads } = useThreads(); // +++ return (
// +++ // +++
); } ``` The `FloatingThreads` component automatically excludes resolved threads from display. Any resolved threads passed in the threads list will not be shown. #### Recommended usage [#FloatingThreads-recommended-usage] [`FloatingThreads`][] and [`AnchoredThreads`][] have been designed to work together to provide the optimal experience on mobile and desktop. We generally recommend using both components, hiding one on smaller screens, as we are below with Tailwind classes. Most apps also don’t need to display resolved threads, so we can filter those out with a [`useThreads`][] option. ```tsx import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingThreads } from "@liveblocks/react-tiptap"; import { Editor } from "@tiptap/react"; function ThreadOverlay({ editor }: { editor: Editor | null }) { const { threads } = useThreads({ query: { resolved: false } }); return ( <> ); } ``` ```tsx title="Alternatively use a media query hook" isCollapsed isCollapsable import { useSyncExternalStore } from "react"; import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingThreads } from "@liveblocks/react-tiptap"; import { Editor } from "@tiptap/react"; function ThreadOverlay({ editor }: { editor: Editor | null }) { const { threads } = useThreads({ query: { resolved: false } }); // +++ const isMobile = useIsMobile(); // +++ // +++ if (isMobile) { return ( ); } // +++ // +++ return ( ); //+++ } export function useIsMobile() { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } function subscribe(callback: () => void) { const query = window.matchMedia("(max-width: 1024px)"); query.addEventListener("change", callback); return () => query.removeEventListener("change", callback); } function getSnapshot() { const query = window.matchMedia("(max-width: 1024px)"); return query.matches; } ``` We can place this component inside [`ClientSideSuspense`][] to prevent it rendering until threads have loaded. ```tsx
// +++ // +++
``` #### Customization [#FloatingThreads-customization] The `FloatingThreads` component acts as a wrapper around each individual [`Thread`][]. You can treat the component like you would a `div`, using classes, listeners, and more. ```tsx ``` To apply styling to each [`Thread`][], you can pass a custom `Thread` property to `components` and modify this in any way. This is the best way to modify a thread’s width. ```tsx import { Thread } from "@liveblocks/react-ui"; ( ), }} // +++ />; ``` You can return any custom `ReactNode` here, including anything from a simple wrapper around `Thread`, up to a full custom `Thread` component built using our [Comment primitives](/docs/api-reference/liveblocks-react-ui#primitives-Comment). ```tsx import { Comment } from "@liveblocks/react-ui/primitives"; ( // +++
{props.thread.comments.map((comment) => ( ))}
// +++ ), }} />; ``` #### Props [#FloatingThreads-props] The threads to display. Override the component’s components. Override the [`Thread`](/docs/api-reference/liveblocks-react-ui#Thread) component. ### AnchoredThreads Displays a list of [`Thread`][] components vertically alongside the editor. ```tsx highlight="3" ```
AnchoredThreads
Takes a list of threads retrieved from [`useThreads`][] and renders them to the page. Each thread is displayed at the same vertical coordinates as its corresponding text highlight. If multiple highlights are in the same location, each thread is placed in order below the previous thread. ```tsx // +++ import { useThreads } from "@liveblocks/react/suspense"; // +++ import { useLiveblocksExtension, FloatingComposer, // +++ AnchoredThreads, // +++ } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; function TextEditor() { const liveblocks = useLiveblocksExtension(); const editor = useEditor({ extensions: [ liveblocks, // ... ], }); // +++ const { threads } = useThreads(); // +++ return (
// +++ // +++
); } ``` The `AnchoredThreads` component automatically excludes resolved threads from display. Any resolved threads passed in the threads list will not be shown. #### Recommended usage [#AnchoredThreads-recommended-usage] [`FloatingThreads`][] and [`AnchoredThreads`][] have been designed to work together to provide the optimal experience on mobile and desktop. We generally recommend using both components, hiding one on smaller screens, as we are below with Tailwind classes. Most apps also don’t need to display resolved threads, so we can filter those out with a [`useThreads`][] option. ```tsx import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingThreads } from "@liveblocks/react-tiptap"; import { Editor } from "@tiptap/react"; function ThreadOverlay({ editor }: { editor: Editor | null }) { const { threads } = useThreads({ query: { resolved: false } }); return ( <> ); } ``` ```tsx title="Alternatively use a media query hook" isCollapsed isCollapsable import { useSyncExternalStore } from "react"; import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingThreads } from "@liveblocks/react-tiptap"; import { Editor } from "@tiptap/react"; function ThreadOverlay({ editor }: { editor: Editor | null }) { const { threads } = useThreads({ query: { resolved: false } }); // +++ const isMobile = useIsMobile(); // +++ // +++ if (isMobile) { return ( ); } // +++ // +++ return ( ); //+++ } export function useIsMobile() { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } function subscribe(callback: () => void) { const query = window.matchMedia("(max-width: 1024px)"); query.addEventListener("change", callback); return () => query.removeEventListener("change", callback); } function getSnapshot() { const query = window.matchMedia("(max-width: 1024px)"); return query.matches; } ``` We can place this component inside [`ClientSideSuspense`][] to prevent it rendering until threads have loaded. ```tsx
// +++ // +++
``` #### Customization [#AnchoredThreads-customization] The `AnchoredThreads` component acts as a wrapper around each [`Thread`][]. It has no width, so setting this is required, and each thread will take on the width of the wrapper. You can treat the component like you would a `div`, using classes, listeners, and more. ```tsx ``` To apply styling to each [`Thread`][], you can pass a custom `Thread` property to `components` and modify this in any way. ```tsx import { Thread } from "@liveblocks/react-ui"; ( ), }} // +++ />; ``` You can return any custom `ReactNode` here, including anything from a simple wrapper around `Thread`, up to a full custom `Thread` component built using our [Comment primitives](/docs/api-reference/liveblocks-react-ui#primitives-Comment). ```tsx import { Comment } from "@liveblocks/react-ui/primitives"; ( // +++
{props.thread.comments.map((comment) => ( ))}
// +++ ), }} />; ``` ##### Modifying thread floating positions Using CSS variables you can modify the gap between threads, and the horizontal offset that’s added when a thread is selected. ```css .lb-tiptap-anchored-threads { /* Minimum gap between threads */ --lb-tiptap-anchored-threads-gap: 8px; /* How far the active thread is offset to the left */ --lb-tiptap-anchored-threads-active-thread-offset: 12px; } ``` #### Props [#AnchoredThreads-props] The threads to display. Override the component’s components. Override the [`Thread`](/docs/api-reference/liveblocks-react-ui#Thread) component. ### HistoryVersionPreview [@badge=beta] Version history is currently in private beta. If you would like access to the beta, please [contact us](https://liveblocks.io/contact/sales). We’d love to hear from you. The `HistoryVersionPreview` component allows you to display a preview of a specific version of your Tiptap editor's content. It also contains a button and logic for restoring. It must be used inside the `` context. To render a list of versions, see [`VersionHistory`](/docs/api-reference/liveblocks-react-ui#Version-History). #### Usage [#HistoryVersionPreview-usage] ```tsx import { HistoryVersionPreview } from "@liveblocks/react-tiptap"; function VersionPreview({ selectedVersion, onVersionRestore }) { return ( ); } ``` #### Props [#HistoryVersionPreview-props] The version of the editor content to preview. Callback function called when the user chooses to restore this version. The `HistoryVersionPreview` component renders a read-only view of the specified version of the editor content. It also provides a button for users to restore the displayed version. ### AiToolbar [@badge=beta] AI Copilots is currently in private beta. If you would like access to the beta, please [contact us](https://liveblocks.io/contact/sales). We’d love to hear from you. Displays a floating AI toolbar near the current Tiptap selection, allowing you to use AI to apply changes to the document or ask questions about it. ```tsx ``` Pass your Tiptap `editor` to use the component, and enable (or customize) the AI option in [`useLiveblocksExtension`][]. ```tsx import { useLiveblocksExtension, // +++ AiToolbar, // +++ FloatingToolbar, } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; function TextEditor() { const liveblocks = useLiveblocksExtension({ // +++ ai: true, // +++ }); const editor = useEditor({ extensions: [ liveblocks, // ... ], }); return (
// +++ // +++
); } ``` #### Opening the toolbar [#AiToolbar-opening] To open the `AiToolbar`, you need to call the `askAi` [command](https://tiptap.dev/docs/editor/api/commands) added by Liveblocks. ```tsx import { Editor } from "@tiptap/react"; function Toolbar({ editor }: { editor: Editor | null }) { if (!editor) { return null; } return ( ); } ``` You can also pass a prompt to the `askAi` command and it will directly request it when opening the toolbar. ```tsx ``` #### Customizing suggestions [#AiToolbar-suggestions] By default, the AI toolbar displays a list of suggestions (e.g. “Fix mistakes”, “Explain”, etc). These can be customized via the `suggestions` prop and the [`AiToolbar.Suggestion`][] component. ```tsx Suggested Fix mistakes Emojify }> Continue writing } // +++ /> ``` Doing so will override the default suggestions, instead you can use a function to keep them while adding your own. ```tsx ( <> {children} // +++ Custom Custom suggestion )} /> ``` #### Props [#AiToolbar-props] The Tiptap editor. The vertical offset of the AI toolbar from the selection. The prompt suggestions to display below the AI toolbar. #### AiToolbar.Suggestion A prompt suggestion displayed below the AI toolbar. ```tsx import { AiToolbar } from "@liveblocks/react-tiptap"; // +++ Fix mistakes // +++ } />; ``` By default, selecting a suggestion will use its label from the `children` as the prompt, this can be overridden with the `prompt` prop. Also optionally takes an icon. ```tsx import { AiToolbar } from "@liveblocks/react-tiptap"; import { Icon } from "@liveblocks/react-ui"; // "Translate to French" is displayed in the suggestion and used as the prompt Translate to French // "Emojify" is displayed in the suggestion but "Add emojis to the text" is used as the prompt Emojify // Custom icon ?}>Explain // Using a Liveblocks icon }>Explain ``` ##### Props [#AiToolbarSuggestion-props] The suggestion’s label, used as the prompt if the `prompt` prop is not set. An optional icon displayed before the label. The prompt to use instead of the label. #### AiToolbar.SuggestionsLabel A label to describe a group of prompt suggestions displayed in the AI toolbar. ```tsx import { AiToolbar } from "@liveblocks/react-tiptap"; // +++ Translation // +++ Translate in French Translate in English } />; ``` #### AiToolbar.SuggestionsSeparator A separator between groups of prompt suggestions displayed in the AI toolbar. ```tsx import { AiToolbar } from "@liveblocks/react-tiptap"; Translate in French Translate in English // +++ // +++ Custom suggestion } />; ``` ## Hooks ### useLiveblocksExtension Liveblocks plugin for Tiptap that adds collaboration to your editor. `liveblocks` should be passed to Tiptap’s `useEditor` as an extension. ```tsx import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; function TextEditor() { // +++ const liveblocks = useLiveblocksExtension(); // +++ const editor = useEditor({ extensions: [ // +++ liveblocks, // +++ // ... ], }); return (
); } ``` The initial content for the editor, if it’s never been set. [Learn more](#Setting-initial-content). The name of this text editor’s field. Allows you to use multiple editors on one page, if each has a separate field value. [Learn more](#Multiple-editors). Experimental. Enable offline support using IndexedDB. This means the after the first load, documents will be stored locally and load instantly. [Learn more](#Offline-support). Enable comments in the editor. Enable mentions in the editor. Enable AI in the editor and optionally customize configuration options. #### Setting initial content Initial content for the editor can be set with `initialContent`. This content will only be used if the current editor has never been edited by any users, and is ignored otherwise. ```tsx import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; function TextEditor() { const liveblocks = useLiveblocksExtension({ // +++ initialContent: "

Hello world

", // +++ }); // ... } ``` #### Multiple editors It’s possible to use multiple editors on one page by passing values to the `field` property. Think of it like an ID for the current editor. ```tsx import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; function TextEditor() { const liveblocks = useLiveblocksExtension({ // +++ field: "editor-one", // +++ }); // ... } ``` Here’s an example of how multiple editors may be set up. ```tsx import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; function TextEditors() { return (
// +++ // +++
); } function TextEditor({ field }: { field: string }) { // +++ const liveblocks = useLiveblocksExtension({ field }); // +++ const editor = useEditor({ extensions: [ liveblocks, // ... ], }); return (
); } ``` #### Offline support [@badge=experimental] It’s possible to enable offline support in your editor with an experimental option. This means that once a document has been opened, it’s saved locally on the browser, and can be shown instantly without a loading screen. As soon as Liveblocks connects, any remote changes will be synchronized, without any load spinner. Enable this by passing a `offlineSupport_experimental ` value. ```tsx import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; function TextEditor() { const liveblocks = useLiveblocksExtension({ offlineSupport_experimental: true, }); // ... } ``` To make sure that your editor loads instantly, you must structure your app carefully to avoid any Liveblocks hooks and [`ClientSideSuspense`][] components from triggering a loading screen. For example, if you’re displaying threads in your editor with [`useThreads`][], you must place this inside a separate component and wrap it in [`ClientSideSuspense`][]. ```tsx "use client"; import { ClientSideSuspense, useThreads } from "@liveblocks/react/suspense"; import { useLiveblocksExtension, AnchoredThreads, FloatingComposer, } from "@liveblocks/react-tiptap"; import { Editor, EditorContent, useEditor } from "@tiptap/react"; export function TiptapEditor() { const liveblocks = useLiveblocksExtension({ offlineSupport_experimental: true, }); const editor = useEditor({ extensions: [ liveblocks, // ... ], immediatelyRender: false, }); return ( <> // +++ // +++ ); } // +++ function Threads({ editor }: { editor: Editor }) { const { threads } = useThreads(); return ; } // +++ ``` #### Customizing AI components [@badge=beta] AI Copilots is currently in private beta. If you would like access to the beta, please [contact us](https://liveblocks.io/contact/sales). We’d love to hear from you. By default, AI components like [`AiToolbar`][] use the term `"AI"`. This can be customized with the `ai.name` option in [`useLiveblocksExtension`][]. This value will be used throughout the AI components: `"Ask {name}"` in the [`Toolbar`][]/ [`FloatingToolbar`][] default buttons, `"{name} is thinking…"` in the [`AiToolbar`][], etc. ```tsx import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; import { useEditor } from "@tiptap/react"; function TextEditor() { const liveblocks = useLiveblocksExtension({ ai: { // +++ // "Ask Liveblocks anything…", "Liveblocks is thinking…", etc name: "Liveblocks", // +++ }, }); const editor = useEditor({ extensions: [ liveblocks, // ... ], }); // ... } ``` If you’re after visual customization, AI components like [`AiToolbar`][] integrate with the rest of Liveblocks' styles, heavily using tokens like `--lb-accent` for example. Learn more about [styling](#Stylesheets). #### Generating AI toolbar responses [@badge=beta] AI Copilots is currently in private beta. If you would like access to the beta, please [contact us](https://liveblocks.io/contact/sales). We’d love to hear from you. By default, the [`AiToolbar`][] component sends its requests to Liveblocks to generate its responses. This can be customized via the `ai.resolveContextualPrompt` option in [`useLiveblocksExtension`][]. This option accepts an async function which will be called by the AI toolbar whenever a prompt is requested, it will receive the prompt and some context (the document’s and selection’s text, the previous request if it’s a follow-up, etc) and is expected to return the type of response and the text to use. ```tsx import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; import { useEditor } from "@tiptap/react"; function TextEditor() { const liveblocks = useLiveblocksExtension({ ai: { // +++ resolveContextualPrompt: async ({ prompt, context, signal }) => { const response = await fetch("/api/contextual-prompt", { method: "POST", body: JSON.stringify({ prompt, context }), signal, }); return response.json(); }, // +++ }, }); const editor = useEditor({ extensions: [ liveblocks, // ... ], }); // ... } ``` ### useIsEditorReady Used to check if the editor content has been loaded or not, helpful for displaying a loading skeleton. ```ts import { useIsEditorReady } from "@liveblocks/react-tiptap"; const status = useIsEditorReady(); ``` Here’s how it can be used in the context of your editor. ```tsx import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; // +++ import { useIsEditorReady, useEditor, EditorContent } from "@tiptap/react"; // +++ function TextEditor() { const liveblocks = useLiveblocksExtension(); // +++ const ready = useIsEditorReady(); // +++ const editor = useEditor({ extensions: [ liveblocks, // ... ], }); return (
// +++ {!ready ?
Loading...
: } // +++
); } ``` ## Stylesheets React Tiptap comes with default styles, and these can be imported into the root of your app or directly into a CSS file with `@import`. Note that you must also install and import a stylesheet from [`@liveblocks/react-ui`](/docs/api-reference/liveblocks-react-ui) to use these styles. ```tsx import "@liveblocks/react-ui/styles.css"; import "@liveblocks/react-tiptap/styles.css"; ``` ### Customizing your styles Adding dark mode and customizing your styles is part of `@liveblocks/react-ui`, learn how to do this under [styling and customization](/docs/api-reference/liveblocks-react-ui#Styling-and-customization). [`LiveblocksPlugin`]: #LiveblocksPlugin [`useLiveblocksExtension`]: #useLiveblocksExtension [`Thread`]: /docs/api-reference/liveblocks-react-ui#Thread [`Composer`]: /docs/api-reference/liveblocks-react-ui#Composer [`useThreads`]: /docs/api-reference/liveblocks-react#useThreads [`Icon`]: /docs/api-reference/liveblocks-react-ui#Icon [`Toolbar`]: #Toolbar [`Toolbar.Button`]: #Toolbar.Button [`Toolbar.Toggle`]: #Toolbar.Toggle [`Toolbar.BlockSelector`]: #Toolbar.BlockSelector [`Toolbar.Separator`]: #Toolbar.Separator [`Toolbar.SectionHistory`]: #Toolbar.SectionHistory [`Toolbar.SectionInline`]: #Toolbar.SectionInline [`Toolbar.SectionCollaboration`]: #Toolbar.SectionCollaboration [`FloatingToolbar`]: #FloatingToolbar [`FloatingComposer`]: #FloatingComposer [`FloatingThreads`]: #FloatingThreads [`AnchoredThreads`]: #AnchoredThreads [`AiToolbar`]: #AiToolbar [`AiToolbar.Suggestion`]: #AiToolbar.Suggestion [`AiToolbar.SuggestionsLabel`]: #AiToolbar.SuggestionsLabel [`AiToolbar.SuggestionsSeparator`]: #AiToolbar.SuggestionsSeparator [`ClientSideSuspense`]: /docs/api-reference/liveblocks-react#ClientSideSuspense --- meta: title: "@liveblocks/react-ui" parentTitle: "API Reference" description: "API Reference for the @liveblocks/react-ui package" alwaysShowAllNavigationLevels: false --- `@liveblocks/react-ui` provides you with [React](https://react.dev/) components to build collaborative experiences. Read our [Comments](/docs/get-started/comments) and [Notifications](/docs/get-started/notifications) get started guides to learn more. ## Comments ### Default components [#Components] #### Thread Displays a thread of comments. Each thread has a composer for creating replies. ```tsx ```
Thread
Map through `threads` to render a list of the room’s threads and comments. Threads can be retrieved with [`useThreads`](/docs/api-reference/liveblocks-react#useThreads). ```tsx highlight="10" import { Thread } from "@liveblocks/react-ui"; import { useThreads } from "@liveblocks/react/suspense"; function Component() { const { threads } = useThreads(); return ( <> {threads.map((thread) => ( ))} ); } ``` ##### Resolved and unresolved threads A thread can be marked as resolved or unresolved via its `resolved` property. The `Thread` component automatically handles this through its `resolved` toggle button displayed by default. You can additionally use `thread.resolved` to filter the displayed threads for example. Or if you want to create your own `Thread` component using [the primitives](/docs/ready-made-features/comments/primitives), you can use [`useMarkThreadAsResolved`](/docs/api-reference/liveblocks-react#useMarkThreadAsResolved) and [`useMarkThreadAsUnresolved`](/docs/api-reference/liveblocks-react#useMarkThreadAsUnresolved) to update the property. Before 2.2, `resolved` was an optional property in a thread’s metadata, it’s now a first-class citizen. If you’re upgrading from a previous version, learn more about this change in our [Upgrade Guide for 2.2](https://liveblocks.io/docs/platform/upgrading/2.2). ##### Props [#Thread-props] The thread to display. How to show or hide the composer to reply to the thread. How to show or hide the actions. Whether to show reactions. Whether to show attachments. Whether to show the composer’s formatting controls. Whether to show the action to resolve the thread. Whether to indent the comments’ content. Whether to show deleted comments. The event handler called when the composer is submitted. The event handler called when changing the resolved status. The event handler called when the thread is deleted. A thread is deleted when all its comments are deleted. The event handler called when a comment is edited. The event handler called when a comment is deleted. The event handler called when clicking on a comment’s author. The event handler called when clicking on a mention. The event handler called when clicking on a comment’s attachment. Override the component’s strings. {/* TODO: Document classes and data attributes */} #### Composer Displays a composer for creating threads or comments. ```tsx ```
Composer
By default, submitting the composer will create a new thread. ```tsx import { Composer } from "@liveblocks/react-ui"; // Creates a new thread function Component() { return ; } ``` ##### Adding thread metadata If you’d like to attach custom metadata to the newly created thread, you can add a `metadata` prop. ```tsx import { Composer } from "@liveblocks/react-ui"; // Creates a new thread with custom metadata function Component() { return ( ); } ``` ###### Typed metadata You can use TypeScript to type your custom metadata by editing your config file. Metadata properties can be `string`, `number`, or `boolean`. ```ts file="liveblocks.config.ts" declare global { interface Liveblocks { // Set your custom metadata types // +++ ThreadMetadata: { // Example types, e.g. colors, coordinates color: string; x: number; y: number; }; // +++ // Other types // ... } } ``` ##### Replying to a thread If you provide a `threadId`, then submitting the composer will add a new reply to the thread. ```tsx import { Composer } from "@liveblocks/react-ui"; // Adds a new comment to a thread function Component({ threadId }) { return ; } ``` ##### Modifying a comment If you provide both a `threadId` and a `commentId`, then submitting the composer will edit the comment. ```tsx import { Composer } from "@liveblocks/react-ui"; // Edits an existing comment function Component({ threadId, commentId }) { return ; } ``` ##### Custom behavior If you’d like to customize submission behavior, you can use `event.preventDefault()` in `onComposerSubmit` to disable the default behavior and call comment and thread mutation methods manually. ```tsx import { Composer } from "@liveblocks/react-ui"; import { useEditComment, useAddReaction } from "@liveblocks/react/suspense"; // Custom submission behavior (edits a comment and adds a reaction) function Component({ threadId, commentId }) { const editComment = useEditComment(); const addReaction = useAddReaction(); return ( { event.preventDefault(); // Example mutations editComment({ threadId, commentId, body, attachments }); addReaction({ threadId, commentId, emoji: "✅" }); // Other custom behavior // ... }} /> ); } ``` Learn more about mutation hooks under [`@liveblocks/react`](/docs/api-reference/liveblocks-react#Comments). ##### Props [#Composer-props] The ID of the thread to reply to or to edit a comment in. The ID of the comment to edit. The metadata of the thread to create. The event handler called when the composer is submitted. The composer’s initial value. The composer’s initial attachments. Whether the composer is collapsed. Setting a value will make the composer controlled. The event handler called when the collapsed state of the composer changes. Whether to show and allow adding attachments. Whether to show formatting controls (e.g. a floating toolbar with formatting toggles when selecting text) Whether the composer is initially collapsed. Setting a value will make the composer uncontrolled. Whether the composer is disabled. Whether to focus the composer on mount. Override the component’s strings. {/* TODO: Document classes and data attributes */} #### Comment Displays a single comment. ```tsx ```
Comment
Map through `thread.comments` to render each comment in a thread. Threads can be retrieved with [`useThreads`](/docs/api-reference/liveblocks-react#useThreads). ```tsx highlight="9" import { Comment } from "@liveblocks/react-ui"; import { ThreadData } from "@liveblocks/client"; // Renders a list of comments attach to the specified `thread` function Component({ thread }: { thread: ThreadData }) { return ( <> {thread.comments.map((comment) => ( ))} ); } ``` ##### Custom thread components [`Comment`](#Comment) can be used in combination with [`Composer`](#Composer) to create a custom thread component. The composer in this example is used to [reply to the existing thread](/docs/api-reference/liveblocks-react-ui#Replying-to-a-thread). ```tsx highlight="10" import { Comment, Composer } from "@liveblocks/react-ui"; import { ThreadData } from "@liveblocks/client"; import { useThreads } from "@liveblocks/react/suspense"; // Renders a list of comments and a composer for adding new comments function CustomThread({ thread }: { thread: ThreadData }) { return ( <> {thread.comments.map((comment) => ( ))} ); } // Renders a list of custom thread components function Component() { const { threads } = useThreads(); return ( <> {threads.map((thread) => ( ))} ); } ``` ##### Props [#Comment-props] The comment to display. How to show or hide the actions. Whether to show reactions. Whether to show attachments. Whether to show the composer’s formatting controls when editing the comment. Whether to indent the comment’s content. Whether to show the comment if it was deleted. If set to `false`, it will render deleted comments as `null`. The event handler called when the comment is edited. The event handler called when the comment is deleted. The event handler called when clicking on the author. The event handler called when clicking on a mention. The event handler called when clicking on a comment’s attachment. Override the component’s strings. {/* TODO: Document classes and data attributes */} ### Primitives Primitives are unstyled, headless components that can be used to create fully custom commenting experiences. If you run into the `Cannot find module '@liveblocks/react-ui/primitives' or its corresponding type declarations` error, you should update your `tsconfig.json`’s `moduleResolution` [property](https://www.typescriptlang.org/tsconfig#moduleResolution) to `"node16"` or `"nodenext"` (or `"bundler"` if you’re on TS >=5). #### Composition All primitives are composable; they forward their props and refs, merge their classes and styles, and chain their event handlers. Inspired by [Radix](https://www.radix-ui.com/) (and powered by its [`Slot`](https://www.radix-ui.com/primitives/docs/utilities/slot) utility), most of the primitives also support an `asChild` prop to replace the rendered element by any provided child, and both set of props will be merged. ```tsx import { Button } from "@/my-design-system"; // Use the default ; ``` Learn more about this concept on [Radix’s composition guide](https://www.radix-ui.com/primitives/docs/guides/composition). #### Composer [#primitives-Composer] Used to render a composer for creating, or editing, threads and comments. ```tsx , MentionSuggestions: () => ( ), Link: () => , }} /> ``` Combine with [`useCreateThread`](/docs/api-reference/liveblocks-react#useCreateThread) to render a composer that creates threads. ```tsx import { Composer, CommentBodyLinkProps, CommentBodyMentionProps, ComposerEditorMentionSuggestionsProps, ComposerSubmitComment, } from "@liveblocks/react-ui/primitives"; import { useCreateThread, useUser } from "@liveblocks/react/suspense"; import { FormEvent } from "react"; // Render a custom composer that creates a thread on submit function MyComposer() { // +++ const createThread = useCreateThread(); // +++ function handleComposerSubmit( { body, attachments }: ComposerSubmitComment, event: FormEvent ) { event.preventdefault(); // Create a new thread // +++ const thread = createThread({ body, attachments, metadata: {}, }); //+++ } return ( // +++ Create thread // +++ ); } // Render a mention in the composer's editor, e.g. "@Emil Joyce" function Mention({ userId }: CommentBodyMentionProps) { return @{userId}; } // Render a list of mention suggestions, used after typing "@" in the editor function MentionSuggestions({ userIds, selectedUserId, }: ComposerEditorMentionSuggestionsProps) { return ( {userIds.map((userId) => ( ))} ); } // Render a single mention suggestion from a `userId` function MentionSuggestion({ userId }: { userId: string }) { const { user } = useUser(userId); return ( {user.name} {user.name} ); } // Render a link in the composer's editor, e.g. "https://liveblocks.io" function Link({ href, children }: CommentBodyLinkProps) { return {children}; } ``` ##### Composer.Form [#primitives-Composer.Form] Surrounds the composer’s content and handles submissions. By default, no action occurs when the composer is submitted. You must create your own mutations within `onComposerSubmit` for [creating threads](/docs/api-reference/liveblocks-react#useCreateThread), [creating comments](/docs/api-reference/liveblocks-react#useCreateComment), [editing comments](/docs/api-reference/liveblocks-react#useEditComment), etc. ```tsx { // Mutate your comments // ... }} > {/* ... */} ``` The composer’s initial attachments. Whether to create attachments when pasting files into the editor. When `preventUnsavedChanges` is set on your [Liveblocks client](/docs/api-reference/liveblocks-client#prevent-users-losing-unsaved-changes) on [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#prevent-users-losing-unsaved-changes), then closing a browser tab will be prevented when there are unsaved changes. By default, that will include draft text or attachments that are being uploaded via this composer, but not submitted yet. If you want to prevent unsaved changes with Liveblocks, but not for this composer, you can opt-out this composer instance by setting this prop to `false`. The event handler called when the form is submitted. Whether the composer is disabled. Replace the rendered element by the one passed as a child. ##### Composer.Editor [#primitives-Composer.Editor] Displays the composer’s editor. ```tsx ``` The editor’s initial value. The text to display when the editor is empty. Whether the editor is disabled. Whether to focus the editor on mount. The reading direction of the editor and related elements. The components displayed within the editor. | Attribute | Value | | --------------- | --------------------------------------- | | `data-focused` | Present when the component is focused. | | `data-disabled` | Present when the component is disabled. |
###### components [#primitives-Composer.Editor-components] The components displayed within the editor. The component used to display mentions. Defaults to the mention’s `userId` prefixed by an @. The component used to display mention suggestions. Defaults to a list of the suggestions’ `userId`. The component used to display links. Defaults to the link’s `children` property. The component used to display a floating toolbar attached to the selection. ###### Mention [#primitives-Composer.Editor-Mention] The component used to display mentions. ```tsx ( @{userId} ), }} /> ``` The mention’s user ID. Whether the mention is selected. ###### MentionSuggestions [#primitives-Composer.Editor-MentionSuggestions] The component used to display mention suggestions. The list of suggested user IDs. he currently selected user ID. ```tsx ( ), }} /> ``` ###### Link [#primitives-Composer.Editor-Link] The component used to display links. ```tsx {children}, }} /> ``` The link’s absolute URL. The link’s content. ###### FloatingToolbar [#primitives-Composer.Editor-FloatingToolbar] Displays a floating toolbar attached to the selection within `Composer.Editor`. ```tsx ( Bold Italic ), }} /> ``` ##### Composer.Mention [#primitives-Composer.Mention] Displays mentions within `Composer.Editor`. ```tsx @{userId} ``` Replace the rendered element by the one passed as a child. | Attribute | Value | | --------------- | ------------------------------------- | | `data-selected` | Present when the mention is selected. |
##### Composer.Suggestions [#primitives-Composer.Suggestions] Contains suggestions within `Composer.Editor`. ```tsx {/* ... */} ``` Replace the rendered element by the one passed as a child. ##### Composer.SuggestionsList [#primitives-Composer.SuggestionsList] Displays a list of suggestions within `Composer.Editor`. ```tsx {userIds.map((userId) => ( @{userId} ))} ``` Replace the rendered element by the one passed as a child. ##### Composer.SuggestionsListItem [#primitives-Composer.SuggestionsListItem] Displays a suggestion within `Composer.SuggestionsList`. ```tsx @{userId} ``` The suggestion’s value. Replace the rendered element by the one passed as a child. | Attribute | Value | | --------------- | ---------------------------------- | | `data-selected` | Present when the item is selected. |
##### Composer.Link [#primitives-Composer.Link] Displays links within `Composer.Editor`. ```tsx {children} ``` Replace the rendered element by the one passed as a child. ##### Composer.Submit [#primitives-Composer.Submit] A button to submit the composer. ```tsx Send ``` Replace the rendered element by the one passed as a child. ##### Composer.FloatingToolbar [#primitives-Composer.FloatingToolbar] Displays a floating toolbar attached to the selection within `Composer.Editor`. ```tsx Bold Italic ``` Replace the rendered element by the one passed as a child. ##### Composer.MarkToggle A toggle button which toggles a specific text mark. ```tsx Bold ``` The text mark to toggle. The event handler called when the mark is toggled. Replace the rendered element by the one passed as a child. ##### Composer.AttachFiles [#primitives-Composer.AttachFiles] A button which opens a file picker to create attachments. ```tsx Attach files ``` Replace the rendered element by the one passed as a child. ##### Composer.AttachmentsDropArea [#primitives-Composer.AttachmentsDropArea] A drop area which accepts files to create attachments. ```tsx Drop files here ``` Replace the rendered element by the one passed as a child. #### Comment [#primitives-Comment] Used to render a single comment. ```tsx , Link: , }} /> ``` Map through `thread.comments` to render each comment in a thread. Threads can be retrieved with [`useThreads`](/docs/api-reference/liveblocks-react#useThreads). ```tsx highlight="13-21" import { Comment, CommentBodyLinkProps, CommentBodyMentionProps, } from "@liveblocks/react-ui/primitives"; import { ThreadData } from "@liveblocks/client"; // Render custom comments in a thread. Pass a thread from `useThreads`. function MyComments({ thread }: { thread: ThreadData }) { return ( <> {thread.comments.map((comment) => (
))} ); } // Render a mention in the comment, e.g. "@Emil Joyce" function Mention({ userId }: CommentBodyMentionProps) { return @{userId}; } // Render a link in the comment, e.g. "https://liveblocks.io" function Link({ href, children }: CommentBodyLinkProps) { return {children}; } ``` ##### Comment.Body [#primitives-Comment.Body] Displays a comment body. ```tsx ``` The comment body to display. If not defined, the component will render `null`. The components displayed within the comment body. Replace the rendered element by the one passed as a child. ###### components [#primitives-Comment.Body-components] The components displayed within the comment body. The component used to display mentions. Defaults to the mention’s `userId` prefixed by an @. The component used to display links. Defaults to the link’s `children` property. ###### Mention [#primitives-Comment.Body-Mention] The component used to display mentions. ```tsx @{userId}, }} /> ``` The mention’s user ID. ###### Link [#primitives-Comment.Body-Link] The component used to display links. ```tsx ( {children} ), }} /> ``` The link’s absolute URL. The link’s content. ##### Comment.Mention [#primitives-Comment.Mention] Displays mentions within `Comment.Body`. ```tsx @{userId} ``` Replace the rendered element by the one passed as a child. ##### Comment.Link [#primitives-Comment.Link] Displays links within `Comment.Body`. ```tsx {children} ``` Replace the rendered element by the one passed as a child. #### Timestamp [#primitives-Timestamp] Displays a formatted date, and automatically re-renders to support relative formatting. Defaults to relative formatting for recent dates (e.g. “5 minutes ago”) and a short absolute formatting for older ones (e.g. “25 Aug”). ```tsx ``` Use with `comment.createdAt`, `comment.editedAt`, or `comment.deletedAt` to display a human-readable time. ```tsx highlight="8" import { ThreadData, Timestamp } from "@liveblocks/react-ui"; function MyComments({ thread }: { thread: ThreadData }) { return ( <> {thread.comments.map((comment) => (
))} ); } ``` The date to display. A function to format the displayed date. Defaults to a relative date formatting function. The `title` attribute’s value or a function to format it. Defaults to an absolute date formatting function. The interval in milliseconds at which the component will re-render. Can be set to `false` to disable re-rendering. The locale used when formatting the date. Defaults to the browser’s locale. Replace the rendered element by the one passed as a child. #### FileSize [#primitives-FileSize] Displays a formatted file size. ```tsx ``` Use with `attachment.size` to display a human-readable file size. ```tsx import { CommentData, FileSize } from "@liveblocks/react-ui"; function MyComment({ comment }: { comment: CommentData }) { return (
{/* ... */} {comment.attachments.map((attachment) => (
{attachment.name} // +++ // +++
))}
); } ``` The file size to display. A function to format the displayed file size. Defaults to a human-readable file size formatting function. The locale used when formatting the file size. Defaults to the browser’s locale. Replace the rendered element by the one passed as a child. ### Hooks #### useComposer Returns states and methods related to the composer. Can only be used within the [`Composer.Form`](#primitives-Composer.Form) primitive. [All values listed below](#useComposer-values). ```tsx import { useComposer } from "@liveblocks/react-ui/primitives"; const { isEmpty, attachments, submit /* ... */ } = useComposer(); ``` ##### Custom composer behavior `useComposer` can be used in combination with [`Composer` primitives](#primitives-Composer) to create a custom composer, and control its behavior. For example, `createMention` allows you to create a button which focuses the editor, adds `@`, and opens the mention suggestions dropdown. ```tsx import { Composer, useComposer } from "@liveblocks/react-ui/primitives"; import { useCreateThread } from "@liveblocks/react/suspense"; function MyComposer() { const createThread = useCreateThread(); return ( { const thread = createThread({ body, attachments, metadata: {}, }); }} > ); } function Editor() { // +++ const { createMention } = useComposer(); // +++ return ( <> // +++ // +++ ); } ``` ##### Handle attachments When using primitives, [`Composer.AttachFiles`](#primitives-Composer.AttachFiles) and [`Composer.AttachmentsDropArea`](#primitives-Composer.AttachmentsDropArea) add attachments to the composer, but they’re not rendered without `useComposer`. The `attachments` array can be used to render the current attachments, and `removeAttachment` allows you to remove them. ```tsx import { Composer, useComposer } from "@liveblocks/react-ui/primitives"; import { useCreateThread } from "@liveblocks/react/suspense"; function MyComposer() { const createThread = useCreateThread(); return ( { const thread = createThread({ body, attachments, metadata: {}, }); }} > // +++ // +++ Attach Files Submit ); } function MyComposerAttachments() { // +++ const { attachments, removeAttachment } = useComposer(); // +++ return (
// +++ {attachments.map((attachment) => (
{attachment.name} ({attachment.status})
))} // +++
); } ``` ##### Values [#useComposer-values] Whether the composer is currently disabled. Whether the editor is currently focused. Whether the editor is currently empty. Whether the composer can currently be submitted. Submit the editor programmatically. Clear the editor programmatically. Select the editor programmatically. Focus the editor programmatically. Blur the editor programmatically. Which text marks are currently active and which aren’t. Remove an attachment by its ID. Start creating a mention at the current selection. Insert text at the current selection. Open a file picker programmatically to create attachments. The composer’s current attachments. Remove an attachment by its ID. #### Other hooks Other Comments hooks are part of [`@liveblocks/react`](/docs/api-reference/liveblocks-react), you can find them on the [React API reference page](/docs/api-reference/liveblocks-react#Comments). - [`useThreads`](/docs/api-reference/liveblocks-react#useThreads) - [`useThreadSubscription`](/docs/api-reference/liveblocks-react#useThreadSubscription) - [`useCreateThread`](/docs/api-reference/liveblocks-react#useCreateThread) - [`useDeleteThread`](/docs/api-reference/liveblocks-react#useDeleteThread) - [`useEditThreadMetadata`](/docs/api-reference/liveblocks-react#useEditThreadMetadata) - [`useMarkThreadAsResolved`](/docs/api-reference/liveblocks-react#useMarkThreadAsResolved) - [`useMarkThreadAsUnresolved`](/docs/api-reference/liveblocks-react#useMarkThreadAsUnresolved) - [`useMarkThreadAsRead`](/docs/api-reference/liveblocks-react#useMarkThreadAsRead) - [`useCreateComment`](/docs/api-reference/liveblocks-react#useCreateComment) - [`useEditComment`](/docs/api-reference/liveblocks-react#useEditComment) - [`useDeleteComment`](/docs/api-reference/liveblocks-react#useDeleteComment) - [`useAddReaction`](/docs/api-reference/liveblocks-react#useAddReaction) - [`useRemoveReaction`](/docs/api-reference/liveblocks-react#useRemoveReaction) - [`useAttachmentUrl`](/docs/api-reference/liveblocks-react#useAttachmentUrl) ## Notifications ### Default components #### InboxNotification Displays a single inbox notification. ```tsx ```
InboxNotification
Map through `inboxNotifications` with [`useInboxNotifications`](/docs/api-reference/liveblocks-react#useInboxNotifications) to render a list of the room’s notifications. ```tsx highlight="10-13" import { InboxNotification } from "@liveblocks/react-ui"; import { useInboxNotifications } from "@liveblocks/react/suspense"; function Component() { const { inboxNotifications } = useInboxNotifications(); return ( <> {inboxNotifications.map((inboxNotification) => ( ))} ); } ``` ##### Rendering notification kinds differently Different `kinds` of notifications are available, for example `thread` which is triggered when using Comments, or `$myCustomNotification` which would be a custom notification you’ve triggered manually. You can choose to render each notification differently. ```tsx highlight="4-14" ( ), $myCustomNotification: (props) => ( ❕} > My custom notification ), }} /> ``` Adding these two properties to `kinds` will overwrite the default component that’s displayed for those two notification types. Using [`InboxNotification.Thread`](/docs/api-reference/liveblocks-react-ui#InboxNotification.Thread) and [`InboxNotification.Custom`](/docs/api-reference/liveblocks-react-ui#InboxNotification.Custom) in this way allow you to easily create components that fit into the existing design system, whilst still adding lots of customization. However, it’s also valid to render any custom JSX. ```tsx highlight="3-5"
New notification
, }} /> ``` ##### Typing custom notifications To type custom notifications, edit the `ActivitiesData` type in your config file. ```ts file="liveblocks.config.ts" highlight="4-10" declare global { interface Liveblocks { // Custom activities data for custom notification kinds ActivitiesData: { // Example, a custom $alert kind $alert: { title: string; message: string; }; }; // Other kinds // ... } } ``` Your activities data is now correctly typed in inline functions. ```tsx highlight="5-6" { // `title` and `message` are correctly typed, as defined in your config const { title, message } = props.inboxNotification.activities[0].data; return ( ❗} > {message} ); }, }} /> ``` If you’d like to create a typed function elsewhere, you can use `InboxNotificationCustomProps` with a generic. In the example below we’re using the `$alert` notification kind as a generic, `InboxNotificationCustomKindProps<"$alert">`. ```tsx highlight="6-8,25" import { InboxNotification, InboxNotificationCustomKindProps, } from "@liveblocks/react-ui"; function AlertNotification(props: InboxNotificationCustomKindProps<"$alert">) { // `title` and `message` are correctly typed, as defined in your config const { title, message } = props.inboxNotification.activities[0].data; return ( ❗} > {message} ); } function Notification({ inboxNotification }) { return ( ); } ``` ##### Props [#InboxNotification-props] The inbox notification to display. The URL which the inbox notification links to. How to show or hide the actions. Override specific kinds of inbox notifications. Override the component’s strings. Override the component’s components. ###### kinds [#InboxNotification-kinds] Override specific kinds of inbox notifications. The component used to display thread notifications. Defaults to `InboxNotification.Thread`. The component used to display a custom notification kind. Custom notification kinds must start with a `$`. ###### InboxNotification.Thread [#InboxNotification.Thread] Displays a thread inbox notification. ```tsx ( ), }} /> ``` The inbox notification to display. How to show or hide the actions. Whether to show the room name in the title. Whether to show reactions. Whether to show attachments. ###### InboxNotification.Custom [#InboxNotification.Custom] Displays a custom notification kind. ```tsx { const activityData = props.inboxNotification.activities[0].data; return ( User {activityData.file} } aside={} {...props} > {activityData.errorDescription} ); }, }} /> ``` The inbox notification to display. The inbox notification’s title. The inbox notification’s content. How to show or hide the actions. The inbox notification’s aside content. Can be combined with{" "} InboxNotification.Icon or InboxNotification.Avatar{" "} to easily follow default styles. Replace the rendered element by the one passed as a child. #### InboxNotificationList Displays inbox notifications as a list. Each [`InboxNotification`](#InboxNotification) component will be wrapped in a `li` element. ```tsx ```
InboxNotificationList
##### Props [#InboxNotificationList-props] The inbox notifications to display. ### Hooks [#Notification-hooks] All hooks for Notifications are in [`@liveblocks/react`](/docs/api-reference/liveblocks-react#Notifications). ## Version History Version history is currently in private beta. If you would like access to the beta, please [contact us](https://liveblocks.io/contact/sales). We’d love to hear from you. When enabled, version history will automatically create versions of your [Lexical](https://liveblocks.io/docs/api-reference/liveblocks-react-lexical) or [Yjs](/docs/api-reference/liveblocks-yjs) document and allow you to restore to specific versions. These components aid in displaying a list of those versions. ### Default components ### HistoryVersionSummary Displays a version summary which includes the author and date. ```tsx { setSelectedVersionId(version.id); }} version={version} selected={version.id === selectedVersionId} /> ``` ##### Props [#HistoryVersionSummary-props] The function to call when the version summary is clicked. The version object containing information about the specific version. Whether this version is currently selected. #### HistoryVersionSummaryList Displays a list of version summaries for a document’s history including authors and dates. ```tsx {versions?.map((version) => ( { setSelectedVersionId(version.id); }} key={version.id} version={version} selected={version.id === selectedVersionId} /> ))} ``` ##### Props [#HistoryVersionSummaryList-props] The version summaries to display, typically an array of HistoryVersionSummary components. ## Utilities ### Components [#utilities-components] #### Icon Most icons used in the default components can be exported via ``. They’re stroke-based and designed for use at 20×20 pixels. ```tsx import { Icon } from "@liveblocks/react-ui"; ``` Find a full list of available icons in [our GitHub repo](https://github.com/liveblocks/liveblocks/blob/main/packages/liveblocks-react-ui/src/icon.ts). #### LiveblocksUIConfig Set configuration options for all `@liveblocks/react-ui` components, such as [overrides](#overrides). ```tsx ``` ##### Props [#LiveblocksUIConfig-props] Override the components’ strings. Override the components’ components. The container to render the portal into. When `preventUnsavedChanges` is set on your Liveblocks client (or set on `LiveblocksProvider`), then closing a browser tab will be prevented when there are unsaved changes. By default, that will include draft texts or attachments that are (being) uploaded via comments/threads composers, but not submitted yet. If you want to prevent unsaved changes with Liveblocks, but not for composers, you can opt-out by setting this option to `false`. ### Hooks [#Version-History-hooks] All hooks for Version History are in [`@liveblocks/react`](/docs/api-reference/liveblocks-react#Version-History). ## Styling and customization ### Default styles The default components come with default styles. These styles can be imported into the root of your app or directly into a CSS file with `@import`. ```tsx import "@liveblocks/react-ui/styles.css"; ``` ### Dark mode You can also import default dark mode styling. There are two versions to choose from, the first uses the [system theme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme). ```tsx // Dark mode using the system theme with `prefers-color-scheme` import "@liveblocks/react-ui/styles/dark/media-query.css"; ``` The second uses the `dark` class name, and two commonly used data attributes. ```tsx // Dark mode using `className="dark"`, `data-theme="dark"`, or `data-dark="true"` import "@liveblocks/react-ui/styles/dark/attributes.css"; ``` {/* TODO: Mention --elevation-background, --elevation-foreground here */} ### CSS variables The default components are built around a set of customizable CSS variables. Set these variables within `.lb-root` to globally style your components. ```css /* Styles all default Comments components */ .lb-root { --lb-accent: purple; --lb-spacing: 1em; --lb-radius: 0; } ``` The border radius scale. `em` values recommended. The spacing scale. `em` values recommended. The accent color. The foreground color used over the accent color. The destructive color. The foreground color used over the destructive color. The main background color. The main foreground color. The line height of main elements (e.g. comment bodies). The size of icons. The stroke weight of icons. The border radius used for avatars. The border radius used for buttons. The duration used for transitioned elements. The easing function used for transitioned elements. The box shadow added to elevated elements. The box shadow added to tooltips. Affects the lightness of accent colors. `%` value required. Affects the lightness of destructive colors. `%` value required. Affects the lightness of foreground colors. `%` value required. {/* TODO: Explain automatic color scales (with a palette-type visual) */} ### Class names Each default component has a set of predefined class names, which can be helpful for custom styling, for example. ```css .lb-thread { /* Customise thread */ } .lb-composer { /* Customise composer */ } ``` Additionally, some elements also have data attributes to provide contextual information, for example: ```css .lb-button[data-variant="primary"] { /* Customise primary buttons */ } .lb-avatar[data-loading] { /* Customise avatar loading state */ } ``` Classes containing colons `:` are internal and may change over time. {/* TODO: Think about adding back the browser support section */} ### Portaled elements Floating elements within the default components (e.g. tooltips, drowdowns, etc) are portaled to the end of the document to avoid `z-index` conflicts and `overflow` issues. When portaled, those elements are also wrapped in a container to handle their positioning. These containers don’t have any specific class names or data attributes so they shouldn’t be targeted or styled directly, but they will mirror whichever `z-index` value is set on their inner element (which would be `auto` by default). So if you need to set a specific `z-index` value on floating elements, you should set it on the floating elements themselves directly, ignoring their containers. You can either target specific floating elements (e.g. `.lb-tooltip`, `.lb-dropdown`, etc) or all of them at once via the `.lb-portal` class name. ```css /* Target all floating elements */ .lb-portal { z-index: 5; } /* Target a specific floating element */ .lb-tooltip { z-index: 10; } ``` ### Overrides Overrides can be used to customize components’ strings and localization-related properties, such as locale and reading direction. They can be set globally for all components using `LiveblocksUIConfig`: ```tsx import { LiveblocksUIConfig } from "@liveblocks/react-ui"; export function App() { return ( {/* ... */} ); } ``` Overrides can also be set per-component, and these settings will take precedence over global settings. This is particularly useful in certain contexts, for example when you’re using a `` component for creating replies to threads: ```tsx ``` --- meta: title: "@liveblocks/react" parentTitle: "API Reference" description: "API Reference for the @liveblocks/react package" alwaysShowAllNavigationLevels: false --- `@liveblocks/react` provides you with [React](https://react.dev/) bindings for our realtime collaboration APIs, built on top of WebSockets. Read our [getting started](/docs/get-started) guides to learn more. ## Suspense All Liveblocks React components and hooks can be exported from two different locations, `@liveblocks/react/suspense` and `@liveblocks/react`. This is because Liveblocks provides two types of hooks; those that support [React Suspense](https://react.dev/reference/react/Suspense), and those that don’t. ```tsx // Import the Suspense hook import { useThreads } from "@/liveblocks/react/suspense"; // Import the regular hook import { useThreads } from "@/liveblocks/react"; ``` We recommend importing from `@liveblocks/react/suspense` and using Suspense by default, as it often makes it easier to build your collaborative application. If you’re using the non-standard [`createRoomContext`](#createRoomContext) function to build your hooks, you must [enable suspense differently](#createRoomContext-Suspense). ### Suspense hooks Suspense hooks can be wrapped in [`ClientSideSuspense`][], which acts as a loading spinner for any components below it. When using this, all components below will only render once their hook contents have been loaded. ```tsx import { ClientSideSuspense, useStorage } from "@liveblocks/react/suspense"; function App() { Loading…}> ; } function Component() { // `animals` is always defined const animals = useStorage((root) => root.animals); // ... } ``` Advanced hooks using the `{ ..., error, isLoading }` syntax, such as [`useThreads`][], can also use [`ErrorBoundary`](https://github.com/bvaughn/react-error-boundary) to render an error if the hook runs into a problem. ```tsx import { ClientSideSuspense, useThreads } from "@liveblocks/react/suspense"; import { ErrorBoundary } from "react-error-boundary"; function App() { return ( Error}> Loading…}> ); } function Component() { // `threads` is always defined const { threads } = useThreads(); // ... } ``` An advantage of Suspense hooks is that you can have multiple different hooks in your tree, and you only need a single `ClientSideSuspense` component to render a loading spinner for all of them. ### Regular hooks Regular hooks often return `null` whilst a component is loading, and you must check for this to render a loading spinner. ```tsx import { useStorage } from "@liveblocks/react"; function Component() { // `animals` is `null` when loading const animals = useStorage((root) => root.animals); if (!animals) { return
Loading…
; } // ... } ``` Advanced hooks using the `{ ..., error, isLoading }` syntax, such as [`useThreads`][], require you to make sure there isn’t a problem before using the data. ```tsx import { useThreads } from "@liveblocks/react"; function Component() { // Check for `error` and `isLoading` before `threads` is defined const { threads, error, isLoading } = useThreads(); if (error) { return
Error
; } if (isLoading) { return
Loading…
; } // ... } ``` ### ClientSideSuspense Liveblocks provides a component named `ClientSideSuspense` which works as a replacement for `Suspense`. This is helpful as our Suspense hooks will throw an error when they’re run on the server, and this component avoids this issue by always rendering the `fallback` on the server. ```tsx import { ClientSideSuspense } from "@liveblocks/react/suspense"; function Page() { return ( +++ Loading…}> +++ ); } ``` #### Loading spinners Instead of wrapping your entire Liveblocks application inside a single `ClientSideSuspense` component, you can use multiple of these components in different parts of your application, and each will work as a loading fallback for any components further down your tree. ```tsx import { ClientSideSuspense } from "@liveblocks/react/suspense"; function Page() { return (
My title
+++ Loading…}> +++
); } ``` This is a great way to build a static skeleton around your dynamic collaborative application. ## Liveblocks ### LiveblocksProvider Sets up a client for connecting to Liveblocks, and is the recommended way to do this for React apps. You must define either `authEndpoint` or `publicApiKey`. Resolver functions should be placed inside here, and a number of other options are available, which correspond with those passed to [`createClient`][]. Unlike [`RoomProvider`][], `LiveblocksProvider` doesn’t call Liveblocks servers when mounted, and it should be placed higher in your app’s component tree. ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( {/* children */} ); } ``` ```tsx title="All LiveblocksProvider props" isCollapsable isCollapsed import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( { // const response = await fetch("/api/liveblocks-auth", { // method: "POST", // headers: { // Authentication: "", // "Content-Type": "application/json", // }, // body: JSON.stringify({ room }), // }); // return await response.json(); // }} // // Alternatively, use a public key // publicApiKey="pk_..." // // Throttle time (ms) between WebSocket updates throttle={100} // --- // Prevent browser tab from closing while local changes aren’t synchronized yet preventUnsavedChanges={false} // --- // Throw lost-connection event after 5 seconds offline lostConnectionTimeout={5000} // --- // Disconnect users after X (ms) of inactivity, disabled by default backgroundKeepAliveTimeout={undefined} // --- // Resolve user info for Comments and Notifications resolveUsers={async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, avatar: userData.avatar.src, })); }} // --- // Resolve room info for Notifications resolveRoomsInfo={async ({ roomIds }) => { const documentsData = await __getDocumentsFromDB__(roomIds); return documentsData.map((documentData) => ({ name: documentData.name, // url: documentData.url, })); }} // --- // Resolve mention suggestions for Comments resolveMentionSuggestions={async ({ text, roomId }) => { const workspaceUsers = await __getWorkspaceUsersFromDB__(roomId); if (!text) { // Show all workspace users by default return __getUserIds__(workspaceUsers); } else { const matchingUsers = __findUsers__(workspaceUsers, text); return __getUserIds__(matchingUsers); } }} // --- // Polyfill options for non-browser environments polyfills={ { // atob, // fetch, // WebSocket, } } > {/* children */} ); } ``` The URL of your back end’s [authentication endpoint](/docs/authentication) as a string, or an async callback function that returns a Liveblocks token result. Either `authEndpoint` or `publicApikey` are required. Learn more about [using a URL string](#LiveblocksProviderAuthEndpoint) and [using a callback](#LiveblocksProviderCallback). The public API key taken from your project’s [dashboard](/dashboard/apikeys). Generally not recommended for production use. Either `authEndpoint` or `publicApikey` are required. [Learn more](#LiveblocksProviderPublicKey). The throttle time between WebSocket messages in milliseconds, a number between `16` and `1000` is allowed. Using `16` means your app will update 60 times per second. [Learn more](#LiveblocksProviderThrottle). When set, navigating away from the current page is prevented while Liveblocks is still synchronizing local changes. [Learn more](#prevent-users-losing-unsaved-changes). After a user disconnects, the time in milliseconds before a [`"lost-connection"`](/docs/api-reference/liveblocks-client#Room.subscribe.lost-connection) event is fired. [Learn more](#LiveblocksProviderLostConnectionTimeout). The time before an inactive WebSocket connection is disconnected. This is disabled by default, but setting a number will activate it. [Learn more](#LiveblocksProviderBackgroundKeepAliveTimeout). A function that resolves user information in [Comments](/docs/ready-made-features/comments). Return an array of `UserMeta["info"]` objects in the same order they arrived. [Learn more](#LiveblocksProviderResolveUsers). A function that resolves room information in [Comments](/docs/ready-made-features/comments). Return an array of `RoomInfo` objects in the same order they arrived. [Learn more](#LiveblocksProviderResolveRoomsInfo). A function that resolves mention suggestions in [Comments](/docs/ready-made-features/comments). Return an array of user IDs. [Learn more](#LiveblocksProviderResolveMentionSuggestions). Place polyfills for `atob`, `fetch`, and `WebSocket` inside here. Useful when using a non-browser environment, such as [Node.js](#LiveblocksProviderNode) or [React Native](#LiveblocksProviderReactNative).
How to handle WebSocket messages that are larger than the maximum message size. Can be set to one of these values:
- `"default"` Don’t send anything, but log the error to the console. - `"split"` Break the message up into chunks each of which is smaller than the maximum message size. Beware that using `"split"` will sacrifice atomicity of changes! Depending on your use case, this may or may not be problematic. - `"experimental-fallback-to-http"` Try sending the update over HTTP instead of WebSockets (experimental).
Experimental. Stream the initial Storage content over HTTP, instead of waiting for a large initial WebSocket message to be sent from the server.
#### LiveblocksProvider with public key [#LiveblocksProviderPublicKey] When creating a client with a public key, you don’t need to set up an authorization endpoint. We only recommend using a public key when prototyping, or on public landing pages, as it makes it possible for end users to access any room’s data. You should instead use an [auth endpoint](#LiveblocksProviderAuthEndpoint). ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( {/* children */} ); } ``` #### LiveblocksProvider with auth endpoint [#LiveblocksProviderAuthEndpoint] If you are not using a public key, you need to set up your own `authEndpoint`. Please refer to our [Authentication guide](/docs/authentication). ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( {/* children */} ); } ``` #### LiveblocksProvider with auth endpoint callback [#LiveblocksProviderCallback] If you need to add additional headers or use your own function to call your endpoint, `authEndpoint` can be provided as a custom callback. You should return the token created with [`Liveblocks.prepareSession`](/docs/api-reference/liveblocks-node#access-tokens) or [`liveblocks.identifyUser`](/docs/api-reference/liveblocks-node#id-tokens), learn more in [authentication guide](/docs/rooms/authentication). ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( { // Fetch your authentication endpoint and retrieve your access or ID token // ... return { token: "..." }; }} > {/* children */} ); } ``` `room` is the room ID that the user is connecting to. When using [Notifications](/docs/ready-made-features/comments/email-notifications), `room` can be `undefined`, as the client is requesting a token that grants access to multiple rooms, rather than a specific room. ##### Fetch your endpoint Here’s an example of fetching your API endpoint at `/api/liveblocks-auth` within the callback. ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( { const response = await fetch("/api/liveblocks-auth", { method: "POST", headers: { Authentication: "", "Content-Type": "application/json", }, // Don't forget to pass `room` down. Note that it // can be undefined when using Notifications. body: JSON.stringify({ room }), }); return await response.json(); }} > {/* children */} ); } ``` ##### Token details You should return the token created with [`Liveblocks.prepareSession`](/docs/api-reference/liveblocks-node#access-tokens) or [`liveblocks.identifyUser`](/docs/api-reference/liveblocks-node#id-tokens). These are the values the functions can return. 1. A valid token, it returns a `{ "token": "..." }` shaped response. 1. A token that explicitly forbids access, it returns an `{ "error": "forbidden", "reason": "..." }` shaped response. If this is returned, the client will disconnect and won't keep trying to authorize. Any other error will be treated as an unexpected error, after which the client will retry the request until it receives either 1. or 2. #### WebSocket throttle [#LiveblocksProviderThrottle] By default, the client throttles the WebSocket messages sent to one every 100 milliseconds, which translates to 10 updates per second. It’s possible to override that configuration with the `throttle` option with a value between `16` and `1000` milliseconds. ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( {/* children */} ); } ``` This option is helpful for smoothing out realtime animations in your application, as you can effectively increase the framerate without using any interpolation. Here are some examples with their approximate frames per second (FPS) values. ```ts throttle: 16, // 60 FPS throttle: 32, // 30 FPS throttle: 200, // 5 FPS ``` #### Prevent users losing unsaved changes [#prevent-users-losing-unsaved-changes] Liveblocks usually synchronizes milliseconds after a local change, but if a user immediately closes their tab, or if they have a slow connection, it may take longer for changes to synchronize. Enabling `preventUnsavedChanges` will stop tabs with unsaved changes closing, by opening a dialog that warns users. In usual circumstances, it will very rarely trigger. ```tsx function Page() { return ( ... ); } ``` More specifically, this option triggers when: - There are unsaved changes after calling any hooks or methods, in all of our products. - There are unsaved changes in a [Text Editor](/docs/ready-made-features/text-editor). - There’s an unsubmitted comment in the [Composer](/docs/api-reference/liveblocks-react-ui#Composer). - The user has made changes and is currently offline. Internally, this option uses the [beforeunload event](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event). #### Lost connection timeout [#LiveblocksProviderLostConnectionTimeout] If you’re connected to a room and briefly lose connection, Liveblocks will reconnect automatically and quickly. However, if reconnecting takes longer than usual, for example if your network is offline, then the room will emit an event informing you about this. How quickly this event is triggered can be configured with the `lostConnectionTimeout` setting, and it takes a number in milliseconds. `lostConnectionTimeout` can be set between `1000` and `30000` milliseconds. The default is `5000`, or 5 seconds. ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( {/* children */} ); } ``` You can listen to the event with [`useLostConnectionListener`][]. Note that this also affects when `others` are reset to an empty array after a disconnection. This helps prevent temporary flashes in your application as a user quickly disconnects and reconnects. For a demonstration of this behavior, see our [connection status example][]. #### Background keep-alive timeout [#LiveblocksProviderBackgroundKeepAliveTimeout] By default, Liveblocks applications will maintain an active WebSocket connection to the Liveblocks servers, even when running in a browser tab that’s in the background. However, if you’d prefer for background tabs to disconnect after a period of inactivity, then you can use `backgroundKeepAliveTimeout`. When `backgroundKeepAliveTimeout` is specified, the client will automatically disconnect applications that have been in an unfocused background tab for _at least_ the specified time. When the browser tab is refocused, the client will immediately reconnect to the room and synchronize the document. ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( {/* children */} ); } ``` `backgroundKeepAliveTimeout` accepts a number in milliseconds—we advise using a value of at least a few minutes, to avoid unnecessary disconnections. #### resolveUsers [#LiveblocksProviderResolveUsers] [Comments](/docs/ready-made-features/comments) stores user IDs in its system, but no other user information. To display user information in Comments components, such as a user’s name or avatar, you need to resolve these IDs into user objects. This function receives a list of user IDs and you should return a list of user objects of the same size, in the same order. ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, avatar: userData.avatar.src, })); }} // Other props // ... > {/* children */} ); } ``` The name and avatar you return are rendered in [`Thread`](/docs/api-reference/liveblocks-react-ui#Thread) components. ##### User objects The user objects returned by the resolver function take the shape of `UserMeta["info"]`, which contains `name` and `avatar` by default. These two values are optional, though if you’re using the [Comments default components](/docs/api-reference/liveblocks-react-ui#Components), they are necessary. Here’s an example of `userIds` and the exact values returned. ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( { // ["marc@example.com", "nimesh@example.com"]; console.log(userIds); return [ { name: "Marc", avatar: "https://example.com/marc.png" }, { name: "Nimesh", avatar: "https://example.com/nimesh.png" }, ]; }} // Other props // ... > {/* children */} ); } ``` You can also return custom information, for example, a user’s `color`: ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( { // ["marc@example.com"]; console.log(userIds); return [ { name: "Marc", avatar: "https://example.com/marc.png", // +++ color: "purple", // +++ }, ]; }} // Other props // ... > {/* children */} ); } ``` ##### Accessing user data You can access any values set within `resolveUsers` with the [`useUser`](/docs/api-reference/liveblocks-react#useUser) hook. ```tsx import { useUser } from "@liveblocks/react/suspense"; function Component() { const user = useUser("marc@example.com"); // { name: "Marc", avatar: "https://...", ... } console.log(user); } ``` #### resolveRoomsInfo [#LiveblocksProviderResolveRoomsInfo] When using [Notifications](/docs/ready-made-features/comments/email-notifications) with [Comments](/docs/ready-made-features/comments), room IDs will be used to contextualize notifications (e.g. “Chris mentioned you in _room-id_”) in the [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) component. To replace room IDs with more fitting names (e.g. document names, “Chris mentioned you in _Document A_”), you can provide a resolver function to the `resolveRoomsInfo` option in [`LiveblocksProvider`](#LiveblocksProvider). This resolver function will receive a list of room IDs and should return a list of room info objects of the same size and in the same order. ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( { const documentsData = await __getDocumentsFromDB__(roomIds); return documentsData.map((documentData) => ({ name: documentData.name, // url: documentData.url, })); }} // Other props // ... > {/* children */} ); } ``` In addition to the room’s name, you can also provide a room’s URL as the `url` property. If you do so, the [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) component will automatically use it. It’s possible to use an inbox notification’s `roomId` property to construct a room’s URL directly in React and set it on [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) via `href`, but the room ID might not be enough for you to construct the URL , you might need to call your backend for example. In that case, providing it via `resolveRoomsInfo` is the preferred way. #### resolveMentionSuggestions [#LiveblocksProviderResolveMentionSuggestions] To enable creating mentions in [Comments](/docs/ready-made-features/comments), you can provide a resolver function to the `resolveMentionSuggestions` option in [`LiveblocksProvider`](#LiveblocksProvider). These mentions will be displayed in the [`Composer`](/docs/api-reference/liveblocks-react-ui#Composer) component. This resolver function will receive the mention currently being typed (e.g. when writing “@jane”, `text` will be `jane`) and should return a list of user IDs matching that text. This function will be called every time the text changes but with some debouncing. ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; function App() { return ( { const workspaceUsers = await __getWorkspaceUsersFromDB__(roomId); if (!text) { // Show all workspace users by default return __getUserIds__(workspaceUsers); } else { const matchingUsers = __findUsers__(workspaceUsers, text); return __getUserIds__(matchingUsers); } }} // Other props // ... > {/* children */} ); } ``` #### LiveblocksProvider for Node.js [#LiveblocksProviderNode] To use `@liveblocks/client` in Node.js, you need to provide [`WebSocket`][] and [`fetch`][] polyfills. As polyfills, we recommend installing [`ws`][] and [`node-fetch`][]. ```bash npm install ws node-fetch ``` Then, pass them to the `LiveblocksProvider` polyfill option as below. ```tsx import { LiveblocksProvider } from "@liveblocks/react/suspense"; import fetch from "node-fetch"; import WebSocket from "ws"; function App() { return ( {/* children */} ); } ``` Note that `node-fetch` v3+ [does not support CommonJS](https://github.com/node-fetch/node-fetch/blob/main/docs/v3-UPGRADE-GUIDE.md#converted-to-es-module). If you are using CommonJS, downgrade `node-fetch` to v2. #### LiveblocksProvider for React Native [#LiveblocksProviderReactNative] To use `@liveblocks/client` with [React Native](https://reactnative.dev/), you need to add an [`atob`][] polyfill. As a polyfill, we recommend installing [`base-64`][]. ```bash npm install base-64 ``` Then you can pass the `decode` function to our `atob` polyfill option when you create the client. ```ts import { LiveblocksProvider } from "@liveblocks/react/suspense"; import { decode } from "base-64"; function App() { return ( {/* children */} ); } ``` ### createLiveblocksContext This used to be the default way to start your app, but now it’s recommended for advanced usage only. We generally recommend using [`LiveblocksProvider`][] and following [typing your data with the Liveblocks interface](#Typing-your-data), unless you need to define multiple room types in your application. Creates a [`LiveblocksProvider`][] and a set of typed hooks. Note that any `LiveblocksProvider` created in this way takes no props, because it uses settings from the `client` instead. We recommend using it in `liveblocks.config.ts` and re-exporting your typed hooks like below. While [`createRoomContext`](#createRoomContext) offers APIs for interacting with rooms (e.g. Presence, Storage, and Comments), [`createLiveblocksContext`](#createLiveblocksContext) offers APIs for interacting with Liveblocks features that are not tied to a specific room (e.g. Notifications). ```tsx file="liveblocks.config.ts" import { createClient } from "@liveblocks/client"; import { createRoomContext, createLiveblocksContext } from "@liveblocks/react"; const client = createClient({ // publicApiKey: "", // authEndpoint: "/api/liveblocks-auth", // throttle: 100, }); // ... export const { RoomProvider } = createRoomContext(client); export const { LiveblocksProvider, useInboxNotifications, // Other hooks // ... } = createLiveblocksContext(client); ``` ### useClient [@badge=LiveblocksProvider] Returns the [`client`](/docs/api-reference/liveblocks-client#Client) of the nearest [`LiveblocksProvider`][] above in the React component tree. ```ts import { useClient } from "@liveblocks/react/suspense"; const client = useClient(); ``` ### useErrorListener [@badge=LiveblocksProvider] Listen to potential Liveblocks errors. Examples of errors include room connection errors, errors creating threads, and errors deleting notifications. Each error has a `message` string, and a `context` object which has different values for each error type. `context` always contains an error `type` and `roomId`. ```ts import { useErrorListener } from "@liveblocks/react/suspense"; useErrorListener((error) => { // { message: "You don't have access to this room", context: { ... }} console.error(error); }); ``` There are many different errors, and each can be handled separately by checking the value of `error.context.type`. Below we’ve listed each error and the context it provides. ```ts title="All error types" isCollapsable isCollapsed import { useErrorListener } from "@liveblocks/react/suspense"; useErrorListener((error) => { switch (error.context.type) { // Can happen if you use Presence, Storage, or Yjs case "ROOM_CONNECTION_ERROR": { const { code } = error.context; // -1 = Authentication error // 4001 = You don't have access to this room // 4005 = Room was full // 4006 = Room ID has changed break; } // Can happen if you use Comments or Notifications case "CREATE_THREAD_ERROR": const { roomId, threadId, commentId, body, metadata } = error.context; break; case "DELETE_THREAD_ERROR": const { roomId, threadId } = error.context; break; case "EDIT_THREAD_METADATA_ERROR": const { roomId, threadId, metadata } = error.context; break; case "MARK_THREAD_AS_RESOLVED_ERROR": case "MARK_THREAD_AS_UNRESOLVED_ERROR": const { roomId, threadId } = error.context; break; case "CREATE_COMMENT_ERROR": case "EDIT_COMMENT_ERROR": const { roomId, threadId, commentId, body } = error.context; break; case "DELETE_COMMENT_ERROR": const { roomId, threadId, commentId } = error.context; break; case "ADD_REACTION_ERROR": case "REMOVE_REACTION_ERROR": const { roomId, threadId, commentId, emoji } = error.context; break; case "MARK_INBOX_NOTIFICATION_AS_READ_ERROR": const { inboxNotificationId, roomId } = error.context; break; case "DELETE_INBOX_NOTIFICATION_ERROR": const { roomId } = error.context; break; case "MARK_ALL_INBOX_NOTIFICATIONS_AS_READ_ERROR": case "DELETE_ALL_INBOX_NOTIFICATIONS_ERROR": break; case "UPDATE_NOTIFICATION_SETTINGS_ERROR": const { roomId } = error.context; break; default: // Ignore any error from the future break; } }); ``` The behavior of this hook changed in 2.16. Previously, this hook had to be in a [`RoomProvider`][] context, and would only trigger for connection errors in the current room. Since 2.16, this hook only requires a [`LiveblocksProvider`][] context, and will notify about any errors, and for any room. For example, notifications are not part of rooms, and errors for these will show. See the [upgrade guide for 2.6](/docs/platform/upgrading/2.16) for more details. ## Room ### RoomProvider Makes a [`Room`][] available in the component hierarchy below. Joins the room when the component is mounted, and automatically leaves the room when the component is unmounted. When using [Sync Datastore](/docs/ready-made-features/sync-datastore), initial Presence values for each user, and Storage values for the room can be set. ```tsx import { RoomProvider } from "@liveblocks/react/suspense"; function App() { return {/* children */}; } ``` The unique ID for the current room. `RoomProvider` will join this room when it loads. If the room doesn’t exist already it will automatically create the room first then join. After setting up [authentication](/docs/authentication) for your app, it can helpful to decide on a naming pattern for your room IDs. The initial Presence of the user entering the room. Each user has their own presence, and this is readable for all other connected users. A user’s Presence resets every time they disconnect. This object must be JSON-serializable. This value is ignored after the first render. [Learn more](#setting-initial-presence). The initial Storage structure for the room when it’s joined for the first time. This is only set a single time, when the room has not yet been populated. This object must contain [conflict-free live structures](/docs/api-reference/liveblocks-client#Storage). This value is ignored after the first render, and if Storage for the current room has already been created. [Learn more](#setting-initial-storage). Whether the room immediately connects to Liveblocks servers. This value is ignored after the first render. #### Setting initial Presence [#setting-initial-presence] Presence is used for storing temporary user-based values, such as a user’s cursor coordinates, or their current selection. Each user has their own presence, and this is readable for all other connected users. Set your initial Presence value by using `initialPresence`. ```tsx import { RoomProvider } from "@liveblocks/react/suspense"; function App() { return ( {/* children */} ); } ``` Each user’s Presence resets every time they disconnect, as this is only meant for temporary data. Any JSON-serializable object is allowed (the `JsonObject` type). #### Setting initial Storage [#setting-initial-storage] Storage is used to store permanent data that’s used in your application, such as shapes on a whiteboard, nodes on a flowchart, or text in a form. The first time a room is entered, you can set an initial value by using `initialStorage`. `initialStorage` is only read and set a single time, unless a new top-level property is added. ```tsx import { LiveList, LiveObject, LiveMap } from "@liveblocks/client"; import { RoomProvider } from "@liveblocks/react/suspense"; function App() { return ( {/* children */} ); } ``` If a new top-level property is added to `initialStorage`, the next time a user connects, the new property will be created. Other properties will be unaffected. Any [conflict-free live structures](/docs/api-reference/liveblocks-client#Storage) and JSON-serializable objects are allowed (the `LsonObject` type). ### createRoomContext This used to be the default way to start your app, but now it’s recommend for advanced usage only. We generally recommend using [`LiveblocksProvider`][] and following [typing your data with the Liveblocks interface](#Typing-your-data), unless you need to define multiple room types in your application. Creates a [`RoomProvider`][] and a set of typed hooks to use in your app. Note that any `RoomProvider` created in this way does not need to be nested in [`LiveblocksProvider`][], as it already has access to the `client`. We generally recommend typing your app using the newer method instead. When using `createRoomContext` it can be helpful to use it in `liveblocks.config.ts` and re-export your typed hooks as below. ```tsx file="liveblocks.config.ts" import { createClient } from "@liveblocks/client"; import { createRoomContext } from "@liveblocks/react"; const client = createClient({ // publicApiKey: "", // authEndpoint: "/api/liveblocks-auth", }); type Presence = {}; type Storage = {}; type UserMeta = {}; type RoomEvent = {}; type ThreadMetadata = {}; // +++ export const { RoomProvider, useMyPresence, // Other hooks // ... } = createRoomContext( client ); // +++ ``` #### Suspense with createRoomContext [#createRoomContext-Suspense] To use the React suspense version of our hooks with `createRoomContext`, you can export from the `suspense` property instead. ```tsx file="liveblocks.config.ts" import { createClient } from "@liveblocks/client"; import { createRoomContext } from "@liveblocks/react"; const client = createClient({ // publicApiKey: "", // authEndpoint: "/api/liveblocks-auth", }); type Presence = {}; type Storage = {}; type UserMeta = {}; type RoomEvent = {}; type ThreadMetadata = {}; export const { // +++ suspense: { RoomProvider, useMyPresence, // Other suspense hooks // ... }, // +++ } = createRoomContext( client ); ``` #### Typing createRoomContext To type your hooks, you can pass multiple different types to `createRoomContext`. A full explanation is in the code snippet below. ```tsx file="liveblocks.config.ts" isCollapsable isCollapsed import { createClient } from "@liveblocks/client"; import { createRoomContext } from "@liveblocks/react"; const client = createClient({ // publicApiKey: "", // authEndpoint: "/api/liveblocks-auth", }); // Presence represents the properties that exist on every user in the Room // and that will automatically be kept in sync. Accessible through the // `user.presence` property. Must be JSON-serializable. type Presence = { // cursor: { x: number, y: number } | null, // ... }; // Optionally, Storage represents the shared document that persists in the // Room, even after all users leave. Fields under Storage typically are // LiveList, LiveMap, LiveObject instances, for which updates are // automatically persisted and synced to all connected clients. type Storage = { // animals: LiveList, // ... }; // Optionally, UserMeta represents static/readonly metadata on each user, as // provided by your own custom auth back end (if used). Useful for data that // will not change during a session, like a user's name or avatar. // type UserMeta = { // id?: string, // Accessible through `user.id` // info?: Json, // Accessible through `user.info` // }; // Optionally, the type of custom events broadcast and listened to in this // room. Use a union for multiple events. Must be JSON-serializable. // type RoomEvent = {}; // Optionally, when using Comments, ThreadMetadata represents metadata on // each thread. Can only contain booleans, strings, and numbers. // export type ThreadMetadata = { // pinned: boolean; // quote: string; // time: number; // }; export const { RoomProvider, useMyPresence, useStorage, // Other hooks // ... } = createRoomContext< Presence, Storage /* UserMeta, RoomEvent, ThreadMetadata */ >(client); ``` ### useRoom [@badge=RoomProvider] Returns the [`Room`][] of the nearest [`RoomProvider`][] above in the React component tree. ```ts import { useRoom } from "@liveblocks/react/suspense"; const room = useRoom(); ``` Will throw when used outside of a [`RoomProvider`][]. If you don’t want this hook to throw when used outside of a Room context (for example to write components in a way that they can be used both inside and outside of a Liveblocks room), you can use the `{ allowOutsideRoom }` option: ```ts import { useRoom } from "@liveblocks/react/suspense"; const room = useRoom({ allowOutsideRoom: true }); // Possibly `null` ``` ### useIsInsideRoom [@badge=Both] Returns a boolean, `true` if the hook was called inside a [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider) context, and `false` otherwise. ```ts import { useIsInsideRoom } from "@liveblocks/react/suspense"; const isInsideRoom = useIsInsideRoom(); ``` #### Displaying different components inside rooms `useIsInsideRoom` is helpful for rendering different components depending on whether they’re inside a room, or not. One example is a header component that only displays a live avatar stack when users are connected to the room. ```tsx import { useIsInsideRoom, useOthers } from "@liveblocks/react/suspense"; function Header() { // +++ const isInsideRoom = useIsInsideRoom(); // +++ return (
// +++ {isInsideRoom ? : null} // +++
); } function LiveAvatars() { const others = useOthers(); return others.map((other) => ); } ``` Here’s how the example above would render in three different [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider) and [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider) contexts. ```tsx // 👥👤 Live avatar stack and your avatar
// 👤 Just your avatar
// 👤 Just your avatar
``` ### useStatus [@badge=RoomProvider] Returns the current WebSocket connection status of the room, and will re-render your component whenever it changes. ```ts import { useStatus } from "@liveblocks/react/suspense"; const status = useStatus(); ``` The possible value are: `initial`, `connecting`, `connected`, `reconnecting`, or `disconnected`. ### useStorageStatus [@badge=RoomProvider] Deprecated. Starting with 2.12.0, we recommend using [`useSyncStatus`][] instead for tracking sync status, because it will reflect sync status of all parts of Liveblocks, not just Storage. Returns the current storage status of the room, and will re-render your component whenever it changes. A `{ smooth: true }` option is also available, which prevents quick changes between states, making it ideal for [rendering a synchronization badge in your app](#display-synchronization-badge). ```ts import { useStorageStatus } from "@liveblocks/react"; const storageStatus = useStorageStatus(); // "not-loaded" | "loading" | "synchronizing" | "synchronized" console.log(storageStatus); ``` 👉 A [Suspense version][] of this hook is also available. ```ts import { useStorageStatus } from "@liveblocks/react/suspense"; const storageStatus = useStorageStatus(); // "synchronizing" | "synchronized" console.log(storageStatus); ``` ### useSyncStatus [@badge=Both] Returns the current synchronization status of Liveblocks, and will re-render your component whenever it changes. This includes any part of Liveblocks that may be synchronizing local changes to the server, including (any room’s) Storage, text editors, threads, or notifications. A `{ smooth: true }` option is also available, which prevents quick changes between states, making it ideal for [rendering a synchronization badge in your app](#display-synchronization-badge). ```ts import { useSyncStatus } from "@liveblocks/react"; const syncStatus = useSyncStatus(); // "not-loaded" | "loading" | "synchronizing" | "synchronized" ``` 👉 A [Suspense version][] of this hook is also available. ```ts import { useSyncStatus } from "@liveblocks/react/suspense"; const syncStatus = useSyncStatus(); // "synchronizing" | "synchronized" ``` #### Display a synchronization badge [#display-synchronization-badge] Passing `{ smooth: true }` prevents the status changing from `"synchronizing"` to `"synchronized"` until 1 second has passed after the final change. This means it’s ideal for rendering a synchronization status badge, as it won’t flicker in a distracting manner when changes are made in quick succession. ```tsx import { useSyncStatus } from "@liveblocks/react/suspense"; function StorageStatusBadge() { const syncStatus = useSyncStatus({ smooth: true }); return
{syncStatus === "synchronized" ? "✅ Saved" : "🔄 Saving"}
; } ``` #### Prevent users losing unsaved changes [#use-sync-status-prevent-users-losing-unsaved-changes] Liveblocks usually synchronizes milliseconds after a local change, but if a user immediately closes their tab, or if they have a slow connection, it may take longer for changes to synchronize. Enabling `preventUnsavedChanges` will stop tabs with unsaved changes closing, by opening a dialog that warns users. In usual circumstances, it will very rarely trigger. ```tsx function Page() { return ( ... ); } ``` More specifically, this option triggers when: - There are unsaved changes after calling any hooks or methods, in all of our products. - There are unsaved changes in a [Text Editor](/docs/ready-made-features/text-editor). - There’s an unsubmitted comment in the [Composer](/docs/api-reference/liveblocks-react-ui#Composer). - The user has made changes and is currently offline. Internally, this option uses the [beforeunload event](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event). ### useOthersListener [@badge=RoomProvider] Calls the given callback when an “others” event occurs. Possible event types are: - `enter` – A user has entered the room. - `leave` – A user has left the room. - `reset` – The others list has been emptied. This is the first event that occurs when the room is entered. It also occurs when you’ve lost connection to the room. - `update` – A user’s presence data has been updated. ```ts function App() { useOthersListener(({ type, user, others }) => { switch (type) { case "enter": // `user` has entered the room break; case "leave": // `user` has left the room break; case "update": // Presence for `user` has updated break; case "reset": // Others list has been emptied break; } }); } ``` ### useLostConnectionListener [@badge=RoomProvider] Calls the given callback in the exceptional situation that a connection is lost and reconnecting does not happen quickly enough. This event allows you to build high-quality UIs by warning your users that the app is still trying to re-establish the connection, for example through a toast notification. You may want to take extra care in the mean time to ensure their changes won’t go unsaved. When this happens, this callback is called with the event `lost`. Then, once the connection restores, the callback will be called with the value `restored`. If the connection could definitively not be restored, it will be called with `failed` (uncommon). The [`lostConnectionTimeout`][] client option will determine how quickly this event will fire after a connection loss (default: 5 seconds). ```ts import { toast } from "my-preferred-toast-library"; function App() { useLostConnectionListener((event) => { switch (event) { case "lost": toast.warn("Still trying to reconnect..."); break; case "restored": toast.success("Successfully reconnected again!"); break; case "failed": toast.error("Could not restore the connection"); break; } }); } ``` Automatically unsubscribes when the component is unmounted. For a demonstration of this behavior, see our [connection status example][]. ## Presence Try the [Liveblocks DevTools extension](/devtools) to visualize your collaborative experiences as you build them, in realtime. ### useMyPresence [@badge=RoomProvider] Return the presence of the current user, and a function to update it. Automatically subscribes to updates to the current user’s presence. Note that the `updateMyPresence` setter function is different to the setter function returned by React’s `useState` hook. Instead, you can pass a partial presence object to `updateMyPresence`, and any changes will be merged into the current presence. It will not replace the entire presence object. ```ts import { useMyPresence } from "@liveblocks/react/suspense"; const [myPresence, updateMyPresence] = useMyPresence(); updateMyPresence({ x: 0 }); updateMyPresence({ y: 0 }); // At the next render, "myPresence" will be equal to "{ x: 0, y: 0 }" ``` This is roughly equal to: ```tsx const myPresence = useSelf((me) => me.presence); const updateMyPresence = useUpdateMyPresence(); ``` `updateMyPresence` accepts an optional argument to add a new item to the undo/redo stack. See [`room.history`][] for more information. ```ts updateMyPresence({ selectedId: "xxx" }, { addToHistory: true }); ``` ### useUpdateMyPresence [@badge=RoomProvider] Returns a setter function to update the current user’s presence. Use this if you don’t need the current user’s presence in your component, but you need to update it (e.g. live cursor). It’s better to use `useUpdateMyPresence` because it won’t subscribe your component to get rerendered when the presence updates. Note that the `updateMyPresence` setter function is different to the setter function returned by React’s `useState` hook. Instead, you can pass a partial presence object to `updateMyPresence`, and any changes will be merged into the current presence. It will not replace the entire presence object. ```ts import { useUpdateMyPresence } from "@liveblocks/react/suspense"; const updateMyPresence = useUpdateMyPresence(); updateMyPresence({ y: 0 }); ``` `updateMyPresence` accepts an optional argument to add a new item to the undo/redo stack. See [`room.history`][] for more information. ```ts updateMyPresence({ selectedId: "xxx" }, { addToHistory: true }); ``` ### useSelf [@badge=RoomProvider] Returns the current user once it is connected to the room, and automatically subscribes to updates to the current user. ```ts import { useSelf } from "@liveblocks/react/suspense"; const currentUser = useSelf(); // { // connectionId: 1, // presence: { cursor: { x: 27, y: -8 } }, // } const currentUser = useSelf((me) => me.presence.cursor); // { x: 27, y: -8 } ``` The benefit of using a selector is that it will only update your component if that particular selection changes. For full details, see [how selectors work][]. 👉 A [Suspense version][] of this hook is also available, which will never return `null`. ### useOthers [@badge=RoomProvider] Extracts data from the list of other users currently in the same Room, and automatically subscribes to updates on the selected data. For full details, see [how selectors work][]. The `others` argument to the `useOthers` selector function is an _immutable_ array of Users. ```tsx // ✅ Rerenders only if the number of users changes const numOthers = useOthers((others) => others.length); // ✅ Rerenders only if someone starts or stops typing const isSomeoneTyping = useOthers((others) => others.some((other) => other.presence.isTyping) ); // ✅ Rerenders only if actively typing users are updated const typingUsers = useOthers( (others) => others.filter((other) => other.presence.isTyping), shallow // 👈 ); ``` 👉 A [Suspense version][] of this hook is also available, which will never return `null`. One caveat with this API is that selecting a subset of data for each user quickly becomes tricky. When you want to select and get updates for only a particular subset of each user’s data, we recommend using the [`useOthersMapped`][] hook instead, which is optimized for this use case. ```tsx // ❌ Mapping is hard to get right with this hook const cursors = useOthers( (others) => others.map((other) => other.presence.cursor), shallow ); // ✅ Better to use useOthersMapped const cursors = useOthersMapped((other) => other.presence.cursor); ``` When called without arguments, returns the user list and updates your component whenever _anything_ in it changes. This might be way more often than you want! ```tsx const others = useOthers(); // ⚠️ Caution, might rerender often! // [ // { connectionId: 2, presence: { cursor: { x: 27, y: -8 } } }, // { connectionId: 3, presence: { cursor: { x: 0, y: 19 } } }, // ] ``` In production-ready apps, you likely want to avoid calling `useOthers` without arguments. ### useOthersMapped [@badge=RoomProvider] Extract data using a [selector][] for every user in the room, and subscribe to all changes to the selected data. A [Suspense version][] of this hook is also available. The key difference with [`useOthers`][] is that the selector (and the optional comparison function) work at the _item_ level, like doing a `.map()` over the others array. ```tsx // Example 1 const others = useOthersMapped((other) => other.presence.cursor); // [ // [2, { x: 27, y: -8 }], // [3, { x: 0, y: 19 }], // ] // Example 2 const others = useOthersMapped( (other) => ({ avatar: other.info.avatar, isTyping: other.presence.isTyping, }), shallow // 👈 ); // [ // [2, { avatar: 'https://...', isTyping: true }], // [3, { avatar: null, isTyping: false }], // ] ``` Returns an array where each item is a pair of `[connectionId, data]`. For pragmatic reasons, the results are keyed by the `connectionId`, because in most cases you’ll want to iterate over the results and draw some UI for each, which in React requires you to use a `key={connectionId}` prop. ```tsx const others = useOthersMapped((other) => other.presence.cursor); // In JSX return ( <> {others.map(([connectionId, cursor]) => ( ))} ); ``` ### useOthersConnectionIds [@badge=RoomProvider] Returns an array of connection IDs (numbers), and rerenders automatically when users join or leave. This hook is useful in particular in combination with the [`useOther`][] (singular) hook, to implement high-frequency rerendering of components for each user in the room, e.g. cursors. See the [`useOther`][] (singular) documentation below for a full usage example. ```tsx useOthersConnectionIds(); // [2, 4, 7] ``` Roughly equivalent to: ```tsx useOthers((others) => others.map((other) => other.connectionId), shallow); ``` 👉 A [Suspense version][] of this hook is also available. ### useOther [@badge=RoomProvider] Extract data using a [selector][] for one specific user in the room, and subscribe to all changes to the selected data. A [Suspense version][] of this hook is also available. ```tsx // ✅ Rerenders when this specific user’s isTyping changes (but not when their cursor changes) const isTyping = useOther( 3, // User with connectionId 3 (user) => user.presence.isTyping ); ``` The reason this hook exists is to enable the most efficient rerendering model for high-frequency updates to other’s presences, which is the following structure: ```tsx file="Cursors.tsx" const Cursors = // +++ // (1) Wrap parent component in a memo and make sure it takes no props React.memo(function () { const othersConnectionIds = useOthersConnectionIds(); // (2) // +++ return ( <> {othersConnectionIds.map((connectionId) => ( ))} ); }); ``` ```tsx file="Cursor.tsx" function Cursor({ connectionId }) { // +++ const { x, y } = useOther(connectionId, (other) => other.presence.cursor); // (4) // +++ return ; } ``` 1. Makes sure this whole component tree will never rerender beyond the first time. 2. Makes sure the parent component only rerenders when users join/leave. 3. Makes sure each cursor remains associated to the same connection. 4. Makes sure each cursor rerenders whenever _its_ data changes only. 👉 A [Suspense version][] of this hook is also available, which will never return `null`. ## Broadcast ### useBroadcastEvent [@badge=RoomProvider] Returns a callback that lets you broadcast custom events to other users in the room. ```ts import { useBroadcastEvent } from "@liveblocks/react/suspense"; // +++ // On client A const broadcast = useBroadcastEvent(); broadcast({ type: "EMOJI", emoji: "🔥" }); // +++ // On client B useEventListener(({ event, user, connectionId }) => { // ^^^^ Will be Client A if (event.type === "EMOJI") { // Do something } }); ``` ### useEventListener [@badge=RoomProvider] Listen to custom events sent by other people in the room via [`useBroadcastEvent`][]. Provides the `event` along with the `connectionId` of the user that sent the message. If an event was sent from the [Broadcast to a room](/docs/api-reference/rest-api-endpoints#post-broadcast-event) REST API, `connectionId` will be `-1`. ```ts import { useEventListener } from "@liveblocks/react/suspense"; // On client A const broadcast = useBroadcastEvent(); broadcast({ type: "EMOJI", emoji: "🔥" }); // +++ // On client B useEventListener(({ event, user, connectionId }) => { // ^^^^ Will be Client A if (event.type === "EMOJI") { // Do something } }); // +++ ``` The `user` property will indicate which User instance sent the message. This will typically be equal to one of the others in the room, but it can also be `null` in case this event was broadcasted from the server, using the [Broadcast Event API](https://liveblocks.io/docs/api-reference/rest-api-endpoints#post-broadcast-event). Automatically unsubscribes when the component is unmounted. ## Storage Each room contains Storage, a conflict-free data store that multiple users can edit at the same time. When users make edits simultaneously, conflicts are resolved automatically, and each user will see the same state. Storage is ideal for storing permanent document state, such as shapes on a canvas, notes on a whiteboard, or cells in a spreadsheet. ### Data structures Storage provides three different conflict-free data structures, which you can use to build your application. All structures are permanent and persist when all users have left the room, unlike [Presence](/docs/ready-made-features/presence) which is temporary. - [`LiveObject`][] - Similar to JavaScript object. Use this for storing records with fixed key names and where the values don’t necessarily have the same types. For example, a `Person` with a `name: string` and an `age: number` field. If multiple clients update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. - [`LiveList`][] - An ordered collection of items synchronized across clients. Even if multiple users add/remove/move elements simultaneously, LiveList will solve the conflicts to ensure everyone sees the same collection of items. - [`LiveMap`][] - Similar to a JavaScript Map. Use this for indexing values that all have the same structure. For example, to store an index of `Person` values by their name. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. ### Typing Storage [#typing-storage] To type the Storage values you receive, make sure to set your `Storage` type. ```ts import { LiveList } from "@liveblocks/client"; declare global { interface Liveblocks { Storage: { animals: LiveList; }; } } ``` You can then set an initial value in [`RoomProvider`][]. ```tsx import { LiveList } from "@liveblocks/client"; import { RoomProvider } from "@liveblocks/react/suspense"; function App() { return ( {/* children */} ); } ``` The type received in your Storage will match the type passed. Learn more under [typing your data](#typing-your-data). ```tsx import { useMutation } from "@liveblocks/react/suspense"; function App() { const addAnimal = useMutation(({ storage }) => { const animals = storage.get("animals"); // LiveList<["Fido"]> console.log(animals); animals.push("Felix"); // LiveList<["Fido", "Felix"]> console.log(animals); }); return ; } ``` [`useStorage`][] will return an immutable copy of the data, for example a `LiveList` is converted to an `array`, which makes it easy to render. ```tsx import { useStorage } from "@liveblocks/react/suspense"; function App() { const animals = useStorage((root) => root.animals); // ["Fido", "Felix"] console.log(animals); return (
    {animals.map((animal) => (
  • {animal}
  • ))}
); } ``` ### Nesting data structures All Storage data structures can be nested, allowing you to create complex trees of conflict-free data. ```ts import { LiveObject, LiveList, LiveMap } from "@liveblocks/client"; type Person = LiveObject<{ name: string; pets: LiveList; }>; declare global { interface Liveblocks { Storage: { people: LiveMap; }; } } ``` Here’s an example of setting `initialStorage` for this type. ```tsx import { LiveObject, LiveList, LiveMap } from "@liveblocks/client"; import { RoomProvider } from "@liveblocks/react/suspense"; function App() { return ( {/* children */} ); } ``` Get the [Liveblocks DevTools extension](/devtools) to develop and debug your application as you build it. ### useStorage [@badge=RoomProvider] Extracts data from Liveblocks Storage state and automatically subscribes to updates to that selected data. For full details, see [how selectors work][]. ```tsx // ✅ Rerenders if todos (or their children) change const items = useStorage((root) => root.todos); // ✅ Rerenders when todos are added or deleted const numTodos = useStorage((root) => root.todos.length); // ✅ Rerenders when the value of allDone changes const allDone = useStorage((root) => root.todos.every((item) => item.done)); // ✅ Rerenders if any _unchecked_ todo items change const uncheckedItems = useStorage( (root) => root.todos.filter((item) => !item.done), shallow // 👈 ); ``` The `root` argument to the `useStorage` selector function is an _immutable_ copy of your entire Liveblocks Storage tree. Think of it as the value you provided in the `initialStorage` prop at the [`RoomProvider`][] level, but then (recursively) converted to their “normal” JavaScript equivalents (objects, arrays, maps) that are read-only. From that immutable `root`, you can select or compute any value you like. Your component will automatically get rerendered if the value you return differs from the last rendered value. This hook returns `null` while storage is still loading. To avoid that, use the [Suspense version][]. It’s recommended to select only the subset of Storage data that your component needs. This will avoid unnecessary rerenders that happen with overselection. In order to select one item from a LiveMap within the storage tree with the `useStorage` method, you can use the example below: ```ts const key = "errands"; const myTodos = useStorage((root) => root.todoMap.get(key)); ``` In order to query a LiveMap, and filter for specific values: ```ts const myTodos = useStorage( root => Array.from(root.todoMap.values()).filter(...), shallow, ); ``` ### useBatch [@badge=RoomProvider] Deprecated, starting with 0.18, we recommend using [`useMutation`][] for writing to Storage, which will automatically batch all mutations. Returns a function that batches Storage and Presence modifications made during the given function. Each modification is grouped together, which means that other clients receive the changes as a single message after the batch function has run. Every modification made during the batch is merged into a single history item (undo/redo). ```tsx import { useBatch } from "@liveblocks/react/suspense"; const batch = useBatch(); batch(() => { // All modifications made in this callback are batched }); ``` Note that `batch` cannot take an `async` function. ```tsx // ❌ Won't work batch(async () => /* ... */); // ✅ Will work batch(() => /*... */); ``` ### useHistory [@badge=RoomProvider] Returns the room’s history. See [`Room.history`][] for more information. ```ts import { useHistory } from "@liveblocks/react/suspense"; const { undo, redo, pause, resume } = useHistory(); ``` ### useUndo [@badge=RoomProvider] Returns a function that undoes the last operation executed by the current client. It does not impact operations made by other clients. ```ts import { useUndo } from "@liveblocks/react/suspense"; const undo = useUndo(); ``` ### useRedo [@badge=RoomProvider] Returns a function that redoes the last operation executed by the current client. It does not impact operations made by other clients. ```ts import { useRedo } from "@liveblocks/react/suspense"; const redo = useRedo(); ``` ### useCanUndo [@badge=RoomProvider] Returns whether there are any operations to undo. ```ts import { useCanUndo, useUpdateMyPresence } from "@liveblocks/react/suspense"; const updateMyPresence = useUpdateMyPresence(); const canUndo = useCanUndo(); updateMyPresence({ y: 0 }); // At the next render, "canUndo" will be true ``` ### useCanRedo [@badge=RoomProvider] Returns whether there are any operations to redo. ```ts import { useCanRedo, useUndo, useUpdateMyPresence, } from "@liveblocks/react/suspense"; const updateMyPresence = useUpdateMyPresence(); const undo = useUndo(); const canRedo = useCanRedo(); updateMyPresence({ y: 0 }); undo(); // At the next render, "canRedo" will be true ``` ### useMutation [@badge=RoomProvider] Creates a callback function that lets you mutate Liveblocks state. ```tsx import { useMutation } from "@liveblocks/react/suspense"; const fillWithRed = useMutation( // Mutation context is passed as the first argument ({ storage, setMyPresence }) => { // Mutate Storage storage.get("shapes").get("circle1").set("fill", "red"); // ^^^ // ...or Presence setMyPresence({ lastUsedColor: "red" }); }, [] ); // JSX return )} ``` Error handling is another important aspect to consider when using the `useThreads` hook. The `error` and `fetchMoreError` fields provide information about any errors that occurred during the initial fetch or subsequent fetch operations, respectively. You can use these fields to display appropriate error messages to the user and implement retry mechanisms if needed. The following example shows how to display error messages for both initial loading errors and errors that occur when fetching more threads. ```tsx import { useThreads } from "@liveblocks/react"; const { threads, error, fetchMore, fetchMoreError } = useThreads(); // Handle error if the initial load failed. // The `error` field is not returned by the Suspense hook as the error is thrown to nearest ErrorBoundary if (error) { return (

Error loading threads: {error.message}

); } return (
{threads.map((thread) => ( ... ))} {fetchMoreError && (

Error loading more threads: {fetchMoreError.message}

)}
); ``` 👉 A [Suspense version][] of this hook is also available, which will never return a loading state and will throw error to the nearest ErrorBoundary if initial load failed. ### useThreadSubscription [@badge=RoomProvider] Returns the subscription status of a thread. ```tsx import { useThreadSubscription } from "@liveblocks/react/suspense"; const { status, unreadSince } = useThreadSubscription("th_xxx"); ``` ### useCreateThread [@badge=RoomProvider] Returns a function that creates a thread with an initial comment, and optionally some metadata. ```tsx import { useCreateThread } from "@liveblocks/react/suspense"; const createThread = useCreateThread(); const thread = createThread({ body: {}, attachments: [], metadata: {} }); ``` ### useDeleteThread [@badge=RoomProvider] Returns a function that deletes a thread and all its associated comments by ID. Only the thread creator can delete the thread. ```tsx import { useDeleteThread } from "@liveblocks/react/suspense"; const deleteThread = useDeleteThread(); deleteThread("th_xxx"); ``` ### useEditThreadMetadata [@badge=RoomProvider] Returns a function that edits a thread’s metadata. To delete an existing metadata property, set its value to `null`. Passing `undefined` for a metadata property will ignore it. ```tsx import { useEditThreadMetadata } from "@liveblocks/react/suspense"; const editThreadMetadata = useEditThreadMetadata(); editThreadMetadata({ threadId: "th_xxx", metadata: {} }); ``` ### useMarkThreadAsResolved [@badge=RoomProvider] Returns a function that marks a thread as resolved. ```tsx import { useMarkThreadAsResolved } from "@liveblocks/react/suspense"; const markThreadAsResolved = useMarkThreadAsResolved(); markThreadAsResolved("th_xxx"); ``` ### useMarkThreadAsUnresolved [@badge=RoomProvider] Returns a function that marks a thread as unresolved. ```tsx import { useMarkThreadAsUnresolved } from "@liveblocks/react/suspense"; const markThreadAsUnresolved = useMarkThreadAsUnresolved(); markThreadAsUnresolved("th_xxx"); ``` ### useMarkThreadAsRead [@badge=RoomProvider] Returns a function that marks a thread as read. ```tsx import { useMarkThreadAsRead } from "@liveblocks/react/suspense"; const markThreadAsRead = useMarkThreadAsRead(); markThreadAsRead("th_xxx"); ``` ### useCreateComment [@badge=RoomProvider] Returns a function that adds a comment to a thread. ```tsx import { useCreateComment } from "@liveblocks/react/suspense"; const createComment = useCreateComment(); const comment = createComment({ threadId: "th_xxx", body: {}, attachments: [], }); ``` ### useEditComment[@badge=RoomProvider] Returns a function that edits a comment’s body. ```tsx import { useEditComment } from "@liveblocks/react/suspense"; const editComment = useEditComment(); editComment({ threadId: "th_xxx", commentId: "cm_xxx", body: {}, attachments: [], }); ``` ### useDeleteComment [@badge=RoomProvider] Returns a function that deletes a comment. If it is the last non-deleted comment, the thread also gets deleted. ```tsx import { useDeleteComment } from "@liveblocks/react/suspense"; const deleteComment = useDeleteComment(); deleteComment({ threadId: "th_xxx", commentId: "cm_xxx" }); ``` ### useAddReaction [@badge=RoomProvider] Returns a function that adds a reaction to a comment. ```tsx import { useAddReaction } from "@liveblocks/react/suspense"; const addReaction = useAddReaction(); addReaction({ threadId: "th_xxx", commentId: "cm_xxx", emoji: "👍" }); ``` ### useRemoveReaction [@badge=RoomProvider] Returns a function that removes a reaction from a comment. ```tsx import { useRemoveReaction } from "@liveblocks/react/suspense"; const removeReaction = useRemoveReaction(); removeReaction({ threadId: "th_xxx", commentId: "cm_xxx", emoji: "👍" }); ``` ### useAttachmentUrl [@badge=RoomProvider] Returns a presigned URL for an attachment by its ID. ```tsx import { useAttachmentUrl } from "@liveblocks/react/suspense"; const { url, error, isLoading } = useAttachmentUrl("at_xxx"); ``` 👉 A [Suspense version][] of this hook is also available, which will never return a loading state and will throw when there’s an error. ## Notifications ### useInboxNotifications [@badge=LiveblocksProvider] Returns the inbox notifications for the current user. Inbox notifications are [project-based](/docs/ready-made-features/notifications/concepts#Project-based), meaning notifications from outside the current room are received. ```tsx import { useInboxNotifications } from "@liveblocks/react/suspense"; const { inboxNotifications, error, isLoading } = useInboxNotifications(); ``` By default, the `useInboxNotifications` hook returns up to 50 notifications. To fetch more, the hook provides additional fields for pagination, similar to [`useThreads`][]. ```tsx import { useInboxNotifications } from "@liveblocks/react"; const { inboxNotifications, isLoading, error, +++ hasFetchedAll, fetchMore, isFetchingMore, fetchMoreError, +++ } = useInboxNotifications(); ``` - `hasFetchedAll` indicates whether all available inbox notifications have been fetched. - `fetchMore` loads up to 50 more notifications, and is always safe to call. - `isFetchingMore` indicates whether more notifications are being fetched. - `fetchMoreError` returns error statuses resulting from fetching more. #### Pagination [#useInboxNotifications-pagination] The following example demonstrates how to use the `fetchMore` function to implement a “Load More” button, which fetches additional inbox notifications when clicked. The button is disabled while fetching is in progress. ```tsx import { InboxNotification } from "@liveblocks/react-ui"; import { useInboxNotifications } from "@liveblocks/react/suspense"; function Inbox() { const { inboxNotifications, hasFetchedAll, fetchMore, isFetchingMore } = useInboxNotifications(); return (
{inboxNotifications.map((notification) => ( ... ))} // +++ {hasFetchedAll ? (
🎉 You're all caught up!
) : ( )} // +++
); } ``` #### Error handling [#useInboxNotifications-error-handling] Error handling is another important aspect to consider when using the `useInboxNotifications` hook. The `error` and `fetchMoreError` fields provide information about any errors that occurred during the initial fetch or subsequent fetch operations, respectively. You can use these fields to display appropriate error messages to the user and implement retry mechanisms if needed. The following example shows how to display error messages for both initial loading errors and errors that occur when fetching more inbox notifications. ```tsx import { InboxNotification } from "@liveblocks/react-ui"; import { useInboxNotifications } from "@liveblocks/react/suspense"; function Inbox() { const { inboxNotifications, error, fetchMore, fetchMoreError } = useInboxNotifications(); // Handle error if the initial load failed. // The `error` field is not returned by the Suspense hook as the error is thrown to nearest ErrorBoundary // +++ if (error) { return (

Error loading inbox notifications: {error.message}

); } // +++ return (
{inboxNotifications.map((notification) => ( ... ))} {fetchMoreError && (

Error loading more inbox notifications: {fetchMoreError.message}

)}
); } ``` 👉 A [Suspense version][] of this hook is also available, which will never return a loading state and will throw error to the nearest ErrorBoundary if initial load failed. ### useUnreadInboxNotificationsCount [@badge=LiveblocksProvider] Returns the number of unread inbox notifications for the current user. ```tsx import { useUnreadInboxNotificationsCount } from "@liveblocks/react/suspense"; const { count, error, isLoading } = useUnreadInboxNotificationsCount(); ``` 👉 A [Suspense version][] of this hook is also available, which will never return a loading state and will throw when there’s an error. ### useMarkInboxNotificationAsRead [@badge=LiveblocksProvider] Returns a function that marks an inbox notification as read for the current user. ```tsx import { useMarkInboxNotificationAsRead } from "@liveblocks/react/suspense"; const markInboxNotificationAsRead = useMarkInboxNotificationAsRead(); markInboxNotificationAsRead("in_xxx"); ``` ### useMarkAllInboxNotificationsAsRead [@badge=LiveblocksProvider] Returns a function that marks all of the current user‘s inbox notifications as read. ```tsx import { useMarkAllInboxNotificationsAsRead } from "@liveblocks/react/suspense"; const markAllInboxNotificationsAsRead = useMarkAllInboxNotificationsAsRead(); markAllInboxNotificationsAsRead(); ``` ### useDeleteInboxNotification [@badge=LiveblocksProvider] Returns a function that deletes an inbox notification for the current user. ```tsx import { useDeleteInboxNotification } from "@liveblocks/react/suspense"; const deleteInboxNotification = useDeleteInboxNotification(); deleteInboxNotification("in_xxx"); ``` ### useDeleteAllInboxNotifications [@badge=LiveblocksProvider] Returns a function that deletes all of the current user‘s inbox notifications. ```tsx import { useDeleteAllInboxNotifications } from "@liveblocks/react/suspense"; const deleteAllInboxNotifications = useDeleteAllInboxNotifications(); deleteAllInboxNotifications(); ``` ### useInboxNotificationThread [@badge=LiveblocksProvider] Returns the thread associated with a `"thread"` inbox notification. ```tsx import { useInboxNotificationThread } from "@liveblocks/react/suspense"; const thread = useInboxNotificationThread("in_xxx"); ``` It can **only** be called with IDs of `"thread"` inbox notifications, so we recommend only using it [when customizing the rendering](/docs/api-reference/liveblocks-react-ui#Rendering-notification-kinds-differently) or in other situations where you can guarantee the kind of the notification. When `useInboxNotifications` returns `"thread"` inbox notifications, it also receives the associated threads and caches them behind the scenes. When you call `useInboxNotificationThread`, it simply returns the cached thread for the inbox notification ID you passed to it, without any fetching or waterfalls. ### useRoomNotificationSettings [@badge=RoomProvider] Returns the user’s notification settings for the current room and a function to update them. Updating this setting will change which [`inboxNotifications`](#useInboxNotifications) the current user receives in the current room. ```tsx import { useRoomNotificationSettings } from "@liveblocks/react/suspense"; const [{ settings }, updateSettings] = useRoomNotificationSettings(); // { threads: "replies_and_mentions" } console.log(settings); // No longer receive thread notifications in this room updateSettings({ threads: "none", }); ``` These are the three possible values that can be set: - `"all"` Receive notifications for every activity. - `"replies_and_mentions"` Receive notifications for mentions and thread you’re participating in. - `"none"` No notifications are received. ### useUpdateRoomNotificationSettings [@badge=RoomProvider] Returns a function that updates the user’s notification settings for the current room. Updating this setting will change which [`inboxNotifications`](#useInboxNotifications) the current user receives in the current room. ```tsx import { useUpdateRoomNotificationSettings } from "@liveblocks/react/suspense"; const updateRoomNotificationSettings = useUpdateRoomNotificationSettings(); // No longer receive thread notifications in this room updateSettings({ threads: "none", }); ``` These are the three possible values that can be set: - `"all"` Receive notifications for every activity. - `"replies_and_mentions"` Receive notifications for mentions and thread you’re participating in. - `"none"` No notifications are received. Works the same as `updateSettings` in [`useRoomNotificationSettings`](#useRoomNotificationSettings). ### useNotificationSettings [@badge=LiveblocksProvider] User notification settings is currently in beta. Returns the user’s notification settings in the current project, in other words which [notification webhook events](/docs/platform/webhooks#NotificationEvent) will be sent for the current user. User notification settings are project-based, which means that `settings` and `updateSettings` are for the current user’s settings in every room. Useful for creating a [notification settings panel](/docs/guides/how-to-create-a-notification-settings-panel). ```tsx import { useNotificationSettings } from "@liveblocks/react"; const [{ isLoading, error, settings }, updateSettings] = useNotificationSettings(); // Current user receives thread notifications on the email channel // { email: { thread: true, ... }, ... } console.log(settings); // Disabling thread notifications on the email channel updateSettings({ email: { thread: false, }, }); ``` A user’s initial settings are set in the dashboard, and different kinds should be enabled there. If no kind is enabled on the current channel, `null` will be returned. For example, with the email channel: ```ts const [{ isLoading, error, settings }, updateSettings] = useNotificationSettings(); // { email: null, ... } console.log(settings); ``` #### Updating notification settings [#useNotificationSettings-updating-notification-settings] The `updateSettings` function can be used to update the current user’s notification settings, changing their settings for every room in the project. Each notification `kind` must first be enabled on your project’s notification dashboard page before settings can be used. ```tsx // You only need to pass partials updateSettings({ email: { thread: true }, }); // Enabling a custom notification on the slack channel updateSettings({ slack: { $myCustomNotification: true }, }); // Setting complex settings updateSettings({ email: { thread: true, textMention: false, $newDocument: true, }, slack: { thread: false, $fileUpload: false, }, teams: { thread: true, }, }); ``` #### Error handling [#useNotificationSettings-error-handling] Error handling is an important aspect to consider when using the `useNotificationSettings` hook. The `error` fields provides information about any error that occurred during the fetch operation. The following example shows how to display error messages for both initial loading errors and errors that occur when fetching more inbox notifications. ```tsx import { useNotificationSettings } from "@liveblocks/react"; const [{ isLoading, error, settings }, updateSettings] = useNotificationSettings(); if (error) { return (

Error loading user notification settings: {error.message}

); } ``` #### 👉🏻 A [Suspense version][] of this hook is also available, which will never return a loading state and will throw and error to the nearest `ErrorBoundary` if initial load failed. ### useUpdateNotificationSettings [@badge=LiveblocksProvider] User notification settings is currently in beta. Returns a function that updates user’s notification settings, which affects which [notification webhook events](/docs/platform/webhooks#NotificationEvent) will be sent for the current user. User notification settings are project-based, which means that `updateSettings` modifies the current user’s settings in every room. Each notification `kind` must first be enabled on your project’s notification dashboard page before settings can be used. Useful for creating a [notification settings panel](/docs/guides/how-to-create-a-notification-settings-panel). ```tsx import { useUpdateNotificationSettings } from "@liveblocks/react"; const updateSettings = useUpdateNotificationSettings(); // Disabling thread notifications on the email channel updateSettings({ email: { thread: false }, }); ``` Works the same as `updateSettings` in [`useNotificationSettings`](#useNotificationSettings). You can pass a partial object, or many settings at once. ```tsx // You only need to pass partials updateSettings({ email: { thread: true }, }); // Enabling a custom notification on the slack channel updateSettings({ slack: { $myCustomNotification: true }, }); // Setting complex settings updateSettings({ email: { thread: true, textMention: false, $newDocument: true, }, slack: { thread: false, $fileUpload: false, }, teams: { thread: true, }, }); ``` ## Version History Version history is currently in private beta. If you would like access to the beta, please [contact us](https://liveblocks.io/contact/sales). We’d love to hear from you. ### useHistoryVersions [@badge=RoomProvider] Returns the versions of the room. See [Version History Components](/docs/api-reference/liveblocks-react-ui#Version-history-components) for more information on how to display versions. ```tsx import { useHistoryVersions } from "@liveblocks/react"; const { versions, error, isLoading } = useHistoryVersions(); ``` ## Miscellaneous ### useUser [@badge=Both] Returns user info from a given user ID. ```tsx import { useUser } from "@liveblocks/react/suspense"; const { user, error, isLoading } = useUser("user-id"); ``` 👉 A [Suspense version][] of this hook is also available, which will never return a loading state and will throw when there’s an error. To use `useUser`, you should provide a resolver function to the [`resolveUsers`][] option in [`createClient`][]. ### useRoomInfo [@badge=Both] Returns room info from a given room ID. ```tsx import { useRoomInfo } from "@liveblocks/react/suspense"; const { info, error, isLoading } = useRoomInfo("room-id"); ``` 👉 A [Suspense version][] of this hook is also available, which will never return a loading state and will throw when there’s an error. To use `useRoomInfo`, you should provide a resolver function to the [`resolveRoomsInfo`][] option in [`createClient`][]. ## TypeScript ### Typing your data It’s possible to have automatic types flow through your application by defining a global `Liveblocks` interface. We recommend doing this in a `liveblocks.config.ts` file in the root of your app, so it’s easy to keep track of your types. Each type (`Presence`, `Storage`, etc.), is optional, but it’s recommended to make use of them. ```ts file="liveblocks.config.ts" declare global { interface Liveblocks { // Each user's Presence, for useMyPresence, useOthers, etc. Presence: {}; // The Storage tree for the room, for useMutation, useStorage, etc. Storage: {}; UserMeta: { id: string; // Custom user info set when authenticating with a secret key info: {}; }; // Custom events, for useBroadcastEvent, useEventListener RoomEvent: {}; // Custom metadata set on threads, for useThreads, useCreateThread, etc. ThreadMetadata: {}; // Custom room info set with resolveRoomsInfo, for useRoomInfo RoomInfo: {}; // Custom activities data for custom notification kinds ActivitiesData: {}; } } // Necessary if you have no imports/exports export {}; ``` Here are some example values that might be used. ```ts file="liveblocks.config.ts" import { LiveList } from "@liveblocks/client"; declare global { interface Liveblocks { // Each user's Presence, for useMyPresence, useOthers, etc. Presence: { // Example, real-time cursor coordinates cursor: { x: number; y: number }; }; // The Storage tree for the room, for useMutation, useStorage, etc. Storage: { // Example, a conflict-free list animals: LiveList; }; UserMeta: { id: string; // Custom user info set when authenticating with a secret key info: { // Example properties, for useSelf, useUser, useOthers, etc. name: string; avatar: string; }; }; // Custom events, for useBroadcastEvent, useEventListener // Example has two events, using a union RoomEvent: { type: "PLAY" } | { type: "REACTION"; emoji: "🔥" }; // Custom metadata set on threads, for useThreads, useCreateThread, etc. ThreadMetadata: { // Example, attaching coordinates to a thread x: number; y: number; }; // Custom room info set with resolveRoomsInfo, for useRoomInfo RoomInfo: { // Example, rooms with a title and url title: string; url: string; }; // Custom activities data for custom notification kinds ActivitiesData: { // Example, a custom $alert kind $alert: { title: string; message: string; }; }; } } // Necessary if you have no imports/exports export {}; ``` ### Typing with createRoomContext Before Liveblocks 2.0, it was recommended to create your hooks using [`createRoomContext`][], and manually pass your types to this function. This is no longer [the recommended method](#Typing-your-data) for setting up Liveblocks, but it can still be helpful, for example you can use `createRoomContext` multiple times to create different room types, each with their own correctly typed hooks. ```tsx file="liveblocks.config.ts" isCollapsable isCollapsed import { createClient } from "@liveblocks/client"; import { createRoomContext } from "@liveblocks/react"; const client = createClient({ // publicApiKey: "", // authEndpoint: "/api/liveblocks-auth", }); // Presence represents the properties that exist on every user in the Room // and that will automatically be kept in sync. Accessible through the // `user.presence` property. Must be JSON-serializable. type Presence = { // cursor: { x: number, y: number } | null, // ... }; // Optionally, Storage represents the shared document that persists in the // Room, even after all users leave. Fields under Storage typically are // LiveList, LiveMap, LiveObject instances, for which updates are // automatically persisted and synced to all connected clients. type Storage = { // animals: LiveList, // ... }; // Optionally, UserMeta represents static/readonly metadata on each user, as // provided by your own custom auth back end (if used). Useful for data that // will not change during a session, like a user's name or avatar. // type UserMeta = { // id?: string, // Accessible through `user.id` // info?: Json, // Accessible through `user.info` // }; // Optionally, the type of custom events broadcast and listened to in this // room. Use a union for multiple events. Must be JSON-serializable. // type RoomEvent = {}; // Optionally, when using Comments, ThreadMetadata represents metadata on // each thread. Can only contain booleans, strings, and numbers. // export type ThreadMetadata = { // pinned: boolean; // quote: string; // time: number; // }; export const { RoomProvider, useMyPresence, useStorage, // Other hooks // ... } = createRoomContext< Presence, Storage /* UserMeta, RoomEvent, ThreadMetadata */ >(client); ``` To upgrade to Liveblocks 2.0 and the new typing system, follow the [2.0 migration guide](/docs/platform/upgrading/2.0). ## Helpers ### shallow Compares two values shallowly. This can be used as the second argument to selector based functions to loosen the equality check: ```tsx const redShapes = useStorage( (root) => root.shapes.filter((shape) => shape.color === "red"), shallow // 👈 here ); ``` The default way [selector results](#selectors-return-arbitrary-values) are compared is by checking referential equality (`===`). If your selector returns computed arrays (like in the example above) or objects, this will not work. By passing `shallow` as the second argument, you can “loosen” this check. This is because `shallow` will shallowly compare the members of an array (or values in an object): ```tsx // Comparing arrays shallow([1, 2, 3], [1, 2, 3]); // true // Comparison objects shallow({ a: 1 }, { a: 1 }); // true ``` Please note that this will only do a shallow (one level deep) check. Hence the name. If you need to do an arbitrarily deep equality check, you’ll have to write a custom equality function or use a library like Lodash for that. ## How selectors work [#selectors] [@keywords=["useStorage", "useSelf", "useOthers", "useOther", "useOthersMapped", "useOthersConnectionIds", "selectors", "comparison"]] The concepts and behaviors described in this section apply to all of our selector hooks: [`useStorage`][] , [`useSelf`][] , [`useOthers`][] , [`useOthersMapped`][], and [`useOther`][] (singular). ```tsx file="Component.tsx" const child = useStorage((root) => root.child); const nested = useStorage((root) => root.child.nested); const total = useStorage((root) => root.x + root.y); const merged = useStorage((root) => [...root.items, ...root.more], shallow); ``` In this section, `useStorage` is used as the canonical example. This is for illustration purposes only. The described concepts and behaviors apply equally to the other selector hooks. In a nutshell, the key behaviors for all selector APIs are: - They [receive immutable data](#selectors-receive-immutable-data) - They [return arbitrary values](#selectors-return-arbitrary-values) - They [auto-subscribe to updates](#selectors-subscribe-to-updates) Let’s go over these traits and responsibilities in the next few sections. ### Selectors receive immutable data [#selectors-receive-immutable-data] The received input to all selector functions is a **read-only** and **immutable** top level context value that differs for each hook: - `useStorage((root) => ...)` receives the Storage root - `useSelf((me) => ...)` receives the current user - `useOthers((others) => ...)` receives a list of other users in the room - `useOthersMapped((other) => ...)` receives each individual other user in the room - `useOther(connectionId, (other) => ...)` receives a specific user in the room For example, suppose you have set up Storage in the typical way by setting `initialStorage` in your [`RoomProvider`][] to a tree that describes your app’s data model using `LiveList`, `LiveObject`, and `LiveMap`. The "root" argument for your selector function, however, will receive **an immutable and read-only representation** of that Storage tree, consisting of "normal" JavaScript datastructures. This makes consumption much easier. ```tsx file="Component.tsx" function Component() { useStorage((root) => ...); // ^^^^ // Read-only. No mutable Live structures in here. // // { // animals: ["🦁", "🦊", "🐵"], // mathematician: { firstName: "Ada", lastName: "Lovelace" }, // fruitsByName: new Map([ // ["apple", "🍎"], // ["banana", "🍌"], // ["cherry", "🍒"], // ]) // } // } ``` Internally, these read-only trees use a technique called **structural sharing**. This means that between rerenders, if nodes in the tree did not change, they will **guarantee** to return the same memory instance. Selecting and returning these nodes directly is therefore safe and considered a good practice, because they are stable references by design. ### Selectors return arbitrary values [#selectors-return-arbitrary-values] [@keywords=["shallow"]] ```tsx file="Component.tsx" const animals = useStorage((root) => root.animals); // ["🦁", "🦊", "🐵"] const ada = useStorage((root) => root.mathematician); // { firstName: "Ada", lastName: "Lovelace" } const fullname = useStorage( (root) => `${root.mathematician.firstName} ${root.mathematician.lastName}` ); // "Ada Lovelace" const fruits = useStorage((root) => [...root.fruitsByName.values()], shallow); // ["🍎", "🍌", "🍒"] ``` Selectors you write can return _any_ value. You can use it to “just” select nodes from the root tree (first two examples above), but you can also return computed values, like in the last two examples. #### Selector functions must return a stable result One important rule is that selector functions **must return a stable result** to be efficient. This means calling the same selector twice with the same argument should return two results that are _referentially equal_. Special care needs to be taken when filtering or mapping over arrays, or when returning object literals, because those operations create new array or object instances on every call (the reason why is detailed [in the next section](#selectors-subscribe-to-updates)). #### Examples of stable results
✅ `(root) => root.animals` is stable
Liveblocks guarantees this. All nodes in the Storage tree are stable references as long as their contents don’t change.
️️⚠️ `(root) => root.animals.map(...)` is not stable
Because `.map()` creates a new array instance every time. You’ll need to use [`shallow`][] here.
✅ `(root) => root.animals.map(...).join(", ")` is stable
Because `.join()` ultimately returns a string and all primitive values are always stable.
#### Use a shallow comparison if the result isn’t stable If your selector function doesn’t return a stable result, it will lead to an explosion of unnecessary rerenders. In most cases, you can use a [`shallow`][] comparison function to loosen the check: ```tsx import { shallow } from "@liveblocks/react"; // ❌ Bad - many unnecessary rerenders const uncheckedItems = useStorage((root) => root.todos.filter((item) => !item.done) ); // ✅ Great const uncheckedItems = useStorage( (root) => root.todos.filter((item) => !item.done), shallow // 👈 The fix! ); ``` If your selector function constructs complex objects, then a [`shallow`][] comparison may not suffice. In those advanced cases, you can provide your own custom comparison function, or use `_.isEqual` from Lodash. ### Selectors auto-subscribe to updates [#selectors-subscribe-to-updates] Selectors effectively automatically subscribe your components to updates to the selected or computed values. This means that your component will **automatically rerender** when the selected value changes. Using **multiple selector hooks** within a single React component is perfectly fine. Each such hook will individually listen for data changes. The component will rerender if _at least one_ of the hooks requires it. If more than one selector returns a new value, the component _still only rerenders once_. Technically, deciding if a rerender is needed works by re-running your selector function `(root) => root.child` every time something changes inside Liveblocks storage. Anywhere. That happens often in a busy multiplayer app! The reason why this is still no problem is that even though `root` will be a different value on every change, `root.child` will not be if it didn’t change (due to how Liveblocks internally uses structural sharing). Only once the returned value is different from the previously returned value, the component will get rerendered. Otherwise, your component will just remain idle. Consider the case: ```tsx function Component() { const animals = useStorage((root) => root.animals); } ``` And the following timeline: - First render, `root.animals` initially is `["🦁", "🦊", "🐵"]`. - Then, something unrelated elsewhere in Storage is changed. In response to the change, `root.animals` gets re-evaluated, but it still returns the same (unchanged) array instance. - Since the value didn’t change, no rerender is needed. - Then, someone removes an animal from the list. In response to the change, `root.animals` gets re-evaluated, and now it returns `["🦁", "🦊"]`. - Because the previous value and this value are different, the component will rerender, seeing the updated value. [`createclient`]: /docs/api-reference/liveblocks-client#createClient [`createroomcontext`]: /docs/api-reference/liveblocks-react#createRoomContext [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`lostconnectiontimeout`]: /docs/api-reference/liveblocks-client#createClientLostConnectionTimeout [`room.history`]: /docs/api-reference/liveblocks-client#Room.history [`roomprovider`]: /docs/api-reference/liveblocks-react#RoomProvider [`liveblocksprovider`]: /docs/api-reference/liveblocks-react#LiveblocksProvider [`usemutation`]: /docs/api-reference/liveblocks-react#useMutation [`usestorage`]: /docs/api-reference/liveblocks-react#useStorage [`useself`]: /docs/api-reference/liveblocks-react#useSelf [`useothers`]: /docs/api-reference/liveblocks-react#useOthers [`useothersmapped`]: /docs/api-reference/liveblocks-react#useOthersMapped [`useothersconnectionids`]: /docs/api-reference/liveblocks-react#useOthersConnectionIds [`useother`]: /docs/api-reference/liveblocks-react#useOther [`uselostconnectionlistener`]: /docs/api-reference/liveblocks-react#useLostConnectionListener [`clientsidesuspense`]: /docs/api-reference/liveblocks-react#ClientSideSuspsnse [`usebroadcastevent`]: /docs/api-reference/liveblocks-react#useBroadcastEvent [`usethreads`]: /docs/api-reference/liveblocks-react#useThreads [`useinboxnotifications`]: /docs/api-reference/liveblocks-react#useInboxNotifications [`usesyncstatus`]: /docs/api-reference/liveblocks-react#useSyncStatus [`useerrorlistener`]: /docs/api-reference/liveblocks-react#useErrorListener [`room`]: /docs/api-reference/liveblocks-client#Room [`shallow`]: /docs/api-reference/liveblocks-react#shallow [`resolveusers`]: /docs/api-reference/liveblocks-client#resolveUsers [`resolveroomsinfo`]: /docs/api-reference/liveblocks-client#resolveRoomsInfo [selector]: /docs/api-reference/liveblocks-react#selectors [how selectors work]: /docs/api-reference/liveblocks-react#selectors [suspense version]: /docs/api-reference/liveblocks-react#Suspense [connection status example]: https://liveblocks.io/examples/connection-status/nextjs [`atob`]: https://developer.mozilla.org/en-US/docs/Web/API/atob [`base-64`]: https://www.npmjs.com/package/base-64 [`websocket`]: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket [`ws`]: https://www.npmjs.com/package/ws [`fetch`]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API [`node-fetch`]: https://npmjs.com/package/node-fetch --- meta: title: "@liveblocks/redux" parentTitle: "API Reference" description: "API Reference for the @liveblocks/redux package" alwaysShowAllNavigationLevels: false --- `@liveblocks/redux` provides you with [Redux](https://react-redux.js.org/) bindings for our realtime collaboration APIs, built on top of WebSockets. Read our [getting started](/docs/get-started) guides to learn more. ## Enhancer Enhancer that lets you connect a Redux state to Liveblocks Presence and Storage features. ```js import { liveblocksEnhancer } from "@liveblocks/redux"; import { configureStore } from "@reduxjs/toolkit"; const store = configureStore({ reducer: /* reducer */, enhancers: [ liveblocksEnhancer({ client, storageMapping: {}, presenceMapping: {}, }), ], }); ``` ### client [#enhancer-option-client] See different authentication methods in the [`createClient`][] method. ```js highlight="1,4-6,12" import { createClient } from "@liveblocks/client"; import { liveblocksEnhancer } from "@liveblocks/redux"; const client = createClient({ authEndpoint: "/api/liveblocks-auth", }); const store = configureStore({ reducer: /* reducer */, enhancers: [ liveblocksEnhancer({ client, }), ], }); ``` ### presenceMapping [#enhancer-option-presence-mapping] Mapping used to synchronize a part of your Redux state with one Liveblocks room presence. ```js highlight="20" import { liveblocksEnhancer } from "@liveblocks/redux"; const initialState = { cursor: { x: 0, y: 0 }, }; const slice = createSlice({ name: "state", initialState, reducers: { /* reducers */ }, }); const store = configureStore({ reducer: slice.reducer, enhancers: [ liveblocksEnhancer({ client, presenceMapping: { cursor: true }, }), ], }); ``` ### storageMapping [#enhancer-option-storage-mapping] Mapping used to synchronize a part of your Redux state with one Liveblocks Room storage. ```js highlight="20" import { liveblocksEnhancer } from "@liveblocks/redux"; const initialState = { scientist: { name: "" }, }; const slice = createSlice({ name: "state", initialState, reducers: { /* reducers */ }, }); const store = configureStore({ reducer: slice.reducer, enhancers: [ liveblocksEnhancer({ client, storageMapping: { scientist: true }, }), ], }); ``` ## Actions ### ENTER [#actions-enter] Dispatch `enterRoom` action to enter a room and start sync it with Redux state. - `roomId`: The room’s ID. ```js import { actions } from "@liveblocks/redux"; import { useDispatch, useSelector } from "react-redux"; const dispatch = useDispatch(); dispatch(actions.enterRoom("roomId")); ``` ### LEAVE [#actions-leave] Dispatch `leaveRoom` action to leave the current room and stop syncing it with Redux state. ```js import { actions } from "@liveblocks/redux"; import { useDispatch, useSelector } from "react-redux"; const dispatch = useDispatch(); dispatch(actions.leaveRoom()); ``` ## state.liveblocks [#liveblocks-state] Liveblocks extra state attached by the enhancer. ### others [#liveblocks-state-others] Other users in the room. Empty when no room is currently synced. ```js const others = useSelector((state) => state.liveblocks.others); ``` ### isStorageLoading [#liveblocks-state-is-storage-loading] Whether the room storage is currently loading. ```js const connection = useSelector((state) => state.liveblocks.isStorageLoading); ``` ### status [#liveblocks-state-status] Gets the current WebSocket connection status of the room. ```js const { liveblocks: { status }, } = useStore(); ``` The possible value are: `initial`, `connecting`, `connected`, `reconnecting`, or `disconnected`. [`createclient`]: /docs/api-reference/liveblocks-client#createClient [`status`]: /docs/api-reference/liveblocks-redux#liveblocks-state-status --- meta: title: "@liveblocks/yjs" parentTitle: "API Reference" description: "API Reference for the @liveblocks/yjs package" alwaysShowAllNavigationLevels: false --- `@liveblocks/yjs` is a [Yjs](https://yjs.dev/) provider enabling you to use Liveblocks as the hosted back end of your realtime collaborative application. Read our [getting started](/docs/get-started) guides to learn more. ## Setup To set up Yjs, it’s recommended to use [`getYjsProviderForRoom`][]. It’s no longer recommended to use [`LiveblocksYjsProvider`][] directly, as issues may happen when dynamically switching between rooms. ### React In React, pass your room object with [`useRoom`][]. It’s fine to use this function in your React components. From here, you can access your `Y.Doc`. ```ts import { useRoom } from "@liveblocks/react"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; function App() { const room = useRoom(); const yProvider = getYjsProviderForRoom(room); const yDoc = yProvider.getYDoc(); // ... } ``` ### JavaScript In JavaScript, pass the room retrieved with [`client.enterRoom`][]. ```tsx import { createClient } from "@liveblocks/client"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; const client = createClient({ // Options // ... }); const { room, leave } = client.enterRoom("my-room-id", { // Options // ... }); const yProvider = getYjsProviderForRoom(room); const yDoc = yProvider.getYDoc(); ``` ## getYjsProviderForRoom [#getYjsProviderForRoom] Gets the current or creates a new [`LiveblocksYjsProvider`][] for a room—this is the recommended way to use Yjs. This provider will automatically be cleaned up when the room is destroyed, so you don’t need to destroy the provider manually. The second argument is the [`LiveblocksYjsProvider`][] options. ```ts import { getYjsProviderForRoom } from "@liveblocks/yjs"; const yProvider = getYjsProviderForRoom(room, { // Options // ... }); const yDoc = yProvider.getYDoc(); ``` The [`LiveblocksYjsProvider`][] for the room. The provider is automatically cleaned up and destroyed when necessary. Fetch your [`Y.Doc`][] with [`LiveblocksYjsProvider.getYDoc`](#LiveblocksYjsProvider.getYDoc). The Liveblocks room, retrieved with [`useRoom`][] or [`client.enterRoom`][]. This option will load subdocs automatically. This option enables Yjs permanent user data class used by some libraries for tracking changes by author. Experimental. Enable offline support using IndexedDB. This means the after the first load, documents will be stored locally and load instantly. Experimental. Use V2 encoding. ## LiveblocksYjsProvider [#LiveblocksYjsProvider] `LiveblocksYjsProvider` is a [Yjs provider](https://github.com/yjs/yjs#providers) that allows you to connect a Yjs document to Liveblocks. Any changes you make to the document will be stored on Liveblocks servers and synchronized with other clients in the room. We generally recommend getting your Liveblocks Yjs provider with [`getYjsProviderForRoom`][] as it overcomes problems caused when dynamically switching between rooms. You can connect by creating a Yjs document, then passing it to `LiveblocksYjsProvider` along with the currently connected Liveblocks room. ```ts highlight="13-15" import * as Y from "yjs"; import { createClient } from "@liveblocks/client"; import { LiveblocksYjsProvider } from "@liveblocks/yjs"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); const { room, leave } = client.enterRoom("your-room-id"); // Create Yjs document and provider const yDoc = new Y.Doc(); const yProvider = new LiveblocksYjsProvider(room, yDoc, { // Options // ... }); ``` The Liveblocks room, retrieved with [`useRoom`][] or [`client.enterRoom`][]. The [`Y.Doc`][] for the document. This option will load subdocs automatically. This option enables Yjs permanent user data class used by some libraries for tracking changes by author. Experimental. Enable offline support using IndexedDB. This means the after the first load, documents will be stored locally and load instantly. Experimental. Use V2 encoding. ### LiveblocksYjsProvider.getYDoc Returns the current room’s root [`Y.Doc`][]. ```ts // Root Y.Doc for the room const yDoc = yProvider.getYDoc(); ``` ### LiveblocksYjsProvider.awareness [#LiveblocksYjsProvider.awareness] The [awareness](#Awareness) instance attached to the provider. ```ts // Yjs awareness const awareness = yProvider.awareness; ``` ### LiveblocksYjsProvider.destroy Cleanup function. Destroys the [`LiveblocksYjsProvider`][] instance and removes all resources. ```ts // Clean up yProvider yProvider.destroy(); ``` ### LiveblocksYjsProvider.on("sync") [#LiveblocksYjsProvider.on.sync] Add an event listener for the `sync` event. The `sync` event is triggered when the client has received content from the server. Can be used to fire events when the document has loaded. ```ts // Listen for the sync event yProvider.on("sync", (isSynced: boolean) => { if (isSynced === true) { // Yjs content is synchronized and ready } else { // Yjs content is not synchronized } }); ``` Aliased by `LiveblocksYjsProvider.on("synced")`. ```ts // "sync" and "synced" both listen to the same event yProvider.on("sync", (sync: boolean) => /* ... */); yProvider.on("synced", (sync: boolean) => /* ... */); ``` ### LiveblocksYjsProvider.off("sync") [#LiveblocksYjsProvider.off.sync] Remove an event listener for the `sync` event. The `sync` event is triggered when the client has received content from the server. Used to clean up [`LiveblocksYjsProvider.on("sync")`][]. ```ts const handleSync = (synced: boolean) => {}; yProvider.on("sync", handleSync); // Clean up sync event yProvider.off("sync", handleSync); ``` Aliased by `LiveblocksYjsProvider.on("synced")`. ```ts // "sync" and "synced" both listen to the same event yProvider.off("sync", (sync: boolean) => /* ... */); yProvider.off("synced", (sync: boolean) => /* ... */); ``` ### LiveblocksYjsProvider.once("sync") [#LiveblocksYjsProvider.once.sync] Add a one-time event listener for the `sync` event. The `sync` event is triggered when the client has received content from the server. Can be used to fire events when the document has loaded. ```ts // Listen for the sync event only once yProvider.once("sync", (isSynced: boolean) => { if (isSynced === true) { // Yjs content is synchronized and ready } else { // Yjs content is not synchronized } }); ``` Aliased by `LiveblocksYjsProvider.once("synced")`. ```ts // "sync" and "synced" both listen to the same event yProvider.once("sync", (sync: boolean) => /* ... */); yProvider.once("synced", (sync: boolean) => /* ... */); ``` ### LiveblocksYjsProvider.emit("sync") [#LiveblocksYjsProvider.emit.sync] Synchronously call each listener for the `sync` event in the order they were registered, passing the supplied arguments to each. ```ts // Call each listener and pass `true` as an argument yProvider.emit("sync", true); ``` Aliased by `LiveblocksYjsProvider.emit("synced")`. ```ts // "sync" and "synced" both listen to the same event yProvider.emit("sync" /* , ... */); yProvider.emit("synced" /* , ... */); ``` ### LiveblocksYjsProvider.synced [#LiveblocksYjsProvider.synced] Boolean. Returns whether the client is synchronized with the back end. ```ts // Check if Yjs content is synchronized with the server const isSynced: boolean = yProvider.synced; ``` ### LiveblocksYjsProvider.connect [#LiveblocksYjsProvider.connect] Does nothing, added for compatibility. Connections are handled by the [Liveblocks client](https://liveblocks.io/docs/api-reference/liveblocks-client#createClient). ### LiveblocksYjsProvider.disconnect [#LiveblocksYjsProvider.disconnect] Does nothing, added for compatibility. Connections are handled by the [Liveblocks client](https://liveblocks.io/docs/api-reference/liveblocks-client#createClient). ## Awareness [#Awareness] [`LiveblocksYjsProvider`][] instances have an `awareness` property, which is powered by [Liveblocks Presence](/docs/api-reference/liveblocks-client#Room.getPresence). You can pass it to various bindings which implement awareness, for example plugins that enable multiplayer cursors in text editors. ```ts const yDoc = new Y.Doc(); const yProvider = new LiveblocksYjsProvider(room, yDoc); // Yjs awareness const awareness = yProvider.awareness; ``` Because awareness is part of presence, it’s also accessible with [`room.getPresence`][] and [`useMyPresence`][] under the `__yjs` property. ```ts // Yjs awareness const awareness = room.getPresence().__yjs; ``` ### Awareness.doc [#Awareness.doc] The Yjs document that the current awareness instance is attached to. ```ts // The current Yjs document const yDoc: Y.Doc = awareness.doc; ``` ### Awareness.clientId [#Awareness.clientId] A unique number identifying which client this awareness object is attached to. ```ts // A unique number representing the current user const clientId: number = awareness.clientId; ``` ### Awareness.getLocalState [#Awareness.getLocalState] Get the current user’s awareness state. ```ts // The current user’s awareness const localState: unknown = awareness.getLocalState(); ``` ### Awareness.setLocalState [#Awareness.setLocalState] Set the current user’s awareness state. Accepts JSON-compatible objects. ```ts // Set the current user’s awareness awareness.setLocalState({ user: { name: "Jonathan", }, }); ``` ### Awareness.setLocalStateField [#Awareness.setLocalStateField] Set a single property in the current user’s awareness state. Accepts JSON-compatible objects, or `null` to remove a property. ```ts // Set a single property on the current user’s awareness awareness.setLocalStateField("user", { name: "Jonathan" }); ``` ### Awareness.getStates [#Awareness.getStates] Returns a `Map` of states for each client, with each user’s unique `clientId` as the key. ```ts // A Map of each user’s awareness state const states: Map = awareness.getStates(); ``` ### Awareness.states [#Awareness.states] A `Map` of states for each client, with each user’s unique `clientId` as the key. ```ts // A Map of each user’s awareness state const states: Map = awareness.states; ``` ### Awareness.meta [#Awareness.meta] Provided for compatibility, but generally not necessary. This would be used for handling user awareness timeouts, but internally awareness uses Liveblocks Presence, and this handles it for you. ```ts const meta: Map = awareness.meta; ``` ### Awareness.destroy [#Awareness.destroy] Provided for compatibility, but generally not necessary. Cleanup function. Destroys the [`Awareness`][] instance and removes all resources. Used internally by [`LiveblocksYjsProvider`][]. ```ts // Cleanup function awareness.destroy(); ``` ### Awareness.on("destroyed") [#Awareness.on.destroyed] Provided for compatibility, but generally not necessary. Add an event listener for the `destroy` event. The `destroy` event is triggered when [`awareness.destroy`][] has been called. ```ts awareness.on("destroyed", () => { // Awareness has been cleaned up }); ``` ### Awareness.off("destroyed") [#Awareness.off.destroyed] Provided for compatibility, but generally not necessary. Remove an event listener for the `destroy` event. The `destroy` event is triggered when [`awareness.destroy`][] has been called. Used to clean up [`Awareness.on("destroyed")`.] ```ts const handleDestroy = () => {}; awareness.on("destroyed", handleDestroy); // Clean up destroy event awareness.off("destroyed", handleDestroy); ``` ### Awareness.once("destroyed") [#Awareness.once.destroyed] Provided for compatibility, but generally not necessary. Add a one-time event listener for the `destroy` event. The `destroy` event is triggered when [`awareness.destroy`][] has been called. ```ts awareness.once("destroyed", () => { // Awareness has been cleaned up }); ``` ### Awareness.emit("destroyed") [#Awareness.emit.destroyed] Synchronously call each listener for the `destroy` event in the order they were registered, passing the supplied arguments to each. ```ts // Call each listener and pass `true` as an argument awareness.emit("destroy", true); ``` [`@liveblocks/react`]: /docs/api-reference/liveblocks-react [`useRoom`]: /docs/api-reference/liveblocks-react#useRoom [`room.getPresence`]: /docs/api-reference/liveblocks-client#Room.getPresence [`client.enterRoom`]: /docs/api-reference/liveblocks-client#Client.enterRoom [`useMyPresence`]: /docs/api-reference/liveblocks-react#useMyPresence [`getYjsProviderForRoom`]: #getYjsProviderForRoom [`LiveblocksYjsProvider`]: #LiveblocksYjsProvider [`LiveblocksYjsProvider.awareness]: #LiveblocksYjsProvider.awareness [`LiveblocksYjsProvider.destroy]: #LiveblocksYjsProvider.destroy [`LiveblocksYjsProvider.on("sync")`]: #LiveblocksYjsProvider.on.sync [`LiveblocksYjsProvider.off("sync")`]: #LiveblocksYjsProvider.off.sync [`LiveblocksYjsProvider.once("sync")`]: #LiveblocksYjsProvider.once.sync [`LiveblocksYjsProvider.emit("sync")`]: #LiveblocksYjsProvider.emit.sync [`LiveblocksYjsProvider.synced`]: #LiveblocksYjsProvider.synced [`LiveblocksYjsProvider.connect`]: #LiveblocksYjsProvider.connect [`LiveblocksYjsProvider.disconnect`]: #LiveblocksYjsProvider.disconnect [`Awareness`]: #Awareness [`Awareness.doc`]: #Awareness.doc [`Awareness.clientId]: #Awareness.clientId [`Awareness.getLocalState`]: #Awareness.getLocalState [`Awareness.setLocalState`]: #Awareness.setLocalState [`Awareness.setLocalStateField`]: #Awareness.setLocalStateField [`Awareness.getStates`]: #Awareness.getStates [`Awareness.states`]: #Awareness.states [`Awareness.destroy`]: #Awareness.destroy [`Awareness.meta`]: #Awareness.meta [`Awareness.on("destroyed")`]: #Awareness.on.destroyed [`Awareness.off("destroyed")`]: #Awareness.off.destroyed [`Awareness.once("destroyed")`]: #Awareness.once.destroyed [`Awareness.emit("destroyed")`]: #Awareness.emit.destroyed [`Y.Doc`]: https://docs.yjs.dev/api/y.doc --- meta: title: "@liveblocks/zustand" parentTitle: "API Reference" description: "API Reference for the @liveblocks/zustand package" alwaysShowAllNavigationLevels: false --- `@liveblocks/zustand` provides you with [Zustand](https://docs.pmnd.rs/zustand) bindings for our realtime collaboration APIs, built on top of WebSockets. Read our [getting started](/docs/get-started) guides to learn more. ## Middleware The `liveblocks` middleware lets you connect a Zustand state to Liveblocks Presence and Storage features. ```js highlight="2,5,9-13" import create from "zustand"; import { liveblocks } from "@liveblocks/zustand"; const useStore = create( liveblocks( (set) => ({ /* state and actions */ }), { client, presenceMapping: {}, storageMapping: {}, } ) ); ``` ### client [#middleware-option-client] See different authentication methods in the [`createClient`][] method. ```js import { createClient } from "@liveblocks/client"; import { liveblocks } from "@liveblocks/zustand"; const client = createClient({ authEndpoint: "/api/liveblocks-auth", }); liveblocks(/* Zustand config */, { client }) ``` ### presenceMapping [#middleware-option-presence-mapping] Mapping used to synchronize a part of your Zustand state with one Liveblocks Room presence. ```js highlight="8" const useStore = create( liveblocks( (set) => ({ cursor: { x: 0, y: 0 }, }), { client, presenceMapping: { cursor: true }, } ) ); ``` ### storageMapping [#middleware-option-storage-mapping] Mapping used to synchronize a part of your Zustand state with one Liveblocks room storage. ```js highlight="8" const useStore = create( liveblocks( (set) => ({ scientist: { name: "" }, }), { client, storageMapping: { scientist: true }, } ) ); ``` ## state.liveblocks [#liveblocks-state] Liveblocks extra state attached by the liveblocks. ### enterRoom [#liveblocks-state-enter-room] Enters a room and starts syncing it with your Zustand state. - `roomId`: The room’s ID. ```js const { liveblocks: { enterRoom }, } = useStore(); enterRoom("roomId"); ``` If this is the first time you’re entering the room, the room is initialized from your local Zustand state (only for the keys mentioned in your `storageMapping` configuration). ### leaveRoom [#liveblocks-state-leave-room] Leaves the current room and stops syncing it with Zustand state. ```js const { liveblocks: { leaveRoom }, } = useStore(); leaveRoom(); ``` ### room [#liveblocks-state-room] The [`Room`][] currently synced to your Zustand state. ```js const { liveblocks: { room }, } = useStore(); ``` ### others [#liveblocks-state-others] Other users in the room. Empty when no room is currently synced. ```js const { liveblocks: { others }, } = useStore(); ``` ### isStorageLoading [#liveblocks-state-is-storage-loading] Whether or not the room storage is currently loading. ```js const { liveblocks: { isStorageLoading }, } = useStore(); ``` ### status [#liveblocks-state-status] Gets the current WebSocket connection status of the room. ```js const { liveblocks: { status }, } = useStore(); ``` The possible value are: `initial`, `connecting`, `connected`, `reconnecting`, or `disconnected`. [`createclient`]: /docs/api-reference/liveblocks-client#createClient [`room`]: /docs/api-reference/liveblocks-client#room [`status`]: /docs/api-reference/liveblocks-zustand#liveblocks-state-status --- meta: title: "API v1 Endpoints" parentTitle: "API Reference" description: "API Reference for the REST v1 endpoints" --- Manage a room and its storage using the Liveblocks REST API endpoints. They’ll help you manage your data and extend Liveblocks’ functionality. You’ll find the API base URL below. ```bash https://liveblocks.net/api/v1/ ``` This is the API v1 reference, learn more about the latest version on the [API v2 reference][]. ## Authentication To use the API, you need to add a JWT token to the request’s authorization header: ```bash curl https://liveblocks.net/api/v1/* \ -H "Authorization: Bearer YOUR_JWT_TOKEN" ``` You can get a JWT token by calling our authorization endpoint, using your secret key (accessible from the dashboard). The token will be valid for one hour. ```bash curl https://liveblocks.io/api/authorize \ -H "Authorization: Bearer YOUR_SECRET_KEY" ``` ### Example response [@hidden] ```json { "token": "YOUR_JWT_TOKEN" } ``` ## Get room storage Get the room storage data as a JSON using the endpoint below. ### Some implementation details [@hidden] - Each Liveblocks data structure is represented by a JSON element having two properties: - `"liveblocksType"`: [`"LiveObject"`][] | [`"LiveList"`][] | [`"LiveMap"`][] - `"data"` => contains the nested data structures (children) and data. - The root is always a `LiveObject`. ### Example response [@hidden] ```json { "liveblocksType": "LiveObject", "data": { "aLiveObject": { "liveblocksType": "LiveObject", "data": { "a": 1 } }, "aLiveList": { "liveblocksType": "LiveList", "data": ["a", "b"] }, "aLiveMap": { "liveblocksType": "LiveMap", "data": { "a": 1, "b": 2 } } } } ``` ## Initialize room storage Initialize a room storage using the following endpoint. The storage of the room you’re initializing must be empty. The new storage data can be passed as a JSON in the request body. ### Some implementation details [@hidden] - The format of the request body is the same as what's returned by the get storage endpoint. - For each Liveblocks data structure that you want to create, you need a JSON element having two properties: - `"liveblocksType"`: [`"LiveObject"`][] | [`"LiveList"`][] | [`"LiveMap"`][] - `"data"` => contains the nested data structures (children) and data. - The root's type can only be `LiveObject`. ### Example request body [@hidden] ```json { "liveblocksType": "LiveObject", "data": { "aLiveObject": { "liveblocksType": "LiveObject", "data": { "a": 1 } }, "aLiveList": { "liveblocksType": "LiveList", "data": ["a", "b"] }, "aLiveMap": { "liveblocksType": "LiveMap", "data": { "a": 1, "b": 2 } } } } ``` ## Delete room storage Delete all elements of the room storage using the following endpoint. ## Get room users Get the current list of users connected to a room. ### Some implementation details [@hidden] - User's custom properties `id` and `info` can be set during the authentication to the room, see [`authorize`][]. ### Example response [@hidden] ```json { "data": [ { "type": "user", "connectionId": 0, "id": "customUserId", "info": {} } ] } ``` [api v2 reference]: /docs/api-reference/rest-api-endpoints [`"livelist"`]: /docs/api-reference/liveblocks-client#LiveList [`"livemap"`]: /docs/api-reference/liveblocks-client#LiveMap [`"liveobject"`]: /docs/api-reference/liveblocks-client#LiveObject [`authorize`]: /docs/api-reference/liveblocks-node#authorize --- meta: title: "Authentication" description: "Learn more about authenticating your Liveblocks application" --- Liveblocks provides different methods to authenticate your application using your public and secret API keys. For any production application, you should use your secret key to enable [access token](#access-tokens) or [ID token](#id-tokens) authentication. These methods can be used to control access to your rooms and data. We don’t recommend using your public API key in production, as it makes it possible for end users to access any room’s data. It’s also difficult for us to accurately measure your monthly collaborating users without any user information. We recommend using your secret key instead, see below. ## Authentication methods Secret key authentication in Liveblocks relies on generating [JSON Web Tokens](https://en.wikipedia.org/wiki/JSON_Web_Token) (JWTs), and then passing these to your client. There are two different types of authentication tokens you can generate and it’s important to decide on which you need before setting up your application. ### Access tokens [#access-tokens] [Access token](/docs/authentication/access-token) authentication allows you to handle permissions yourself. When a user authenticates, it’s up to you to let Liveblocks know which rooms they should be allowed inside. This means that you need to manually keep track of which users should be allowed in which rooms, and apply these permissions yourself each time a user connects.
An access token granting entry to a room
In the diagram above, you can see that `olivier@example.com`’s access token is allowing him into the `Vu78Rt:design:9Hdu73` room. A naming pattern like this is necessary for your rooms when using access tokens, and it works well for simple permissions. However, if you need complex permissions, we recommend ID tokens. ### ID tokens [#id-tokens] [ID token](/docs/authentication/id-token) authentication allows Liveblocks to handle permissions for you. This means that when you create or modify a room, you can set a user’s permissions on the room itself, this acting as a source of truth. Later, when a user tries to enter a room, Liveblocks will automatically check if the user has permission, and deny them access if the permissions aren’t set.
An ID token granting entry to a room
In the diagram above, `olivier@example.com`’s ID token verifies his identity, and when he tries to enter the `a32wQXid4A9` room, his permissions are then checked on the room itself. ID tokens are best if you need complex permissions set on different levels (e.g. workspace → team → user). ## Choose a method - **Access token authentication** is best for prototyping, as it’s easy to set up. It’s also ideal if you only need simple permissions, and you’d prefer handle these without relying on Liveblocks. - **ID token authentication** is best if you’d like Liveblocks to automatically prevent access to the wrong users. It allows you to set different levels of permissions on different users and groups. } /> } /> --- meta: title: "Authenticate with access tokens" parentTitle: "Authentication" description: "Learn more about access token permissions" --- Access token authentication allows you to handle permissions yourself. When a user authenticates, it’s up to you to let Liveblocks know which rooms they should be allowed inside. This means that you need to manually keep track of which users should be allowed in which rooms, and apply these permissions yourself each time a user connects.
An access token granting entry to a room
If you’re looking to build an application with permissions at organization, group, and user levels, we recommend using [ID tokens](/docs/authentication/id-token) instead. Access tokens have [limitations when granting nested permissions](#limitations). ## Authenticating Authenticating with access tokens means creating a [JSON Web Token](https://en.wikipedia.org/wiki/JSON_Web_Token) (JWT) that grants the current user permission to enter certain rooms when connecting to Liveblocks. An access token is created by calling [`liveblocks.prepareSession`](/docs/api-reference/liveblocks-node#access-tokens) then by allowing access to certain rooms. ```ts const session = liveblocks.prepareSession("olivier@example.com"); // Giving write access to one room, then read access to multiple rooms with a wildcard session.allow("Vu78Rt:design:9Hdu73", session.FULL_ACCESS); session.allow("Vu78Rt:product:*", session.READ_ACCESS); const { body, status } = await session.authorize(); // '{ token: "j6Fga7..." }' console.log(body); ``` **Before using access tokens, it’s recommended to read through this entire page**, as it explains helpful practices for granting access to rooms. However, if you’d like to get set up now, you can select your framework and read more later. } /> } /> } /> } /> } /> } /> ## Permissions [#permissions] ### Default permissions When creating rooms automatically with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider) **every room is publicly available**. If you’d like to prevent unauthenticated access to your room data, you must instead set permissions on your back end using the [Liveblocks Node.js package](/docs/api-reference/rest-api-endpoints), or the [REST API](/docs/api-reference/rest-api-endpoints). #### Permission types [#permission-types] There are three permission values that you can set as default on rooms.
`["room:write"]`
Full access. Enables people to view and edit the room. `isReadOnly` is `false`. Also known as `session.FULL_ACCESS`.
`["room:read", "room:presence:write"]`
Read access with presence. Enables people to edit their presence, but only view the room’s storage. `isReadOnly` is `true`. Also known as `session.READ_ACCESS`.
`[]`
Private. Only users that have been given explicit access can enter the room.
#### Setting default permissions The `defaultAccesses` level is used to set the default permissions of the entire room.
Access denied illustration
We can use the [`liveblocks.createRoom`](/docs/api-reference/rest-api-endpoints#post-rooms) to create a new room with private access by default: ```ts highlight="2" const room = await liveblocks.createRoom("Vu78Rt:design:9Hdu73", { defaultAccesses: [], }); ``` We could also later modify the value with [`liveblocks.updateRoom`](/docs/api-reference/liveblocks-node#post-rooms-roomId), in this example turning the room read-only: ```ts highlight="2" const room = await liveblocks.updateRoom("Vu78Rt:design:9Hdu73", { defaultAccesses: ["room:read", "room:presence:write"], }); ``` ### Advanced permissions Along with default permissions, you can assign advanced permissions to individual users. These permissions will override any default permissions. When granting advanced permissions using access tokens, it’s recommended to use a naming pattern for your room IDs. This makes it easy to use wildcard permissions, allowing you to authenticate access to multiple rooms at once. One scenario where this is helpful, is when rooms and users in your app are part of an organization (or workspace), and you need to permit users entry to each room that’s part of this. #### Organization hierarchy Let’s picture an organization, a customer in your product. This organization has a number of groups (or teams), and each group can create documents that other members of the group can view.
An organization with documents in different teams
In your application, each organization, group, and document has a unique ID, and we can use these to create a naming pattern for your rooms. For example, in the diagram above, the Acme organization (`Vu78Rt`) has a Product group (`product`) with two documents inside (`6Dsw12`, `L2hr8p`). #### Naming pattern An example of a naming pattern would be to combine the three IDs into a unique room ID separating them with symbols, such as `::`. A room ID following this pattern may look like `Vu78Rt:product:6Dsw1z`.
Splitting a room ID into the pattern detailed above
This example is not a strict naming pattern you must follow, and you can use any pattern you like. Take care to avoid using your separator character in any other part of the room ID. #### Wildcard permissions Assuming you’re using the naming pattern displayed above, you can then grant access to multiple rooms at once using wildcards.
An access token using a wildcard to access multiple rooms
In the image above, you can see that _Olivier_ has access to multiple _product_ rooms, thanks to the `Vu78Rt:product:*` wildcard rule. This is how he was authorized: ```ts const session = liveblocks.prepareSession("olivier@example.com"); // Giving full access to one room session.allow("Vu78Rt:design:9Hdu73", session.FULL_ACCESS); // Give full access to every room with an ID beginning with "Vu78Rt:product:" session.allow("Vu78Rt:product:*", session.FULL_ACCESS); const { body, status } = await session.authorize(); ``` Note that you can only use a wildcard at the end of a room ID. ```jsx // ❌ Wildcard must be at the end of the room ID session.allow("Vu78Rt:*:product", session.FULL_ACCESS); // ✅ Valid wildcard session.allow("Vu78Rt:product:*", session.FULL_ACCESS); ``` #### Read-only access Should we wish to grant read-only access to each room in the organization, we then add another line to enable this. ```ts highlight="9-10" const session = liveblocks.prepareSession("olivier@example.com"); // Giving full access to one room session.allow("Vu78Rt:design:9Hdu73", session.FULL_ACCESS); // Give full access to every room with an ID beginning with "Vu78Rt:product:" session.allow("Vu78Rt:product:*", session.FULL_ACCESS); // Give read-only access to every room in the `Vu78Rt` organization session.allow("Vu78Rt:*", session.READ_ACCESS); const { body, status } = await session.authorize(); ``` #### Limitations [#limitations] There’s a limitation with access tokens related to granting access to individual rooms that are part of groups. Let’s say a user has been given access to every `product` room in their organization. ```tsx // Access to every `product` room session.allow("Vu78Rt:product:*", session.FULL_ACCESS); ``` This user is able to enter `product` rooms, but has no access to any `design` rooms.
An access token using a wildcard to access product rooms
Let’s say the user is invited to a `design` room via share menu—how would we grant them access?
Inviting Olivier to the `Vu78Rt:design:9Hdu73` room
We can’t give them access to _every_ `design` room with a wildcard, as they should only have permission for _one_. ```tsx // ❌ Access to every `design` room session.allow("Vu78Rt:design:*", session.FULL_ACCESS); ``` Instead, we would have to manually find the exact room ID without a wildcard, and apply it ourselves—the naming pattern doesn’t work for this room. ```tsx // Access to just this `design` room, but not scalable session.allow("Vu78Rt:design:9Hdu73", session.FULL_ACCESS); ``` To use access tokens you’d have to manually keep track of every room ID where the naming pattern doesn’t apply. This isn’t ideal, and it also doesn’t scale, as the token will need to be refreshed whenever access is granted to new rooms for this to work correctly. ##### Building more complex permissions For this reason, **[we recommend using ID tokens for complex permissions](/docs/authentication/id-token)**. ID token authentication allows you to attach permissions to each room when it’s created or modified, which means you don’t need to check permissions yourself, and no naming pattern is required. #### Migrating your current rooms IDs If your application already has rooms, it’s possible to rename their IDs to be compatible with a naming pattern. Learn more in our [room ID migration guide](/docs/guides/how-to-rename-room-ids-and-successfully-migrate-users). ## Select your framework [#select-your-framework] Select your framework for specific instructions on setting up access token authentication. } /> } /> } /> } /> } /> } /> --- meta: title: "Set up access token permissions with Express" parentTitle: "Authentication" description: "Learn how to setup access token permissions with Express." --- Follow the following steps to start configure your authentication endpoint where ## Quickstart Install the `liveblocks/node` package ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users need permission to interact with rooms, and you can permit access in an `api/liveblocks-auth` endpoint by creating the `liveblocks-auth.ts` file with the following code. In here you can implement your security and define the rooms that your user can enter. With access tokens, you should always use a [naming pattern](/docs/authentication/access-token#permissions) for your room IDs, as this enables you to easily allow access to a range of rooms at once. In the code snippet below, we’re using a naming pattern and wildcard `*` to give the user access to every room in their organization, and every room in their group. ```ts file="liveblocks-auth.ts" const express = require("express"); import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const app = express(); app.use(express.json()); app.post("/api/liveblocks-auth", (req, res) => { // Get the current user from your database const user = __getUserFromDB__(req); // Start an auth session inside your endpoint const session = liveblocks.prepareSession( user.id, { userInfo: user.metadata }, // Optional ); // Use a naming pattern to allow access to rooms with wildcards // Giving the user read access on their org, and write access on their group session.allow(`${user.organization}:*`, session.READ_ACCESS); session.allow(`${user.organization}:${user.group}:*`, session.FULL_ACCESS); // Authorize the user and return the result const { status, body } = await session.authorize(); return res.status(status).end(body); }); ``` Read [access token permission](/docs/authentication/access-token#permissions) to learn more about naming rooms and granting permissions with wildcards. Note that if a naming pattern doesn’t work for every room in your application, you can [grant access to individual rooms too](/docs/guides/how-to-grant-access-to-individual-rooms-with-access-tokens). Set up the client On the front end, you can now replace the `publicApiKey` option with `authEndpoint` pointing to the endpoint you just created. ```ts file="liveblocks.config.ts" import { createClient } from "@liveblocks/client"; const client = createClient({ authEndpoint: "/api/liveblocks-auth", }); ``` If you need to pass custom headers or data to your endpoint, you can use [authEndpoint as a callback](/docs/api-reference/liveblocks-client#createClientCallback) instead. ```ts file="liveblocks.config.ts" isCollapsed isCollapsable import { createClient } from "@liveblocks/client"; // Passing custom headers and body to your endpoint const client = createClient({ authEndpoint: async (room) => { const headers = { // Custom headers // ... "Content-Type": "application/json", }; const body = JSON.stringify({ // Custom body // ... room, }); const response = await fetch("/api/liveblocks-auth", { method: "POST", headers, body, }); return await response.json(); }, }); ``` ## More information Both `userId` and `userInfo` can then be used in your JavaScript application as such: ```ts const self = room.getSelf(); // or useSelf() in React console.log(self.id); console.log(self.info); ```
Auth diagram
--- meta: title: "Set up access token permissions with Firebase" parentTitle: "Authentication" description: "Learn how to setup access token permissions with Firebase." --- Follow the following steps to start configure your authentication endpoint and start building your own security logic. ## Quickstart Install the `liveblocks/node` package Let’s first install the `@liveblocks/node` package in your Firebase functions project. ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users need permission to interact with rooms, and you can permit access by creating a new Firebase [callable function](https://firebase.google.com/docs/functions/callable) as shown below. In here you can implement your security and define the rooms that your user can enter. With access tokens, you should always use a [naming pattern](/docs/authentication/access-token#permissions) for your room IDs, as this enables you to easily allow access to a range of rooms at once. In the code snippet below, we’re using a naming pattern and wildcard `*` to give the user access to every room in their organization, and every room in their group. ```js const functions = require("firebase-functions"); const { Liveblocks } = require("@liveblocks/node"); const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); exports.auth = functions.https.onCall(async (data, context) => { // Get the current user from your database const user = __getUserFromDB__(data); // Start an auth session inside your endpoint const session = liveblocks.prepareSession( user.id, { userInfo: user.metadata }, // Optional ); // Use a naming pattern to allow access to rooms with wildcards // Giving the user read access on their org, and write access on their group session.allow(`${user.organization}:*`, session.READ_ACCESS); session.allow(`${user.organization}:${user.group}:*`, session.FULL_ACCESS); // Authorize the user and return the result const { status, body } = await session.authorize(); return JSON.parse(body); }); ``` Read [access token permission](/docs/authentication/access-token#permissions) to learn more about naming rooms and granting permissions with wildcards. Note that if a naming pattern doesn’t work for every room in your application, you can [grant access to individual rooms too](/docs/guides/how-to-grant-access-to-individual-rooms-with-access-tokens). Set up the client On the front end, you can now replace the `publicApiKey` option with `authEndpoint` pointing to the endpoint you just created. ```js import { createClient } from "@liveblocks/client"; import firebase from "firebase"; import "firebase/functions"; firebase.initializeApp({ /* Firebase config */ }); const auth = firebase.functions().httpsCallable("liveblocks-auth"); // Create a Liveblocks client const client = createClient({ authEndpoint: async (room) => (await auth({ room })).data, }); ``` ## More information Both `userId` and `userInfo` can then be used in your JavaScript application as such: ```ts const self = room.getSelf(); // or useSelf() in React console.log(self.id); console.log(self.info); ```
Auth diagram
--- meta: title: "Set up access token permissions with Next.js" parentTitle: "Authentication" description: "Learn how to setup access token permissions with Next.js." --- Follow the following steps to start configure your authentication endpoint and start building your own security logic in Next.js’ `/app` directory. ## Quickstart Install the `liveblocks/node` package ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users need permission to interact with rooms, and you can permit access in an `api/liveblocks-auth` endpoint by creating the following `app/api/liveblocks-auth/route.ts` file. In here you can implement your security and define the rooms that your user can enter. With access tokens, you should always use a [naming pattern](/docs/authentication/access-token#permissions) for your room IDs, as this enables you to easily allow access to a range of rooms at once. In the code snippet below, we’re using a naming pattern and wildcard `*` to give the user access to every room in their organization, and every room in their group. ```ts file="app/api/liveblocks-auth/route.ts" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST(request: Request) { // Get the current user from your database const user = __getUserFromDB__(request); // Start an auth session inside your endpoint const session = liveblocks.prepareSession( user.id, { userInfo: user.metadata } // Optional ); // Use a naming pattern to allow access to rooms with wildcards // Giving the user read access on their org, and write access on their group session.allow(`${user.organization}:*`, session.READ_ACCESS); session.allow(`${user.organization}:${user.group}:*`, session.FULL_ACCESS); // Authorize the user and return the result const { status, body } = await session.authorize(); return new Response(body, { status }); } ``` Read [access token permission](/docs/authentication/access-token#permissions) to learn more about naming rooms and granting permissions with wildcards. Note that if a naming pattern doesn’t work for every room in your application, you can [grant access to individual rooms too](/docs/guides/how-to-grant-access-to-individual-rooms-with-access-tokens). Set up the client On the front end, you can now replace the `publicApiKey` prop on [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider) with `authEndpoint` pointing to the endpoint you just created. ```tsx ``` If you need to pass custom headers or data to your endpoint, you can use [authEndpoint as a callback](/docs/api-reference/liveblocks-client#LiveblocksProviderCallback) instead. ```tsx title="Pass custom headers" isCollapsed isCollapsable { // Passing custom headers and body to your endpoint const headers = { // Custom headers // ... "Content-Type": "application/json", }; const body = JSON.stringify({ // Custom body // ... room, }); const response = await fetch("/api/liveblocks-auth", { method: "POST", headers, body, }); return await response.json(); }} /> ``` Attach metadata to users Optionally, you can attach static metadata to each user, which will be accessible in your app. First you need to define the types in your config file, under `UserMeta["info"]`. ```ts file="liveblocks.config.ts" highlight="7-11" declare global interface Liveblocks { UserMeta: { id: string; // Example, use any JSON-compatible data in your metadata info: { name: string; avatar: string; colors: string[]; } } // Other type definitions // ... } } ``` When authenticating, you can then pass the user’s metadata to `prepareSession` in the endpoint we’ve just created. ```ts file="app/api/liveblocks-auth/route.ts" highlight="8-12" // Get the current user from your database const user = __getUserFromDB__(request); // Start an auth session inside your endpoint const session = liveblocks.prepareSession( user.id, { userInfo: { name: user.name, avatar: user.avatarUrl, colors: user.colorArray, } } ); ``` User metadata has now been set! You can access this information in your app through [`useSelf`](/docs/api-reference/liveblocks-react#useSelf). ```tsx highlight="4" export { useSelf } from "@liveblocks/react/suspense"; function Component() { const { name, avatar, colors } = useSelf((me) => me.info); } ``` Bear in mind that if you’re using the [default Comments components](/docs/api-reference/liveblocks-react-ui#Components), you must specify a `name` and `avatar` in `userInfo`. ## More information Both `userId` and `userInfo` can then be used in your React application as such: ```ts const self = useSelf(); console.log(self.id); console.log(self.info); ```
Auth diagram
--- meta: title: "Set up access token permissions with Nuxt.js" parentTitle: "Authentication" description: "Learn how to setup access token permissions with Nuxt.js." --- Follow the following steps to start configure your authentication endpoint and start building your own security logic. ## Quickstart Install the `liveblocks/node` package ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users need permission to interact with rooms, and you can permit access in an `api/liveblocks-auth` endpoint by creating the `server/api/liveblocks-auth.ts` file with the following code. This is where you will implement your security and define the rooms that the user has access to. With access tokens, you should always use a [naming pattern](/docs/authentication/access-token#permissions) for your room IDs, as this enables you to easily allow access to a range of rooms at once. In the code snippet below, we’re using a naming pattern and wildcard `*` to give the user access to every room in their organization, and every room in their group. ```ts file="server/api/liveblocks-auth.ts" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export default defineEventHandler(async (event) => { // Get the current user from your database const user = __getUserFromDB__(event); // Start an auth session inside your endpoint const session = liveblocks.prepareSession( user.id, { userInfo: user.metadata }, // Optional ); // Use a naming pattern to allow access to rooms with wildcards // Giving the user read access on their org, and write access on their group session.allow(`${user.organization}:*`, session.READ_ACCESS); session.allow(`${user.organization}:${user.group}:*`, session.FULL_ACCESS); // Authorize the user and return the result const { status, body } = await session.authorize(); return body; }) ``` Read [access token permission](/docs/authentication/access-token#permissions) to learn more about naming rooms and granting permissions with wildcards. Note that if a naming pattern doesn’t work for every room in your application, you can [grant access to individual rooms too](/docs/guides/how-to-grant-access-to-individual-rooms-with-access-tokens). Set up the client On the front end, you can now replace the `publicApiKey` option with `authEndpoint` pointing to the endpoint you just created. ```ts file="liveblocks.config.ts" import { createClient } from "@liveblocks/client"; const client = createClient({ authEndpoint: "/api/liveblocks-auth", }); ``` If you need to pass custom headers or data to your endpoint, you can use [authEndpoint as a callback](/docs/api-reference/liveblocks-client#createClientCallback) instead. ```ts file="liveblocks.config.ts" isCollapsed isCollapsable import { createClient } from "@liveblocks/client"; // Passing custom headers and body to your endpoint const client = createClient({ authEndpoint: async (room) => { const headers = { // Custom headers // ... "Content-Type": "application/json", }; const body = JSON.stringify({ // Custom body // ... room, }); const response = await fetch("/api/liveblocks-auth", { method: "POST", headers, body, }); return await response.json(); }, }); ``` ## More information Both `userId` and `userInfo` can then be used in your Vue.js application as such: ```ts const self = ref(room.getSelf()); console.log(self.id); console.log(self.info); ```
Auth diagram
--- meta: title: "Set up access token permissions with Remix" parentTitle: "Authentication" description: "Learn how to setup access token permissions with Remix." --- Follow the following steps to start configure your authentication endpoint and start building your own security logic. ## Quickstart Install the `liveblocks/node` package ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users need permission to interact with rooms, and you can permit access in an `api/liveblocks-auth` endpoint by creating the `app/routes/api/liveblocks-auth.ts` file with the following code. In here you can implement your security and define the rooms that your user can enter. With access tokens, you should always use a [naming pattern](/docs/authentication/access-token#permissions) for your room IDs, as this enables you to easily allow access to a range of rooms at once. In the code snippet below, we’re using a naming pattern and wildcard `*` to give the user access to every room in their organization, and every room in their group. ```ts file="app/routes/api/liveblocks-auth.ts" import type { ActionFunction } from "@remix-run/node"; import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export const action: ActionFunction = async ({ request }) => { // Get the current user from your database const user = __getUserFromDB__(request); // Start an auth session inside your endpoint const session = liveblocks.prepareSession( user.id, { userInfo: user.metadata }, // Optional ); // Use a naming pattern to allow access to rooms with wildcards // Giving the user read access on their org, and write access on their group session.allow(`${user.organization}:*`, session.READ_ACCESS); session.allow(`${user.organization}:${user.group}:*`, session.FULL_ACCESS); // Authorize the user and return the result const { status, body } = await session.authorize(); return new Response(body, { status }); } ``` Read [access token permission](/docs/authentication/access-token#permissions) to learn more about naming rooms and granting permissions with wildcards. Note that if a naming pattern doesn’t work for every room in your application, you can [grant access to individual rooms too](/docs/guides/how-to-grant-access-to-individual-rooms-with-access-tokens). Set up the client On the front end, you can now replace the `publicApiKey` prop on [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider) with `authEndpoint` pointing to the endpoint you just created. ```tsx ``` If you need to pass custom headers or data to your endpoint, you can use [authEndpoint as a callback](/docs/api-reference/liveblocks-client#LiveblocksProviderCallback) instead. ```tsx title="Pass custom headers" isCollapsed isCollapsable { // Passing custom headers and body to your endpoint const headers = { // Custom headers // ... "Content-Type": "application/json", }; const body = JSON.stringify({ // Custom body // ... room, }); const response = await fetch("/api/liveblocks-auth", { method: "POST", headers, body, }); return await response.json(); }} /> ``` Attach metadata to users Optionally, you can attach static metadata to each user, which will be accessible in your app. First you need to define the types in your config file, under `UserMeta["info"]`. ```ts file="liveblocks.config.ts" highlight="7-11" declare global interface Liveblocks { UserMeta: { id: string; // Example, use any JSON-compatible data in your metadata info: { name: string; avatar: string; colors: string[]; } } // Other type definitions // ... } } ``` When authenticating, you can then pass the user’s metadata to `prepareSession` in the endpoint we’ve just created. ```ts file="app/routes/api/liveblocks-auth.ts" highlight="8-12" // Get the current user from your database const user = __getUserFromDB__(request); // Start an auth session inside your endpoint const session = liveblocks.prepareSession( user.id, { userInfo: { name: user.name, avatar: user.avatarUrl, colors: user.colorArray, } } ); ``` User metadata has now been set! You can access this information in your app through [`useSelf`](/docs/api-reference/liveblocks-react#useSelf). ```tsx highlight="4" export { useSelf } from "../liveblocks.config.ts"; function Component() { const { name, avatar, colors } = useSelf((me) => me.info); } ``` Bear in mind that if you’re using the [default Comments components](/docs/api-reference/liveblocks-react-ui#Components), you must specify a `name` and `avatar` in `userInfo`. ## More information Both `userId` and `userInfo` can then be used in your React application as such: ```ts const self = useSelf(); console.log(self.id); console.log(self.info); ```
Auth diagram
--- meta: title: "Set up access token permissions with SvelteKit" parentTitle: "Authentication" description: "Learn how to setup access token permissions with SvelteKit." --- Follow the following steps to start configure your authentication endpoint and start building your own security logic. ## Quickstart Install the `liveblocks/node` package ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users need permission to interact with rooms, and you can permit access in an `api/liveblocks-auth` endpoint by creating the `src/routes/api/liveblocks-auth/+server.ts` file with the following code. In here you can implement your security and define the rooms that your user can enter. With access tokens, you should always use a [naming pattern](/docs/authentication/access-token#permissions) for your room IDs, as this enables you to easily allow access to a range of rooms at once. In the code snippet below, we’re using a naming pattern and wildcard `*` to give the user access to every room in their organization, and every room in their group. ```ts file="src/routes/api/liveblocks-auth/+server.ts" import { type RequestEvent } from "@sveltejs/kit"; import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST({ request }: RequestEvent) { // Get the current user from your database const user = __getUserFromDB__(request); // Start an auth session inside your endpoint const session = liveblocks.prepareSession( user.id, { userInfo: user.metadata }, // Optional ); // Use a naming pattern to allow access to rooms with wildcards // Giving the user read access on their org, and write access on their group session.allow(`${user.organization}:*`, session.READ_ACCESS); session.allow(`${user.organization}:${user.group}:*`, session.FULL_ACCESS); // Authorize the user and return the result const { status, body } = await session.authorize(); return new Response(body, { status }); } ``` Read [access token permission](/docs/authentication/access-token#permissions) to learn more about naming rooms and granting permissions with wildcards. Note that if a naming pattern doesn’t work for every room in your application, you can [grant access to individual rooms too](/docs/guides/how-to-grant-access-to-individual-rooms-with-access-tokens). Set up the client On the front end, you can now replace the `publicApiKey` option with `authEndpoint` pointing to the endpoint you just created. ```ts file="liveblocks.config.ts" import { createClient } from "@liveblocks/client"; const client = createClient({ authEndpoint: "/api/liveblocks-auth", }); ``` If you need to pass custom headers or data to your endpoint, you can use [authEndpoint as a callback](/docs/api-reference/liveblocks-client#createClientCallback) instead. ```ts file="liveblocks.config.ts" isCollapsed isCollapsable import { createClient } from "@liveblocks/client"; // Passing custom headers and body to your endpoint const client = createClient({ authEndpoint: async (room) => { const headers = { // Custom headers // ... "Content-Type": "application/json", }; const body = JSON.stringify({ // Custom body // ... room, }); const response = await fetch("/api/liveblocks-auth", { method: "POST", headers, body, }); return await response.json(); }, }); ``` ## More information Both `userId` and `userInfo` can then be used in your Svelte application as such: ```ts const self = room.getSelf(); console.log(self.id); console.log(self.info.color); ```
Auth diagram
--- meta: title: "Authenticate with ID tokens" parentTitle: "Authentication" description: "Learn more about ID token permissions" --- ID token authentication allows Liveblocks to handle permissions for you. This means that when you create or modify a room, you can set a user’s permissions on the room itself. This means the room acts as a source of truth. Later, when a user tries to enter a room, Liveblocks will automatically check if the user has permission, and deny them access if the permissions aren’t set. Permissions aren’t just for individual users, but can also be set for groups of users, or for the whole room at once.
An ID token granting entry to a room
If you don’t need fine-grained permissions, or if you’d prefer storing individual room permissions in your own system, we recommend using simpler [access tokens](/docs/authentication/access-token) instead. ## Authenticating Authenticating with ID tokens means creating a [JSON Web Token](https://en.wikipedia.org/wiki/JSON_Web_Token) (JWT) that’s used to verify the identity of the current user when connecting to a Liveblocks room. This token is created using [`liveblocks.identifyUser`](/docs/api-reference/liveblocks-node#id-tokens). ```ts const { body, status } = await liveblocks.identifyUser({ userId: "olivier@example.com", }); // '{ token: "eyJga7..." }' console.log(body); ``` **Before using ID tokens, it’s recommended to read this entire page**, as it explains how to set up permissions in your Liveblocks app. However, if you’d like to quickly set up Liveblocks, you can select your framework and read more later. } /> } /> } /> } /> } /> } /> ## Permissions [#permissions] ID token authentication allows you to set different permission types on rooms, assigned at three different levels: default, groups, and users. The system is flexible enough to enable you to build a permission system that’s helpful for building invite dialogs, workspaces, and more.
Share dialog illustration
To set room permissions, you can [create](/docs/api-reference/liveblocks-node#post-rooms) or [update](/docs/api-reference/liveblocks-node#post-rooms-roomId) a room, passing permission information in the options. ```ts const room = await liveblocks.createRoom("a32wQXid4A9", { // This is a private room defaultAccesses: [], // But Olivier can enter usersAccesses: { "olivier@example.com": ["room:read"], }, }); ``` ### Permission types [#permission-types] There are three permission values that you can set on rooms.
`["room:write"]`
Full access. Enables people to view and edit the room. `isReadOnly` is `false`.
`["room:read", "room:presence:write"]`
Read access with presence. Enables people to edit their presence, but only view the room’s storage. `isReadOnly` is `true`.
`[]`
Private. No one can enter the room.
### Permission levels [#permission-types] Permission types can be applied at three different levels, enabling complex entry systems.
defaultAccesses
The default permission types to apply to the entire room.
groupsAccesses
Permission types to apply to specific groups of users.
usersAccesses
Permission types to apply to specific users.
Each level further down will override access levels defined above, for example a room with private access will allow a user with `room:write` access to enter. ### Default room permissions The `defaultAccesses` level is used to set the default permissions of the entire room.
Access denied illustration
When used in our APIs, this property takes an array, with an empty array `[]` signifying no access. Add permission types to this array to define the default access level to your room. ```ts // Private - no one has access by default "defaultAccesses": [] // Public - everyone can edit and view the room "defaultAccesses": ["room:write"] // Read-only - everyone can view the room, but only presence can be edited "defaultAccesses": ["room:read", "room:presence:write"] ``` #### Setting room access We can use the [`liveblocks.createRoom`](/docs/api-reference/rest-api-endpoints#post-rooms) to create a new room with public access levels: ```ts highlight="2" const room = await liveblocks.createRoom("a32wQXid4A9", { defaultAccesses: ["room:write"], }); ``` The default permission types can later be modified with [`liveblocks.updateRoom`](/docs/api-reference/liveblocks-node#post-rooms-roomId), in this example turning the room private: ```ts highlight="2" const room = await liveblocks.updateRoom("a32wQXid4A9", { defaultAccesses: [], }); ``` ### Groups permissions The `groupsAccesses` level is used to set the default permissions of any given group within room.
Groups are represented by a `groupId`—a custom string that represents a selection of users in your app. Groups can be attached to a user by passing an array of `groupId` values in `groupIds`, during authentication. ```js highlight="10" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST(request: Request) { const { status, body } = await liveblocks.identifyUser({ userId: "marie@example.com", groupIds: ["engineering"] }); return new Response(body, { status }); } ``` In our APIs you can then set group accesses by using the `groupId` as the key, and an array of permissions as the value. ```ts // "engineering" group has access to view and edit "groupsAccesses": { "engineering": ["room:write"], } ``` #### Modifying group access [#permissions-levels-groups-accesses-example] To allow an “engineering” group access to view a room, and modify their presence, we can use [`liveblocks.updateRoom`](/docs/api-reference/liveblocks-node#post-rooms-roomId) with `engineering` as a `groupId`: ```ts highlight="3" const room = await liveblocks.updateRoom("a32wQXid4A9", { groupsAccesses: { engineering: ["room:read", "room:presence:write"], }, }); ``` After calling this, every user in the “engineering” group will have read-only access. To remove a group’s permissions, we can use [`liveblocks.updateRoom`](/docs/api-reference/liveblocks-node#post-rooms-roomId) again, and set the permission type to `null`: ```ts highlight="7" const room = await liveblocks.updateRoom("a32wQXid4A9", { groupsAccesses: { engineering: null, }, }); ``` ### User permissions The `usersAccesses` level is used to set permissions of any give user within a room.
Share dialog illustration
To use this, first a user is given a `userId` during authentication. ```js highlight="9" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST(request: Request) { const { status, body } = await liveblocks.identifyUser({ userId: "ellen@acme.inc" }); return new Response(body, { status }); } ``` Then, if you want the user with the `userId` id to make edits, set `userId` to `["room:write"]` within `usersAccesses` when creating or updating a room. ```ts // user with userId "ellen@acme.inc" has access to view and edit "usersAccesses": { "ellen@acme.inc": ["room:write"] } ``` #### Checking user access [#permissions-levels-users-accesses-example] To give them room permission, we can use [`liveblocks.updateRoom`](/docs/api-reference/liveblocks-node#post-rooms-roomId), setting write access on their `userId`: ```ts highlight="3" const room = await liveblocks.updateRoom("a32wQXid4A9", { usersAccesses: { "ellen@acme.inc": ["room:write"], }, }); ``` To check a user’s assigned permission types for this room, we can then use [`liveblocks.updateRoom`](/docs/api-reference/liveblocks-node#get-rooms-roomId) and check `usersAccesses`: ```ts const room = await liveblocks.getRoom("a32wQXid4A9"); // { "ellen@acme.inc": ["room:write"] } console.log(room.data.usersAccesses); ``` ## Select your framework [#select-your-framework] Select your framework for specific instructions on setting up ID token authentication. } /> } /> } /> } /> } /> } /> --- meta: title: "Set up ID token permissions with Express" parentTitle: "Authentication" description: "Learn how to setup ID token permissions with Express." --- Follow the following steps to start configure your authentication endpoint where ## Quickstart Install the `liveblocks/node` package ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users can only interact with rooms they have access to. You can configure permission access in an `api/liveblocks-auth` endpoint by creating the `liveblocks-auth.ts` file with the following code. This is where you will implement your security and define if the current user has access to a specific room. ```ts file="liveblocks-auth.ts" const express = require("express"); import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const app = express(); app.use(express.json()); app.post("/api/liveblocks-auth", (req, res) => { // Get the current user from your database const user = __getUserFromDB__(req); // Identify the user and return the result const { status, body } = await liveblocks.identifyUser( { userId: user.id, groupIds, // Optional }, { userInfo: user.metadata }, ); return res.status(status).end(body); }); ``` Set up the client On the front end, you can now replace the `publicApiKey` option with `authEndpoint` pointing to the endpoint you just created. ```ts file="liveblocks.config.ts" import { createClient } from "@liveblocks/client"; const client = createClient({ authEndpoint: "/api/liveblocks-auth", }); ``` If you need to pass custom headers or data to your endpoint, you can use [authEndpoint as a callback](/docs/api-reference/liveblocks-client#createClientCallback) instead. ```ts file="liveblocks.config.ts" isCollapsed isCollapsable import { createClient } from "@liveblocks/client"; // Passing custom headers and body to your endpoint const client = createClient({ authEndpoint: async (room) => { const headers = { // Custom headers // ... "Content-Type": "application/json", }; const body = JSON.stringify({ // Custom body // ... room, }); const response = await fetch("/api/liveblocks-auth", { method: "POST", headers, body, }); return await response.json(); }, }); ``` Set permission accesses to a room A room can have `defaultAccesses`, `usersAccesses`, and `groupsAccesses` defined. Permissions are then checked when users try to connect to a room. For security purposes, [room permissions](/docs/authentication/id-token#permissions) can only be set on the back-end through `@liveblocks/node` or our REST API. For instance, you can use [`liveblocks.createRoom`](/docs/api-reference/liveblocks-node#post-rooms) to create a new room with read-only public access levels while giving write access to specific groups and users. ```ts highlight="7-15" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const room = await liveblocks.createRoom("my-room-id", { defaultAccesses: ["room:read", "room:presence:write"], groupsAccesses: { "my-group-id": ["room:write"], }, usersAccesses: { "my-user-id": ["room:write"], }, }); ``` For more information, make sure to read the section on [room permissions](/docs/authentication/id-token#permissions). ## More information Both `userId` and `userInfo` can then be used in your JavaScript application as such: ```ts const self = room.getSelf(); // or useSelf() in React console.log(self.id); console.log(self.info); ```
Auth diagram
--- meta: title: "Set up ID token permissions with Firebase" parentTitle: "Authentication" description: "Learn how to setup ID token permissions with Firebase." --- Follow the following steps to start configure your authentication endpoint and start building your own security logic. ## Quickstart Install the `liveblocks/node` package Let’s first install the `@liveblocks/node` package in your Firebase functions project. ```bash npm install @liveblocks/node ``` Set up authentication endpoint Create a new Firebase [callable function](https://firebase.google.com/docs/functions/callable) as shown below. This is where you will implement your security and define if the current user has access to a specific room. ```js const functions = require("firebase-functions"); const { Liveblocks } = require("@liveblocks/node"); const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); exports.auth = functions.https.onCall(async (data, context) => { // Get the current user from your database const user = __getUserFromDB__(data); // Identify the user and return the result const { status, body } = await liveblocks.identifyUser( { userId: user.id, groupIds, // Optional }, { userInfo: user.metadata }, ); return JSON.parse(body); }); ``` Set up the client On the front end, you can now replace the `publicApiKey` option with `authEndpoint` pointing to the endpoint you just created. ```js import { createClient } from "@liveblocks/client"; import firebase from "firebase"; import "firebase/functions"; firebase.initializeApp({ /* Firebase config */ }); const auth = firebase.functions().httpsCallable("liveblocks-auth"); // Create a Liveblocks client const client = createClient({ authEndpoint: async (room) => (await auth({ room })).data, }); ``` Set permission accesses to a room A room can have `defaultAccesses`, `usersAccesses`, and `groupsAccesses` defined. Permissions are then checked when users try to connect to a room. For security purposes, [room permissions](/docs/authentication/id-token#permissions) can only be set on the back-end through `@liveblocks/node` or our REST API. For instance, you can use [`liveblocks.createRoom`](/docs/api-reference/liveblocks-node#post-rooms) to create a new room with read-only public access levels while giving write access to specific groups and users. ```ts highlight="7-15" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const room = await liveblocks.createRoom("my-room-id", { defaultAccesses: ["room:read", "room:presence:write"], groupsAccesses: { "my-group-id": ["room:write"], }, usersAccesses: { "my-user-id": ["room:write"], }, }); ``` For more information, make sure to read the section on [room permissions](/docs/authentication/id-token#permissions). ## More information Both `userId` and `userInfo` can then be used in your JavaScript application as such: ```ts const self = room.getSelf(); // or useSelf() in React console.log(self.id); console.log(self.info); ```
Auth diagram
--- meta: title: "Set up ID token permissions with Next.js" parentTitle: "Authentication" description: "Learn how to setup ID token permissions with Next.js." --- Follow the following steps to start configure your authentication endpoint and start building your own security logic in Next.js’ `/app` directory. ## Quickstart Install the `liveblocks/node` package ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users can only interact with rooms they have access to. You can configure permission access in an `api/liveblocks-auth` endpoint by creating the `app/api/liveblocks-auth/route.ts` file with the following code. This is where you will implement your security and define if the current user has access to a specific room. ```ts file="app/api/liveblocks-auth/route.ts" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST(request: Request) { // Get the current user from your database const user = __getUserFromDB__(request); // Identify the user and return the result const { status, body } = await liveblocks.identifyUser( { userId: user.id, groupIds, // Optional }, { userInfo: user.metadata }, ); return new Response(body, { status }); } ``` Here’s an example using the older API routes format in `/pages`. ```ts file="pages/api/liveblocks-auth.ts" isCollapsed isCollapsable import { Liveblocks } from "@liveblocks/node"; import type { NextApiRequest, NextApiResponse } from "next"; const API_KEY = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_KEY!, }); export default async function handler(request: NextApiRequest, response: NextApiResponse) { // Get the current user from your database const user = __getUserFromDB__(request); // Identify the user and return the result const { status, body } = await liveblocks.identifyUser( { userId: user.id, groupIds, // Optional }, { userInfo: user.metadata }, ); // Authorize the user and return the result const { status, body } = await session.authorize(); response.status(status).send(body); } ``` Set up the client On the front end, you can now replace the `publicApiKey` prop on [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider) with `authEndpoint` pointing to the endpoint you just created. ```tsx ``` If you need to pass custom headers or data to your endpoint, you can use [authEndpoint as a callback](/docs/api-reference/liveblocks-client#LiveblocksProviderCallback) instead. ```tsx title="Pass custom headers" isCollapsed isCollapsable { // Passing custom headers and body to your endpoint const headers = { // Custom headers // ... "Content-Type": "application/json", }; const body = JSON.stringify({ // Custom body // ... room, }); const response = await fetch("/api/liveblocks-auth", { method: "POST", headers, body, }); return await response.json(); }} /> ``` Set permission accesses to a room A room can have `defaultAccesses`, `usersAccesses`, and `groupsAccesses` defined. Permissions are then checked when users try to connect to a room. For security purposes, [room permissions](/docs/authentication/id-token#permissions) can only be set on the back-end through `@liveblocks/node` or our REST API. For instance, you can use [`liveblocks.createRoom`](/docs/api-reference/liveblocks-node#post-rooms) to create a new room with read-only public access levels while giving write access to specific groups and users. ```ts highlight="7-15" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const room = await liveblocks.createRoom("my-room-id", { defaultAccesses: ["room:read", "room:presence:write"], groupsAccesses: { "my-group-id": ["room:write"], }, usersAccesses: { "my-user-id": ["room:write"], }, }); ``` For more information, make sure to read the section on [room permissions](/docs/authentication/id-token#permissions). Attach metadata to users Optionally, you can attach static metadata to each user, which will be accessible in your app. First you need to define the types in your config file, under `UserMeta["info"]`. ```ts file="liveblocks.config.ts" highlight="7-11" declare global interface Liveblocks { UserMeta: { id: string; // Example, use any JSON-compatible data in your metadata info: { name: string; avatar: string; colors: string[]; } } // Other type definitions // ... } } ``` When authenticating, you can then pass the user’s metadata to `prepareSession` in the endpoint we’ve just created. ```ts file="app/api/liveblocks-auth/route.ts" highlight="11-15" // Get the current user from your database const user = __getUserFromDB__(request); // Identify the user and return the result const { status, body } = await liveblocks.identifyUser( { userId: user.id, groupIds, // Optional }, { userInfo: { name: user.name, avatar: user.avatarUrl, colors: user.colorArray, } }, ); ``` User metadata has now been set! You can access this information in your app through [`useSelf`](/docs/api-reference/liveblocks-react#useSelf). ```tsx highlight="4" export { useSelf } from "@liveblocks/react/suspense"; function Component() { const { name, avatar, colors } = useSelf((me) => me.info); } ``` Bear in mind that if you’re using the [default Comments components](/docs/api-reference/liveblocks-react-ui#Components), you must specify a `name` and `avatar` in `userInfo`. ## More information Both `userId` and `userInfo` can then be used in your React application as such: ```ts const self = useSelf(); console.log(self.id); console.log(self.info); ```
Auth diagram
--- meta: title: "Set up ID token permissions with Nuxt.js" parentTitle: "Authentication" description: "Learn how to setup ID token permissions with Nuxt.js." --- Follow the following steps to start configure your authentication endpoint and start building your own security logic. ## Quickstart Install the `liveblocks/node` package ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users can only interact with rooms they have access to. You can configure permission access in an `api/liveblocks-auth` endpoint by creating the `server/api/liveblocks-auth.ts` file with the following code. This is where you will implement your security and define if the current user has access to a specific room. ```ts file="server/api/liveblocks-auth.ts" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export default defineEventHandler(async (event) => { // Get the current user from your database const user = __getUserFromDB__(event); // Identify the user and return the result const { status, body } = await liveblocks.identifyUser( { userId: user.id, groupIds, // Optional }, { userInfo: user.metadata }, ); return body; }) ``` Set up the client On the front end, you can now replace the `publicApiKey` option with `authEndpoint` pointing to the endpoint you just created. ```ts file="liveblocks.config.ts" import { createClient } from "@liveblocks/client"; const client = createClient({ authEndpoint: "/api/liveblocks-auth", }); ``` If you need to pass custom headers or data to your endpoint, you can use [authEndpoint as a callback](/docs/api-reference/liveblocks-client#createClientCallback) instead. ```ts file="liveblocks.config.ts" isCollapsed isCollapsable import { createClient } from "@liveblocks/client"; // Passing custom headers and body to your endpoint const client = createClient({ authEndpoint: async (room) => { const headers = { // Custom headers // ... "Content-Type": "application/json", }; const body = JSON.stringify({ // Custom body // ... room, }); const response = await fetch("/api/liveblocks-auth", { method: "POST", headers, body, }); return await response.json(); }, }); ``` Set permission accesses to a room A room can have `defaultAccesses`, `usersAccesses`, and `groupsAccesses` defined. Permissions are then checked when users try to connect to a room. For security purposes, [room permissions](/docs/authentication/id-token#permissions) can only be set on the back-end through `@liveblocks/node` or our REST API. For instance, you can use [`liveblocks.createRoom`](/docs/api-reference/liveblocks-node#post-rooms) to create a new room with read-only public access levels while giving write access to specific groups and users. ```ts highlight="7-15" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const room = await liveblocks.createRoom("my-room-id", { defaultAccesses: ["room:read", "room:presence:write"], groupsAccesses: { "my-group-id": ["room:write"], }, usersAccesses: { "my-user-id": ["room:write"], }, }); ``` For more information, make sure to read the section on [room permissions](/docs/authentication/id-token#permissions). ## More information Both `userId` and `userInfo` can then be used in your Vue.js application as such: ```ts const self = ref(room.getSelf()); console.log(self.id); console.log(self.info); ```
Auth diagram
--- meta: title: "Set up ID token permissions with Remix" parentTitle: "Authentication" description: "Learn how to setup ID token permissions with Remix." --- Follow the following steps to start configure your authentication endpoint and start building your own security logic. ## Quickstart Install the `liveblocks/node` package ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users can only interact with rooms they have access to. You can configure permission access in an `api/liveblocks-auth` endpoint by creating the `app/routes/api/liveblocks-auth.ts` file with the following code. This is where you will implement your security and define if the current user has access to a specific room. ```ts file="app/routes/api/liveblocks-auth.ts" import type { ActionFunction } from "@remix-run/node"; import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export const action: ActionFunction = async ({ request }) => { // Get the current user from your database const user = __getUserFromDB__(request); // Identify the user and return the result const { status, body } = await liveblocks.identifyUser( { userId: user.id, groupIds, // Optional }, { userInfo: user.metadata }, ); return new Response(body, { status }); } ``` Set up the client On the front end, you can now replace the `publicApiKey` prop on [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider) with `authEndpoint` pointing to the endpoint you just created. ```tsx ``` If you need to pass custom headers or data to your endpoint, you can use [authEndpoint as a callback](/docs/api-reference/liveblocks-client#LiveblocksProviderCallback) instead. ```tsx title="Pass custom headers" isCollapsed isCollapsable { // Passing custom headers and body to your endpoint const headers = { // Custom headers // ... "Content-Type": "application/json", }; const body = JSON.stringify({ // Custom body // ... room, }); const response = await fetch("/api/liveblocks-auth", { method: "POST", headers, body, }); return await response.json(); }} /> ``` Set permission accesses to a room A room can have `defaultAccesses`, `usersAccesses`, and `groupsAccesses` defined. Permissions are then checked when users try to connect to a room. For security purposes, [room permissions](/docs/authentication/id-token#permissions) can only be set on the back-end through `@liveblocks/node` or our REST API. For instance, you can use [`liveblocks.createRoom`](/docs/api-reference/liveblocks-node#post-rooms) to create a new room with read-only public access levels while giving write access to specific groups and users. ```ts highlight="7-15" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const room = await liveblocks.createRoom("my-room-id", { defaultAccesses: ["room:read", "room:presence:write"], groupsAccesses: { "my-group-id": ["room:write"], }, usersAccesses: { "my-user-id": ["room:write"], }, }); ``` For more information, make sure to read the section on [room permissions](/docs/authentication/id-token#permissions). Attach metadata to users Optionally, you can attach static metadata to each user, which will be accessible in your app. First you need to define the types in your config file, under `UserMeta["info"]`. ```ts file="liveblocks.config.ts" highlight="7-11" declare global interface Liveblocks { UserMeta: { id: string; // Example, use any JSON-compatible data in your metadata info: { name: string; avatar: string; colors: string[]; } } // Other type definitions // ... } } ``` When authenticating, you can then pass the user’s metadata to `prepareSession` in the endpoint we’ve just created. ```ts file="app/routes/api/liveblocks-auth.ts" highlight="11-15" // Get the current user from your database const user = __getUserFromDB__(request); // Identify the user and return the result const { status, body } = await liveblocks.identifyUser( { userId: user.id, groupIds, // Optional }, { userInfo: { name: user.name, avatar: user.avatarUrl, colors: user.colorArray, } }, ); ``` User metadata has now been set! You can access this information in your app through [`useSelf`](/docs/api-reference/liveblocks-react#useSelf). ```tsx highlight="4" export { useSelf } from "@liveblocks/react/suspense"; function Component() { const { name, avatar, colors } = useSelf((me) => me.info); } ``` Bear in mind that if you’re using the [default Comments components](/docs/api-reference/liveblocks-react-ui#Components), you must specify a `name` and `avatar` in `userInfo`. ## More information Both `userId` and `userInfo` can then be used in your React application as such: ```ts const self = useSelf(); console.log(self.id); console.log(self.info); ```
Auth diagram
--- meta: title: "Set up ID token permissions with SvelteKit" parentTitle: "Authentication" description: "Learn how to setup ID token permissions with SvelteKit." --- Follow the following steps to start configure your authentication endpoint and start building your own security logic. ## Quickstart Install the `liveblocks/node` package ```bash npm install @liveblocks/node ``` Set up authentication endpoint Users can only interact with rooms they have access to. You can configure permission access in an `api/liveblocks-auth` endpoint by creating the `src/routes/api/liveblocks-auth/+server.ts` file with the following code. This is where you will implement your security and define if the current user has access to a specific room. ```ts file="src/routes/api/liveblocks-auth/+server.ts" import { type RequestEvent } from "@sveltejs/kit"; import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST({ request }: RequestEvent) { // Get the current user from your database const user = __getUserFromDB__(request); // Identify the user and return the result const { status, body } = await liveblocks.identifyUser( { userId: user.id, groupIds, // Optional }, { userInfo: user.metadata }, ); return new Response(body, { status }); } ``` Set up the client On the front end, you can now replace the `publicApiKey` option with `authEndpoint` pointing to the endpoint you just created. ```ts file="liveblocks.config.ts" import { createClient } from "@liveblocks/client"; const client = createClient({ authEndpoint: "/api/liveblocks-auth", }); ``` If you need to pass custom headers or data to your endpoint, you can use [authEndpoint as a callback](/docs/api-reference/liveblocks-client#createClientCallback) instead. ```ts file="liveblocks.config.ts" isCollapsed isCollapsable import { createClient } from "@liveblocks/client"; // Passing custom headers and body to your endpoint const client = createClient({ authEndpoint: async (room) => { const headers = { // Custom headers // ... "Content-Type": "application/json", }; const body = JSON.stringify({ // Custom body // ... room, }); const response = await fetch("/api/liveblocks-auth", { method: "POST", headers, body, }); return await response.json(); }, }); ``` Set permission accesses to a room A room can have `defaultAccesses`, `usersAccesses`, and `groupsAccesses` defined. Permissions are then checked when users try to connect to a room. For security purposes, [room permissions](/docs/authentication/id-token#permissions) can only be set on the back-end through `@liveblocks/node` or our REST API. For instance, you can use [`liveblocks.createRoom`](/docs/api-reference/liveblocks-node#post-rooms) to create a new room with read-only public access levels while giving write access to specific groups and users. ```ts highlight="7-15" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const room = await liveblocks.createRoom("my-room-id", { defaultAccesses: ["room:read", "room:presence:write"], groupsAccesses: { "my-group-id": ["room:write"], }, usersAccesses: { "my-user-id": ["room:write"], }, }); ``` For more information, make sure to read the section on [room permissions](/docs/authentication/id-token#permissions). ## More information Both `userId` and `userInfo` can then be used in your Svelte application as such: ```ts const self = room.getSelf(); console.log(self.id); console.log(self.info.color); ```
Auth diagram
--- meta: title: "How Liveblocks works" parentTitle: "Concepts" description: "Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences." --- Liveblocks provides customizable pre-built features for human and AI collaboration, used to make your product multiplayer, engaging, and AI‑ready ## Ready-made features Liveblocks provides ready‑to‑use features through customizable pre‑built components that can easily be dropped into your product to boost growth: [Comments](/docs/ready-made-features/comments), [Text Editor](/docs/ready-made-features/text-editor), [AI Copilots](/docs/ready-made-features/ai-copilots), [Presence](/docs/ready-made-features/presence), and [Notifications](/docs/ready-made-features/notifications). You can decide features you want to use based on your requirements and collaborative experiences you’re looking to add. If you have more advanced needs, you can also leverage Liveblocks to [host and scale local-first sync engines](/docs/platform/sync-datastore) such as Liveblocks Storage and Yjs.
How Liveblocks works - Ready-made features
## Rooms A room is the digital space in which people collaborate. You can require your users to be [authenticated](/docs/authentication) to interact with rooms, and each room can have specific [permissions](/docs/authentication) and [metadata](/docs/rooms/metadata) associated with them.
How Liveblocks works - Rooms
## Projects A project in your dashboard represents an application which has [rooms](#rooms). Most Liveblocks products are room-based, however [Notifications](/docs/ready-made-features/notifications) is project-based instead, so that users can receive notifications from other rooms.
How Liveblocks works - Project
## Packages and SDKs Integrations for specific libraries and frameworks to add Liveblocks-powered collaborative experiences to your product: [JavaScript](/docs/api-reference/liveblocks-client), [React](/docs/api-reference/liveblocks-react), [React UI](/docs/api-reference/liveblocks-react-ui), [React Lexical](/docs/api-reference/liveblocks-react-lexical), [Node.js Lexical](/docs/api-reference/liveblocks-node-lexical), [Redux](/docs/api-reference/liveblocks-redux), [Zustand](/docs/api-reference/liveblocks-zustand), [Yjs](/docs/api-reference/liveblocks-yjs), and [Node.js](/docs/api-reference/liveblocks-node). Integrations are designed to serve various collaboration use cases such as collaborative text editors, comments, notifications, and more.
How Liveblocks works - Packages and SDKs
- [`@liveblocks/client`](https://liveblocks.io/docs/api-reference/liveblocks-client) - [`@liveblocks/react`](https://liveblocks.io/docs/api-reference/liveblocks-react) - [`@liveblocks/react-ui`](https://liveblocks.io/docs/api-reference/liveblocks-react-ui) - [`@liveblocks/react-tiptap`](https://liveblocks.io/docs/api-reference/liveblocks-react-tiptap) - [`@liveblocks/react-lexical`](https://liveblocks.io/docs/api-reference/liveblocks-react-lexical) - [`@liveblocks/node-lexical`](https://liveblocks.io/docs/api-reference/liveblocks-node-lexical) - [`@liveblocks/redux`](https://liveblocks.io/docs/api-reference/liveblocks-redux) - [`@liveblocks/zustand`](https://liveblocks.io/docs/api-reference/liveblocks-zustand) - [`@liveblocks/yjs`](https://liveblocks.io/docs/api-reference/liveblocks-yjs) - [`@liveblocks/node`](https://liveblocks.io/docs/api-reference/liveblocks-node) - [`@liveblocks/emails`](https://liveblocks.io/docs/api-reference/liveblocks-emails) ## Platform Liveblocks provides a fully-hosted platform built around a [WebSocket infrastructure](/docs/platform/websocket-infrastructure) that scales effortlessly to millions of users. The platform equips you with a set of powerful tools such as our [sync datastore](/docs/platform/sync-datastore), [REST API](/docs/api-reference/rest-api-endpoints), [webhooks](/docs/platform/webhooks), [schema validation](/docs/platform/schema-validation), [analytics](/docs/platform/analytics), and more. --- meta: title: "Why Liveblocks" parentTitle: "Concepts" description: "Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences." --- At Liveblocks, we firmly believe that flexible office policies are here to stay, and that as a result, all SaaS products will eventually need realtime collaboration. Every project we undertake at Liveblocks stems from this belief, and the belief that we can empower people to work better together, and feel more closely connected with one another. ## Why collaboration In 2006, following years of development, Google launched their browser-based realtime collaborative Microsoft Word competitor. This approach transformed the way people work together enabling them to collaborate efficiently in realtime but also asynchronously via comments—no more disjointed email threads with files being passed around. As Google were gaining market shares, Microsoft were forced to build similar collaboration features directly into Microsoft Word and the broader Microsoft Office ecosystem in order to compete.
Why Liveblocks - Google
Around a decade later, Figma flipped the whole software design industry on its head with their browser-based tool for multiplayer design, and subsequently left leading companies behind. Few designers believed a quality design tool could be built into the browser, and no designers wanted to have their peers and managers looking over their shoulders while designing. Yet, the Figma team proved everybody wrong and a majority of designers started to use their product. And because Figma was built with collaboration at its core, engineers, product managers, executives, marketers, and copywriters quickly followed.
Why Liveblocks - Figma
Those companies had the technical expertise, along with millions of dollars, to pull this off. A handful of forward-looking companies were also able to build incredible collaborative products and became leaders in their respective markets: Notion, Pitch, Canva, Miro, Mural, Linear, to mention a few. While more and more companies are becoming aware of the importance of collaboration, only the ones that have gone through this process truly understand how painful and expensive it is to build and maintain.
Why Liveblocks - Figma
## Why choose Liveblocks Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. We focus on the following five core pillars to enable companies to build awesome collaborative experiences in a matter of days, not months: - A complete modular collaboration toolkit - Deep integrations with your stack - Pre-built components and examples - Developer-centric tooling - Fully-hosted solution ### A complete modular collaboration toolkit Liveblocks is a complete modular toolkit for embedding collaborative experiences into your product, enabling you to pick and choose the parts you need depending on the type of collaborative experience you’re trying to build and its requirements. ### Deep integrations with your stack Liveblocks integrates deeply with popular frontend frameworks and libraries, making it easy to embed real‑time collaborative experiences into any product quickly. ### Pre-built components and examples Liveblocks provides you with powerful open‑source examples and components that can be used modularly, enabling you to move fast while focusing on your core product. ### Developer-centric tooling Liveblocks accelerates your productivity with a great developer experience throughout, including [DevTools](/docs/platform/devtools), and analytics to understand how your users are using your product’s collaborative features. ### Fully-hosted solution No more monitoring WebSocket servers, worrying about scale and maintenance of legacy systems. Liveblocks is fully hosted so you can focus on your core product. --- meta: title: "At least one of the custom notification kinds you provided for 'ActivitiesData' does not match its requirements" parentTitle: "Error" description: "Your 'ActivitiesData' type is incorrect and needs to be updated" --- ## Why this error occurred You have provided a custom `ActivitiesData` type for your application, but the type you provided isn’t a (completely) valid definition. ```ts highlight="4-8" declare global { interface Liveblocks { ActivitiesData: { // Your custom notification kinds go here... $error: { code: number; date: Date; // ❌ Values must simple }; // ❌ Custom notification kinds must start with $ success: { message: string; }; }; } } ``` In the example above, there are two problems. Activities data may only contain simple key/value pairs, where the values must always be assignable to `string | number | boolean | undefined` and custom notification kinds must start with `$`. ## How to fix it You’ll need to figure out what part of your provided `ActivitiesData` type definition isn’t valid. The example above could be fixed as such: ```ts highlight="6-8" declare global { interface Liveblocks { ActivitiesData: { // Your custom notification kinds go here... $error: { code: number; date: string; }; $success: { message: string; }; }; } } ``` ## If you cannot find the root cause Sometimes types can be complex and the root cause is still unclear. In those cases, there is a small trick you can use. Try to assign your type to the required base type, with this line: ```ts highlight="9-10" import type { BaseActivitiesData } from "@liveblocks/client"; declare global { interface Liveblocks { ActivitiesData: MyActivitiesData; } } // Quick debugging snippet to find root cause const xxx: BaseActivitiesData = {} as MyActivitiesData; // ^? // The error will appear here ``` Now TypeScript will explain why it thinks your type isn’t valid: ```error showLineNumbers={false} Type 'MyActivitiesData' is not assignable to type 'BaseActivitiesData'. Property 'date' is incompatible with index signature. Type 'Date' is not assignable to type 'string | number | boolean | undefined'. ``` --- meta: title: "The type you provided for 'Presence' is not a valid JSON object" parentTitle: "Error" description: "Your 'Presence' type is incorrect and needs to be updated" --- ## Why this error occurred You have provided a custom `Presence` type for your application, but the type you provided isn’t a (completely) valid JSON object. Values like `Date`, `Map`, `Set`, functions, classes, or `unknown` aren’t valid JSON. ```ts highlight="4-7" declare global { interface Liveblocks { Presence: { // Your own fields go here... cursor: { x: number; y: number } | null; selection: string[]; lastActivity: Date; // ❌ The issue is here }; } } ``` In the example above, the problem is in the `Date` field, because a `Date` isn’t a valid JSON value. ## How to fix it You’ll need to figure out what part of your provided `Presence` type definition isn’t valid JSON. Sometimes this is immediately obvious, like in the example above. Sometimes the issue may be a bit less obvious: ```ts highlight="6-7" import type { Json } from "@liveblocks/client"; declare global { interface Liveblocks { Presence: { cursor: unknown; // ❌ Unknowns could contain non-JSON cursor: Json; // ✅ Prefer using Json }; } } ``` By using `Json`, you can still work with unknown or unspecified values, but still ensure they will be valid JSON. ## If you cannot find the root cause Sometimes types can be complex and the root cause is still unclear. In those cases, there is a small trick you can use. Try to assign your type to the required base type, with this line: ```ts highlight="9-10" import type { JsonObject } from "@liveblocks/client"; declare global { interface Liveblocks { Presence: MyPresence; } } // Quick debugging snippet to find root cause const xxx: JsonObject = {} as MyPresence; // ^? // The error will appear here ``` Now TypeScript will explain why it thinks your type isn’t valid JSON: ```error showLineNumbers={false} Type 'MyPresence' is not assignable to type 'JsonObject'. Property 'lastActivity' is incompatible with index signature. Type 'Date' is not assignable to type 'Json | undefined'. Type 'Date' is not assignable to type 'JsonObject'. Index signature for type 'string' is missing in type 'Date'. ``` --- meta: title: "The type you provided for 'RoomEvent' is not a valid JSON value" parentTitle: "Error" description: "Your 'RoomEvent' type is incorrect and needs to be updated" --- ## Why this error occurred You have provided a custom `RoomEvent` type for your application, but the type you provided isn’t a (completely) valid JSON value. Values like `Date`, `Map`, `Set`, functions, classes, or `unknown` aren’t valid JSON. For example, suppose you have defined three broadcastable events for your application: ```ts highlight="4-7" declare global { interface Liveblocks { RoomEvent: | { type: "beep" } | { type: "boop"; target: HtmlElement } // ^^^^^^^^^^^ ❌ The issue is here | { type: "buzz"; volume: number }; } } ``` In the example above, the problem is in the `HTMLElement` field, because a `HTMLElement` isn’t a valid JSON value. ## How to fix it You’ll need to figure out what part of your provided `RoomEvent` type definition isn’t valid JSON. Sometimes this is immediately obvious, but sometimes the issue may be a bit less obvious. ## If you cannot find the root cause Sometimes types can be complex and the root cause is still unclear. In those cases, there is a small trick you can use. Try to assign your type to the required base type, with this line: ```ts highlight="9-10" import type { Json } from "@liveblocks/client"; declare global { interface Liveblocks { RoomEvent: MyRoomEvent; } } // Quick debugging snippet to find root cause const xxx: Json = {} as MyRoomEvent; // ^? // The error will appear here ``` Now TypeScript will explain why it thinks your type isn’t valid JSON: ```error showLineNumbers={false} Type 'MyRoomEvent' is not assignable to type 'Json'. Type '{ type: "boop"; target: HTMLElement; }' is not assignable to type 'Json'. Type '{ type: "boop"; target: HTMLElement; }' is not assignable to type 'JsonObject'. Property 'target' is incompatible with index signature. Type 'HTMLElement' is not assignable to type 'Json | undefined'. Type 'HTMLElement' is not assignable to type 'JsonObject'. Index signature for type 'string' is missing in type 'HTMLElement'. ``` --- meta: title: "The type you provided for 'RoomInfo' does not match its requirements" parentTitle: "Error" description: "Your 'RoomInfo' type is incorrect and needs to be updated" --- ## Why this error occurred You have provided a custom `RoomInfo` type for your application, but the type you provided isn’t a (completely) valid definition. ```ts highlight="4-10" declare global { interface Liveblocks { RoomInfo: { // Your custom fields go here... name: string; url: URL; // ❌ The issue is here geo: { city: string; country: string; }; }; } } ``` In the example above, the problem is the `URL` field. Values like `Date`, `Map`, `Set`, functions, classes (including `URL`), or `unknown` aren’t valid JSON. ## The rules of the RoomInfo type The following rules apply if you want to specify a custom `RoomInfo` type: - You can provide any keys and values here, as long as the values are valid JSON. - Two small constraints: - _If_ you specify `name`, it _must_ be assignable to `string | undefined`. - _If_ you specify `url`, then it _must_ be assignable to `string | undefined`. The reason for these two restrictions is that some of our higher-level components will pick these values up and use them to provide default UIs. ## How to fix it You’ll need to figure out what part of your provided `RoomInfo` type definition isn’t valid. The example above can be fixed as follows: ```tsx declare global { interface Liveblocks { RoomInfo: { // Your custom fields go here... name: string; url: string; // ✅ Valid geo: { city: string; country: string; }; }; } } ``` ## If you cannot find the root cause Sometimes types can be complex and the root cause is still unclear. In those cases, there is a small trick you can use. Try to assign your type to the required base type, with this line: ```ts highlight="9-10" import type { BaseRoomInfo } from "@liveblocks/client"; declare global { interface Liveblocks { RoomInfo: MyRoomInfo; } } // Quick debugging snippet to find root cause const xxx: BaseRoomInfo = {} as MyRoomInfo; // ^? // The error will appear here ``` Now TypeScript will explain why it thinks your type isn’t valid room info: ```error showLineNumbers={false} Type 'MyRoomInfo' is not assignable to type 'BaseRoomInfo'. Types of property 'url' are incompatible. Type 'URL' is not assignable to type 'string'. ``` --- meta: title: "The type you provided for 'Storage' is not a valid LSON value" parentTitle: "Error" description: "Your 'Storage' type is incorrect and needs to be updated" --- ## Why this error occurred You have provided a custom `Storage` type for your application, but the type you provided isn’t a (completely) valid LSON object. Values like `Date`, `Map`, `Set`, functions, classes, or `unknown` aren’t valid LSON. LSON is either a valid JSON value, or an instance of `LiveMap`, `LiveList`, or `LiveObject`. ```ts highlight="6" declare global { interface Liveblocks { Storage: { layers: LiveMap>; layerIds: LiveList; createdAt: Date; // ❌ The issue is here }; } } ``` In the example above, the problem is in the `Date` field, because a `Date` isn’t a valid LSON value. ## How to fix it You’ll need to figure out what part of your provided `Storage` type definition isn’t valid LSON. Sometimes this is immediately obvious, like in the example above. Sometimes the issue may be a bit less obvious: ```ts highlight="7-8" import type { Lson } from "@liveblocks/client"; declare global { interface Liveblocks { Storage: { layers: LiveMap>; layerIds: LiveList; // ❌ Unknowns could contain non-LSON layerIds: LiveList; // ✅ Prefer using Lson }; } } ``` By using `Lson`, you can still work with unknown or unspecified values, but still ensure they will be valid LSON. ## If you cannot find the root cause Sometimes types can be complex and the root cause is still unclear. In those cases, there is a small trick you can use. Try to assign your type to the required base type, with this line: ```ts highlight="9-10" import type { LsonObject } from "@liveblocks/client"; declare global { interface Liveblocks { Storage: MyStorage; } } // Quick debugging snippet to find root cause const xxx: LsonObject = {} as MyStorage; // ^? // The error will appear here ``` Now TypeScript will explain why it thinks your type isn’t valid LSON: ```error showLineNumbers={false} Type 'MyStorage' is not assignable to type 'LsonObject'. Property 'layerIds' is incompatible with index signature. Type 'LiveList' is not assignable to type 'Lson | undefined'. Type 'LiveList' is not assignable to type 'LiveList'. The types returned by 'toArray()' are incompatible between these types. Type 'unknown[]' is not assignable to type 'Lson[]'. Type 'unknown' is not assignable to type 'Lson'. ``` --- meta: title: "The type you provided for 'ThreadMetadata' does not match its requirements" parentTitle: "Error" description: "Your 'ThreadMetadata' type is incorrect and needs to be updated" --- ## Why this error occurred You have provided a custom `ThreadMetadata` type for your application, but the type you provided isn’t a (completely) valid definition. ```ts highlight="4-8" declare global { interface Liveblocks { ThreadMetadata: { // Your custom fields go here... pinned: boolean; color: string | null; // ❌ Values may not be null position: { x: number; y: number }; // ❌ Values must be simple zIndex?: number; }; } } ``` In the example above, there are two problems. Thread metadata may only contain simple key/value pairs, where the values must always be assignable to `string | number | boolean | undefined`. ## How to fix it You’ll need to figure out what part of your provided `ThreadMetadata` type definition isn’t valid. The example above could be fixed as such: ```ts highlight="6-8" declare global { interface Liveblocks { ThreadMetadata: { // Your custom fields go here... pinned: boolean; color?: string; // ✅ positionX: number; // ✅ positionY: number; // ✅ zIndex?: number; }; } } ``` ## If you cannot find the root cause Sometimes types can be complex and the root cause is still unclear. In those cases, there is a small trick you can use. Try to assign your type to the required base type, with this line: ```ts highlight="9-10" import type { BaseMetadata } from "@liveblocks/client"; declare global { interface Liveblocks { ThreadMetadata: MyThreadMetadata; } } // Quick debugging snippet to find root cause const xxx: BaseMetadata = {} as MyThreadMetadata; // ^? // The error will appear here ``` Now TypeScript will explain why it thinks your type isn’t valid metadata: ```error showLineNumbers={false} Type 'MyThreadMetadata' is not assignable to type 'BaseMetadata'. Property 'color' is incompatible with index signature. Type 'string | null' is not assignable to type 'string | number | boolean | undefined'. Type 'null' is not assignable to type 'string | number | boolean | undefined'. ``` --- meta: title: "The type you provided for 'UserMeta' does not match its requirements" parentTitle: "Error" description: "Your 'UserMeta' type is incorrect and needs to be updated" --- ## Why this error occurred You have provided a custom `UserMeta` type for your application, but the type you provided isn’t a (completely) valid definition. ```ts highlight="4-10" declare global { interface Liveblocks { UserMeta: { id: string; info: { name: string; color: string; picture: string; lastLogin: Date; // ❌ The issue is here }; }; } } ``` In the example above, the problem is in the `Date` field. Values like `Date`, `Map`, `Set`, functions, classes, or `unknown` aren’t valid JSON. ## The rules of the UserMeta type The following rules apply if you want to specify a custom `UserMeta` type: - Top-level fields `id` and `info` are special. They are optional, but only these two fields can exist. Extra fields you specify will not have any effect and will be ignored. - _If_ you specify `id`, it _must_ still be assignable to `string | undefined`. - _If_ you specify `info`, then it _must_ be a valid JSON object value. Furthermore: - _If_ you specify a `name` field inside `info`, it _must_ still be assignable to `string | undefined`. - _If_ you specify a `avatar` field inside `info`, it _must_ still be assignable to `string | undefined`. The reason for the last two restrictions is that some of our higher-level components will pick these values up and use them to provide default UIs. ```tsx declare global { interface Liveblocks { UserMeta: { id: string; // ✅ Valid id?: string; // ✅ Valid id?: number; // ❌ Invalid, not assignable to string | undefined info: { name: string; // ✅ Valid name?: string; // ✅ Valid name: number; // ❌ Invalid, not assignable to string | undefined avatar: string; // ✅ Valid avatar?: string; // ✅ Valid avatar: number; // ❌ Invalid, not assignable to string | undefined // Other field names are not special and are free-form custom: string; // ✅ Valid custom?: string; // ✅ Valid custom: number; // ✅ Valid custom: Json; // ✅ Valid, can take _any_ valid Json value // However, they still need to be valid JSON custom: unknown; // ❌ Invalid, not value JSON }; }; } } ``` Also please note: ```tsx declare global { interface Liveblocks { UserMeta: { id: string; info: { name: string }; // Only `id` or `info` make sense inside `UserMeta` iWillBeIgnored: string; }; } } ``` ## How to fix it You’ll need to figure out what part of your provided `UserMeta` type definition isn’t valid. Sometimes this is immediately obvious, but sometimes the issue may be a bit less obvious. ## If you cannot find the root cause Sometimes types can be complex and the root cause is still unclear. In those cases, there is a small trick you can use. Try to assign your type to the required base type, with this line: ```ts highlight="9-10" import type { BaseUserMeta } from "@liveblocks/client"; declare global { interface Liveblocks { UserMeta: MyUserMeta; } } // Quick debugging snippet to find root cause const xxx: BaseUserMeta = {} as MyUserMeta; // ^? // The error will appear here ``` Now TypeScript will explain why it thinks your type isn’t valid JSON: ```error showLineNumbers={false} Type 'MyUserMeta' is not assignable to type 'BaseUserMeta'. Types of property 'info' are incompatible. Type '{ name: string; color: string; picture: string; lastLogin: Date; }' is not assignable to type 'IUserInfo'. Property 'lastLogin' is incompatible with index signature. Type 'Date' is not assignable to type 'Json | undefined'. Type 'Date' is not assignable to type 'JsonObject'. Index signature for type 'string' is missing in type 'Date'. ``` --- meta: title: "Cross-linked Liveblocks versions are found in your project" parentTitle: "Error" description: "Cross-linked Liveblocks versions are found in your project. This will cause issues!" --- ## Why this error occurred You’re using multiple `@liveblocks/*` packages in your application bundle, but they’re not all on the same version. For example: you’re using `@liveblocks/zustand` at 1.1.3, but `@liveblocks/react` is on 1.1.4. ## How to find the culprit? To find out if your project is affected, you can run ```bash npm ls | grep @liveblocks ``` Please make sure that all of the versions listed there are on the same version. ## Possible ways to fix it When you upgrade one Liveblocks package, make sure to also upgrade other Liveblocks packages to the same version. --- meta: title: "Multiple copies of Liveblocks are being loaded in your project" parentTitle: "Error" description: "Multiple copies of Liveblocks are being loaded in your project. This will cause issues!" --- ## Why this error occurred Multiple copies of some Liveblocks package ended up being bundled in your application bundle. This can happen because your production bundle includes two entire copies of two different versions of Liveblocks. Or it can be the case that it includes the same version of Liveblocks in the bundle twice because the ESM and the CJS version got bundled separately. It’s important that only a single copy of Liveblocks exists in your application bundle at runtime, otherwise bugs will happen. Plus your bundle will be unnecessarily large. ## Possible causes - Your project is using multiple internal packages that all rely on Liveblocks, but maybe some are on different versions. - Your project is using a non-standard bundler setup. If you believe this is an issue with the way Liveblocks is packaged, feel free to open a support request. ## Possible ways to fix it To investigate your set up, run the following command to see if all your Liveblocks dependencies are on the same version: ```bash npm ls | grep @liveblocks ``` If they’re not all on the same version, please fix that. You could manually upgrade that, but we recommend declaring Liveblocks as a `"peerDependency"` in those internal packages’ `package.json` files, and only declare it in the actual `"dependencies"` in the outermost `package.json` for your project. That way, your package manager will keep all the versions the same. If all your Liveblocks dependencies are on the same version already, and you’re still seeing this error, you’re experiencing the [dual-package hazard](https://nodejs.org/api/packages.html#dual-package-hazard) problem, which means that both the ESM and the CJS version of Liveblocks end up in your production bundle as two separate "instances". Of course, this isn’t supposed to happen. Please let us know about this by [opening a GitHub issue](https://github.com/liveblocks/liveblocks/issues/new?template=bug_report.md), or reaching out in our support channel on [Discord](https://liveblocks.io/discord). --- meta: title: "In order to use this channel, please set up your project first" parentTitle: "Error" description: "A notification channel hasn’t been set up, you need to enable it in your dashboard." --- ## Why this error occurred You tried to access a notification channel, such as `settings.email`, but it’s disabled in your Liveblocks dashboard. ## How to fix it You first need to navigate to your project in the [dashboard](/dashboard).
Notifications dashboard page
Then, enable a notification kind on the channel you’re trying to access. For example, if you’re trying to access `settings.email`, navigate to the _Email_ tab and enable a notification kind. If you’re working with a production app, make sure you [read our guide on enabling notification kinds](/docs/guides/what-to-check-before-enabling-a-new-notification-kind) before publishing your changes, as your app may be affected.
Toggle a custom notification kind
Once a notification kind is enabled on the channel you’re accessing, the error will disappear. ## Learn more Learn more in our documentation. - [What to check before enabling a new notification kind](/docs/guides/what-to-check-before-enabling-a-new-notification-kind). - [How to create a notification settings panel](/docs/guides/how-to-create-a-notification-settings-panel). - [`useNotificationSettings`](/docs/api-reference/liveblocks-react#useNotificationSettings) - [`client.getNotificationSettings`](/docs/api-reference/liveblocks-client#Client.getNotificationSettings) - [`Liveblocks.getNotificationSettings`](/docs/api-reference/liveblocks-node#get-users-userId-notification-settings) --- meta: title: "The issued access token doesn't grant enough permissions." parentTitle: "Error" description: "@liveblocks/client error: the issued access token doesn't grant enough permissions" --- ## Why this error occurred The client may request an access token to access resources beyond a specific room, like Notifications. In such cases, your auth endpoint should issue an access token that grants access to multiple rooms using wildcards (`*`). ### 1.10 warning If you saw this warning in your auth endpoint, it's because the version 1.10 allows you to create an access token with no permission, even though it's not recommended. When doing so, Notifications will work with an access token without permissions, and Liveblocks will return all the user's notifications (mentions for example) even if the user doesn't have access to the room. You should move to use wildcards or use ID tokens, and note that this will not be allowed in later versions. ## Possible ways to fix it In your auth endpoint, when the request's property `room` is undefined, you should create a token that grants access to multiple rooms. Example: ``` session.allow("orga1*", session.FULL_ACCESS); ``` Learn more about [`permissions with access tokens`](https://liveblocks.io/docs/authentication/access-token). --- meta: title: "You need to polyfill atob to use the client in your environment" parentTitle: "Error" description: "@liveblocks/client error: atob polyfill is required in your environment." --- ## Why this error occurred You are using `@liveblocks/client` within a JavaScript runtime which doesn't implement [`atob`](https://developer.mozilla.org/en-US/docs/Web/API/atob). (environments like React Native or Node < 16) ## Possible ways to fix it As a polyfill, we recommend installing the package [`base-64`](https://www.npmjs.com/package/base-64). ```bash npm install base-64 ``` Then you can pass the `decode` function to our `atob` polyfill option when you create the client. ```ts highlight="1,5-7" file="src/index.js" import { decode } from "base-64"; const client = createClient({ /* ... your other client's options */ polyfills: { atob: decode, }, }); ``` --- meta: title: "RoomProvider id property is required" parentTitle: "Error" description: "@liveblocks/react error: RoomProvider id property is required." --- ## Why this error occurred [`RoomProvider`][] `id` property is required in order to provide a [`Room`][] in the tree below. ## Possible ways to fix it ```jsx highlight="5" import { RoomProvider } from "@liveblocks/react"; function Component() { return ( ); } ``` Sometimes, you don’t have access to your room `id` right away. For example, if you’re using Next.js and the `id` is coming from the query string, it’s easy to forget that `useRouter().query.roomId` returns `undefined` on the first render. Do not render the [`RoomProvider`][] if you don’t have access to the room `id` right away. ## Useful links - [`RoomProvider`][] - [`Room`][] [`roomprovider`]: /docs/api-reference/liveblocks-react#RoomProvider [`room`]: /docs/api-reference/liveblocks-client#Room --- meta: title: "Get started with Liveblocks and JavaScript" parentTitle: "Get started" description: "Learn how to get started with Liveblocks and JavaScript" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your JavaScript application using the APIs from the [`@liveblocks/client`](/docs/api-reference/liveblocks-client) package. ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up the Liveblocks client The first step in connecting to Liveblocks is creating a client which will be responsible for communicating with the back end. ```ts file="room.js" const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); ``` Join a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate. To create a realtime experience, multiple users must be connected to the same room. ```js file="room.js" const { room, leave } = client.enterRoom("my-room"); ``` Use the Liveblocks methods Now that we’re connected to a room, we can start using Liveblocks subscriptions. The first we’ll add is `others`, a subscription that provides information about which other users are connected to the room. ```js file="index.js" highlight="5-7" import { room } from "./room.js" const div = document.querySelector("div"); room.subscribe("others", (others) => { div.innerText = `There are ${others.length} other user(s) online`; }); ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions. ## What to read next Congratulations! You now have set up the foundation to start building collaborative experiences for your JavaScript application. - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [JavaScript guides](/docs/guides?technologies=javascript) - [Authentication](/docs/authentication) --- ## Examples using JavaScript --- meta: title: "Get started with Comments using Liveblocks and Next.js" parentTitle: "Get started" description: "Learn how to get started with Comments using Liveblocks and Next.js" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding a commenting experience to your Next.js `/app` directory application using the hooks from [`@liveblocks/react`](/docs/api-reference/liveblocks-react) and the components from [`@liveblocks/react-ui`](/docs/api-reference/liveblocks-react-ui). ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Create a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. When using Next.js’ `/app` router, we recommend creating your room in a `Room.tsx` file in the same directory as your current route. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider), and use [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add a loading spinner to your app. ```tsx file="app/Room.tsx" highlight="12-18" "use client"; import { ReactNode } from "react"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; export function Room({ children }: { children: ReactNode }) { return ( Loading…}> {children} ); } ``` Add the Liveblocks room to your page After creating your room file, it’s time to join it. Import your room into your `page.tsx` file, and place your collaborative app components inside it. ```tsx file="app/page.tsx" highlight="6-8" import { Room } from "./Room"; import { CollaborativeApp } from "./CollaborativeApp"; export default function Page() { return ( ); } ``` Use the Liveblocks hooks and components Now that we’re connected to a room, we can start using the Liveblocks hooks and components. We’ll add [`useThreads`](/docs/api-reference/liveblocks-react#useThreads) to get the threads in the room, then we’ll use the [`Thread`](/docs/api-reference/liveblocks-react-ui#Thread) component to render them. Finally, we’ll add a way to create threads by adding a [`Composer`](/docs/api-reference/liveblocks-react-ui#Composer). ```tsx file="app/CollaborativeApp.tsx" highlight="7,11-14" "use client"; import { useThreads } from "@liveblocks/react/suspense"; import { Composer, Thread } from "@liveblocks/react-ui"; export function CollaborativeApp() { const { threads } = useThreads(); return (
{threads.map((thread) => ( ))}
); } ```
Import default styles The default components come with default styles, you can import them into the root layout of your app or directly into a CSS file with `@import`. ```tsx file="app/layout.tsx" import "@liveblocks/react-ui/styles.css"; ``` Next: authenticate and add your users Comments is set up and working now, but each user is anonymous—the next step is to authenticate each user as they connect, and attach their name and avatar to their comments.
## What to read next Congratulations! You’ve set up the foundation to start building a commenting experience for your Next.js application. - [API Reference](/docs/api-reference/liveblocks-react-ui) - [How to send email notifications when comments are created](/docs/guides/how-to-send-email-notifications-when-comments-are-created) --- ## Examples using Next.js --- meta: title: "Get started with Liveblocks, Lexical, and Next.js" parentTitle: "Get started" description: "Learn how to get started with Liveblocks, Lexical, and Next.js" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Next.js application using the APIs from the [`@liveblocks/react-lexical`](/docs/api-reference/liveblocks-react-lexical) package. ## Quickstart Install Liveblocks and Lexical Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui @liveblocks/react-lexical lexical @lexical/react ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Create a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. When using Next.js’ `/app` router, we recommend creating your room in a `Room.tsx` file in the same directory as your current route. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider), and use [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add a loading spinner to your app. ```tsx file="app/Room.tsx" highlight="12-18" "use client"; import { ReactNode } from "react"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; export function Room({ children }: { children: ReactNode }) { return ( Loading…}> {children} ); } ``` Add the Liveblocks room to your page After creating your room file, it’s time to join it. Import your room into your `page.tsx` file, and place your collaborative app components inside it. ```tsx file="app/page.tsx" highlight="6-8" import { Room } from "./Room"; import { Editor } from "./Editor"; export default function Page() { return ( ); } ``` Set up the collaborative Lexical text editor Now that we set up Liveblocks, we can start integrating Lexical and Liveblocks in the `Editor.tsx` file. To make the editor collaborative, we can use [`LiveblocksPlugin`](/docs/api-reference/liveblocks-react-lexical#LiveblocksPlugin) from `@liveblocks/react-lexical`. [`FloatingToolbar`](/docs/api-reference/liveblocks-react-lexical#FloatingToolbar) adds a text selection toolbar. ```tsx file="app/Editor.tsx" "use client"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { liveblocksConfig, LiveblocksPlugin, FloatingToolbar, } from "@liveblocks/react-lexical"; import { Threads } from "./Threads"; export function Editor() { // Wrap your Lexical config with `liveblocksConfig` const initialConfig = liveblocksConfig({ namespace: "Demo", onError: (error: unknown) => { console.error(error); throw error; }, }); return (
} placeholder={
Start typing here…
} ErrorBoundary={LexicalErrorBoundary} />
); } ```
Render threads and composer To add [Comments](/docs/ready-made-features/comments) to your text editor, we need to import a thread composer and list each thread on the page. Create a `Threads.tsx` file that uses [`FloatingComposer`](/docs/api-reference/liveblocks-react-lexical#FloatingComposer) for creating new threads, alongside [`AnchoredThreads`](/docs/api-reference/liveblocks-react-lexical#AnchoredThreads) and [`FloatingThreads`](/docs/api-reference/liveblocks-react-lexical#FloatingThreads) for displaying threads on desktop and mobile. ```tsx file="app/Threads.tsx" "use client"; import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingComposer, FloatingThreads, } from "@liveblocks/react-lexical"; export function Threads() { const { threads } = useThreads(); return ( <>
); } ```
Style your editor Lexical text editor is unstyled by default, so we can create some custom styles for it in a `globals.css` file. Import `globals.css`, alongside the default Liveblocks styles. You can import them into the root layout of your app or directly into a CSS file with `@import`. ```css file="app/globals.css" isCollapsed isCollapsable .editor { position: relative; display: flex; width: 100%; height: 100%; } [data-lexical-editor] { padding: 2px 12px; outline: none; } [data-lexical-editor] p { margin: 0.8em 0; } /* For mobile */ .floating-threads { display: none; } /* For desktop */ .anchored-threads { display: block; max-width: 300px; width: 100%; position: absolute; right: 4px; } @media (max-width: 640px) { .floating-threads { display: block; } .anchored-threads { display: none; } } .placeholder { position: absolute; left: 12px; top: 16px; pointer-events: none; opacity: 0.5; } ``` ```tsx file="app/layout.tsx" import "@liveblocks/react-ui/styles.css"; import "@liveblocks/react-lexical/styles.css"; import "./globals.css"; ``` Next: authenticate and add your users Text Editor is set up and working now, but each user is anonymous—the next step is to authenticate each user as they connect, and attach their name, color, and avatar, to their cursors and mentions. Optional: add more features Lexical is a highly extensible text editor and it's possible to create complex rich-text applications. A great example is in the [Lexical playground](https://playground.lexical.dev/) which enables features such as tables, text highlights, embedded images, and more. This is all supported using Liveblocks.
## What to read next Congratulations! You now have set up the foundation for your collaborative Lexical text editor inside your Next.js application. - [Overview](/docs/ready-made-features/text-editor/lexical) - [`@liveblocks/react-lexical` API Reference](/docs/api-reference/liveblocks-react-lexical) - [`@liveblocks/node-lexical` API Reference](/docs/api-reference/liveblocks-node-lexical) - [Lexical website](https://lexical.dev/) --- ## Examples using Lexical --- meta: title: "Get started with Notifications using Liveblocks and Next.js" parentTitle: "Get started" description: "Learn how to get started with Notifications using Liveblocks and Next.js" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding notifications to your Next.js `/app` directory application using the hooks from [`@liveblocks/react`](/docs/api-reference/liveblocks-react) and the components from [`@liveblocks/react-ui`](/docs/api-reference/liveblocks-react-ui). ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Create a Liveblocks provider Liveblocks Notifications uses the concept of projects, which relate to projects in [your dashboard](/dashboard). Notifications are sent between users in the same project. To connect and receive notifications, you must add [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider) to a client component in your app. ```tsx file="app/NotificationsProvider.tsx" highlight="8-10" "use client"; import { ReactNode } from "react"; import { LiveblocksProvider } from "@liveblocks/react"; export function NotificationsProvider({ children }: { children: ReactNode }) { return ( {children} ); } ``` Add the provider to your layout After creating your provider file, it’s time to join it. Import your room into your `layout.tsx` file, and place your collaborative app components inside it. ```tsx file="app/layout.tsx" highlight="6-1-" import { NotificationsProvider } from "./NotificationsProvider"; import { MyApp } from "./MyApp"; export default function Layout({ children }) { return ( ); } ``` Use the Liveblocks hooks and components Now that we’ve set up the provider, we can start using the Liveblocks hooks and components. We’ll add [`useInboxNotifications`](/docs/api-reference/liveblocks-react#useInboxNotifications) to get the current project’s notifications, then we’ll use [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) and [`InboxNotificationList`](/docs/api-reference/liveblocks-react-ui#InboxNotificationList) to render them. ```tsx file="app/MyApp.tsx" highlight="10,13-20" "use client"; import { useInboxNotifications } from "@liveblocks/react/suspense"; import { InboxNotification, InboxNotificationList, } from "@liveblocks/react-ui"; export function CollaborativeApp() { const { inboxNotifications } = useInboxNotifications(); return ( {inboxNotifications.map((inboxNotification) => ( ))} ); } ``` Import default styles The default components come with default styles, you can import them into the root layout of your app or directly into a CSS file with `@import`. ```tsx file="app/layout.tsx" import "@liveblocks/react-ui/styles.css"; ``` Next: authenticate and add your users Notifications is set up and working now, but each user is anonymous—the next step is to authenticate each user as they connect, and attach their name and avatar to their notifications. ## What to read next Congratulations! You’ve set up the foundation to start building a notifications experience for your React application. - [API reference](/docs/api-reference/liveblocks-react#Notifications) - [Component reference](/docs/api-reference/liveblocks-react-ui#Notifications) --- ## Examples using Notifications --- meta: title: "Get started with Liveblocks, Tiptap, and Next.js" parentTitle: "Get started" description: "Learn how to get started with Liveblocks, Tiptap, and Next.js." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Next.js application using the APIs from the [`@liveblocks/react-tiptap`](/docs/api-reference/liveblocks-react-tiptap) package. ## Quickstart Install Liveblocks and Tiptap Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui @liveblocks/react-tiptap @tiptap/react @tiptap/starter-kit ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Create a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. When using Next.js’ `/app` router, we recommend creating your room in a `Room.tsx` file in the same directory as your current route. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider), and use [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add a loading spinner to your app. ```tsx file="app/Room.tsx" highlight="12-18" "use client"; import { ReactNode } from "react"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; export function Room({ children }: { children: ReactNode }) { return ( Loading…}> {children} ); } ``` Add the Liveblocks room to your page After creating your room file, it’s time to join it. Import your room into your `page.tsx` file, and place your collaborative app components inside it. ```tsx file="app/page.tsx" highlight="6-8" import { Room } from "./Room"; import { Editor } from "./Editor"; export default function Page() { return ( ); } ``` Set up the collaborative Tiptap text editor Now that we set up Liveblocks, we can start integrating Tiptap and Liveblocks in the `Editor.tsx` file. To make the editor collaborative, we can add [`useLiveblocksExtension`](/docs/api-reference/liveblocks-react-tiptap#useLiveblocksExtension) from `@liveblocks/react-tiptap`. [`FloatingToolbar`](/docs/api-reference/liveblocks-react-tiptap#FloatingToolbar) adds a text selection toolbar. ```tsx file="app/Editor.tsx" "use client"; import { useLiveblocksExtension, FloatingToolbar } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { Threads } from "./Threads"; export function Editor() { const liveblocks = useLiveblocksExtension(); const editor = useEditor({ extensions: [ liveblocks, StarterKit.configure({ // The Liveblocks extension comes with its own history handling history: false, }), ], immediatelyRender: false, }); return (
); } ```
Render threads and composer To add [Comments](/docs/ready-made-features/comments) to your text editor, we need to import a thread composer and list each thread on the page. Create a `Threads.tsx` file that uses [`FloatingComposer`](/docs/api-reference/liveblocks-react-tiptap#FloatingComposer) for creating new threads, alongside [`AnchoredThreads`](/docs/api-reference/liveblocks-react-tiptap#AnchoredThreads) and [`FloatingThreads`](/docs/api-reference/liveblocks-react-tiptap#FloatingThreads) for displaying threads on desktop and mobile. ```tsx file="app/Threads.tsx" import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingComposer, FloatingThreads, } from "@liveblocks/react-tiptap"; import { Editor } from "@tiptap/react"; export function Threads({ editor }: { editor: Editor | null }) { const { threads } = useThreads({ query: { resolved: false } }); return ( <>
); } ```
Style your editor Tiptap text editor is unstyled by default, so we can create some custom styles for it in a `globals.css` file. Import `globals.css`, alongside the default Liveblocks styles. You can import them into the root layout of your app or directly into a CSS file with `@import`. ```css file="app/globals.css" isCollapsed isCollapsable .editor { position: relative; display: flex; width: 100%; height: 100%; } .tiptap { padding: 2px 12px; outline: none; width: 100%; } /* For mobile */ .floating-threads { display: none; } /* For desktop */ .anchored-threads { display: block; max-width: 300px; width: 100%; position: absolute; right: 12px; } @media (max-width: 640px) { .floating-threads { display: block; } .anchored-threads { display: none; } } ``` ```tsx file="app/layout.tsx" import "@liveblocks/react-ui/styles.css"; import "@liveblocks/react-tiptap/styles.css"; import "./globals.css"; ``` Next: authenticate and add your users Text Editor is set up and working now, but each user is anonymous—the next step is to authenticate each user as they connect, and attach their name, color, and avatar, to their cursors and mentions. Optional: add more features Tiptap is easy to extend, and a number of extensions are already available, making it possibly to quickly create complex rich-text applications. For example you can enable features such as tables, text highlights, embedded images, and more. This is all supported using Liveblocks.
## What to read next Congratulations! You now have set up the foundation for your collaborative Tiptap text editor inside your Next.js application. - [@liveblocks/react-tiptap API Reference](/docs/api-reference/liveblocks-react-tiptap) - [Tiptap guides](/docs/guides?technologies=tiptap) - [Tiptap website](https://tiptap.dev) --- ## Examples using Tiptap --- meta: title: "Get started with Liveblocks and Next.js" parentTitle: "Get started" description: "Learn how to get started with Liveblocks and Next.js" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Next.js `/app` directory application using the hooks from the [`@liveblocks/react`](/docs/api-reference/liveblocks-react) package. ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Create a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. When using Next.js’ `/app` router, we recommend creating your room in a `Room.tsx` file in the same directory as your current route. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider), and use [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add a loading spinner to your app. ```tsx file="app/Room.tsx" highlight="12-18" "use client"; import { ReactNode } from "react"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; export function Room({ children }: { children: ReactNode }) { return ( Loading…}> {children} ); } ``` Add the Liveblocks room to your page After creating your room file, it’s time to join it. Import your room into your `page.tsx` file, and place your collaborative app components inside it. ```tsx file="app/page.tsx" highlight="6-8" import { Room } from "./Room"; import { CollaborativeApp } from "./CollaborativeApp"; export default function Page() { return ( ); } ``` Use the Liveblocks hooks Now that we’re connected to a room, we can start using the Liveblocks hooks. The first we’ll add is [`useOthers`](/docs/api-reference/liveblocks-react#useOthers), a hook that provides information about which other users are connected to the room. ```tsx file="app/CollaborativeApp.tsx" highlight="6" "use client"; import { useOthers } from "@liveblocks/react/suspense"; export function CollaborativeApp() { const others = useOthers(); const userCount = others.length; return
There are {userCount} other user(s) online
; } ```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation to start building collaborative experiences for your React application. - [@liveblocks/react API Reference](/docs/api-reference/liveblocks-react) - [Next.js and React guides](/docs/guides?technologies=nextjs%2Creact) - [How to use Liveblocks Presence with React](/docs/guides/how-to-use-liveblocks-presence-with-react) - [How to use Liveblocks Storage with React](/docs/guides/how-to-use-liveblocks-storage-with-react) --- ## Examples using Next.js --- meta: title: "Get started with Comments using Liveblocks and React" parentTitle: "Get started" description: "Learn how to get started with Comments using Liveblocks and React" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding a commenting experience to your React application using the hooks from [`@liveblocks/react`](/docs/api-reference/liveblocks-react) and the components from [`@liveblocks/react-ui`](/docs/api-reference/liveblocks-react-ui). ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Set up the Liveblocks client Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). ```tsx file="App.tsx" highlight="11-15" "use client"; import { LiveblocksProvider, RoomProvider, } from "@liveblocks/react/suspense"; import { Room } from "./Room"; export default function App() { return ( {/* ... */} ); } ``` Join a Liveblocks room After setting up the room, you can add collaborative components inside it, using [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. ```tsx file="App.tsx" highlight="14-16" "use client"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; import { Room } from "./Room"; export default function App() { return ( Loading…}> ); } ``` Use the Liveblocks hooks and components Now that we’re connected to a room, we can start using the Liveblocks hooks and components. We’ll add [`useThreads`](/docs/api-reference/liveblocks-react#useThreads) to get the threads in the room, then we’ll use the [`Thread`](/docs/api-reference/liveblocks-react-ui#Thread) component to render them. Finally, we’ll add a way to create threads by adding a [`Composer`](/docs/api-reference/liveblocks-react-ui#Composer). ```tsx file="Room.tsx" highlight="7,11-14" "use client"; import { useThreads } from "@liveblocks/react/suspense"; import { Composer, Thread } from "@liveblocks/react-ui"; export function Room() { const { threads } = useThreads(); return (
{threads.map((thread) => ( ))}
); } ```
Import default styles The default components come with default styles, you can import them into the root of your app or directly into a CSS file with `@import`. ```tsx import "@liveblocks/react-ui/styles.css"; ``` Next: authenticate and add your users Comments is set up and working now, but each user is anonymous—the next step is to authenticate each user as they connect, and attach their name and avatar to their comments.
## What to read next Congratulations! You’ve set up the foundation to start building a commenting experience for your React application. - [API Reference](/docs/api-reference/liveblocks-react-ui) - [How to send email notifications when comments are created](/docs/guides/how-to-send-email-notifications-when-comments-are-created) --- ## Examples using React --- meta: title: "Get started with Liveblocks, Lexical, and React" parentTitle: "Get started" description: "Learn how to get started with Liveblocks, Lexical, and React" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your React application using the APIs from the [`@liveblocks/react-lexical`](/docs/api-reference/liveblocks-react-lexical) package. ## Quickstart Install Liveblocks and Lexical Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui @liveblocks/react-lexical lexical @lexical/react ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Set up the Liveblocks client Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). ```tsx file="App.tsx" highlight="11-15" "use client"; import { LiveblocksProvider, RoomProvider, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( {/* ... */} ); } ``` Join a Liveblocks room After setting up the room, you can add collaborative components inside it, using [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. ```tsx file="App.tsx" highlight="14-16" "use client"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( Loading…}> ); } ``` Set up the collaborative Lexical text editor Now that we set up Liveblocks, we can start integrating Lexical and Liveblocks in the `Editor.tsx` file. To make the editor collaborative, we can use [`LiveblocksPlugin`](/docs/api-reference/liveblocks-react-lexical#LiveblocksPlugin) from `@liveblocks/react-lexical`. [`FloatingToolbar`](/docs/api-reference/liveblocks-react-lexical#FloatingToolbar) adds a text selection toolbar. ```tsx file="Editor.tsx" "use client"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { liveblocksConfig, LiveblocksPlugin, FloatingToolbar, } from "@liveblocks/react-lexical"; import { Threads } from "./Threads"; export function Editor() { // Wrap your Lexical config with `liveblocksConfig` const initialConfig = liveblocksConfig({ namespace: "Demo", onError: (error: unknown) => { console.error(error); throw error; }, }); return (
} placeholder={
Start typing here…
} ErrorBoundary={LexicalErrorBoundary} />
); } ```
Render threads and composer To add [Comments](/docs/ready-made-features/comments) to your text editor, we need to import a thread composer and list each thread on the page. Create a `Threads.tsx` file that uses [`FloatingComposer`](/docs/api-reference/liveblocks-react-lexical#FloatingComposer) for creating new threads, alongside [`AnchoredThreads`](/docs/api-reference/liveblocks-react-lexical#AnchoredThreads) and [`FloatingThreads`](/docs/api-reference/liveblocks-react-lexical#FloatingThreads) for displaying threads on desktop and mobile. ```tsx file="Threads.tsx" "use client"; import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingComposer, FloatingThreads, } from "@liveblocks/react-lexical"; export function Threads() { const { threads } = useThreads(); return ( <>
); } ```
Style your editor Lexical text editor is unstyled by default, so we can create some custom styles for it in a `globals.css` file. Import `globals.css`, alongside the default Liveblocks styles. You can import them into the root layout of your app or directly into a CSS file with `@import`. ```css file="globals.css" isCollapsed isCollapsable .editor { position: relative; display: flex; width: 100%; height: 100%; } [data-lexical-editor] { padding: 2px 12px; outline: none; } [data-lexical-editor] p { margin: 0.8em 0; } /* For mobile */ .floating-threads { display: none; } /* For desktop */ .anchored-threads { display: block; max-width: 300px; width: 100%; position: absolute; right: 4px; } @media (max-width: 640px) { .floating-threads { display: block; } .anchored-threads { display: none; } } .placeholder { position: absolute; left: 12px; top: 16px; pointer-events: none; opacity: 0.5; } ``` ```tsx import "@liveblocks/react-ui/styles.css"; import "@liveblocks/react-lexical/styles.css"; import "./globals.css"; ``` Next: authenticate and add your users Text Editor is set up and working now, but each user is anonymous—the next step is to authenticate each user as they connect, and attach their name, color, and avatar, to their cursors and mentions. Optional: add more features Lexical is a highly extensible text editor and it's possible to create complex rich-text applications. A great example is in the [Lexical playground](https://playground.lexical.dev/) which enables features such as tables, text highlights, embedded images, and more. This is all supported using Liveblocks.
## What to read next Congratulations! You now have set up the foundation for your collaborative Lexical text editor inside your React application. - [Overview](/docs/ready-made-features/text-editor/lexical) - [`@liveblocks/react-lexical` API Reference](/docs/api-reference/liveblocks-react-lexical) - [`@liveblocks/node-lexical` API Reference](/docs/api-reference/liveblocks-node-lexical) - [Lexical website](https://lexical.dev/) --- ## Examples using Lexical --- meta: title: "Get started with Notifications using Liveblocks and React" parentTitle: "Get started" description: "Learn how to get started with Notifications using Liveblocks and React" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding notifications to your application using the hooks from [`@liveblocks/react`](/docs/api-reference/liveblocks-react) and the components from [`@liveblocks/react-ui`](/docs/api-reference/liveblocks-react-ui). ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Set up the Liveblocks client Liveblocks uses the concept of rooms, separate virtual spaces where collaborate, and to create a realtime experience, multiple users must be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider). ```tsx file="App.tsx" highlight="11-15" "use client"; import { LiveblocksProvider } from "@liveblocks/react/suspense"; import { Room } from "./Room"; export default function App() { return ( ); } ``` Use the Liveblocks hooks and components Now that we’ve set up the provider, we can start using the Liveblocks hooks and components. We’ll add [`useInboxNotifications`](/docs/api-reference/liveblocks-react#useInboxNotifications) to get the current project’s notifications, then we’ll use [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) and [`InboxNotificationList`](/docs/api-reference/liveblocks-react-ui#InboxNotificationList) to render them. ```tsx file="app/MyApp.tsx" highlight="1,13-20" "use client"; import { useInboxNotifications } from "@liveblocks/react/suspense"; import { InboxNotification, InboxNotificationList, } from "@liveblocks/react-ui"; export function CollaborativeApp() { const { inboxNotifications } = useInboxNotifications(); return ( {inboxNotifications.map((inboxNotification) => ( ))} ); } ``` Import default styles The default components come with default styles, you can import them into the root of your app or directly into a CSS file with `@import`. ```tsx import "@liveblocks/react-ui/styles.css"; ``` Next: authenticate and add your users Notifications is set up and working now, but each user is anonymous—the next step is to authenticate each user as they connect, and attach their name and avatar to their notifications. ## What to read next Congratulations! You’ve set up the foundation to start building a notifications experience for your React application. - [API reference](/docs/api-reference/liveblocks-react#Notifications) - [Component reference](/docs/api-reference/liveblocks-react-ui#Notifications) --- ## Examples using Notifications --- meta: title: "Get started with Liveblocks, Tiptap, and React" parentTitle: "Get started" description: "Learn how to get started with Liveblocks, Tiptap, and React" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your React application using the APIs from the [`@liveblocks/react-tiptap`](/docs/api-reference/liveblocks-react-tiptap) package. ## Quickstart Install Liveblocks and Tiptap Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui @liveblocks/react-tiptap @tiptap/react @tiptap/starter-kit ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Set up the Liveblocks client Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). ```tsx file="App.tsx" highlight="11-15" "use client"; import { LiveblocksProvider, RoomProvider, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( {/* ... */} ); } ``` Join a Liveblocks room After setting up the room, you can add collaborative components inside it, using [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. ```tsx file="App.tsx" highlight="14-16" "use client"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( Loading…}> ); } ``` Set up the collaborative Tiptap text editor Now that we set up Liveblocks, we can start integrating Tiptap and Liveblocks in the `Editor.tsx` file. To make the editor collaborative, we can add [`useLiveblocksExtension`](/docs/api-reference/liveblocks-react-tiptap#useLiveblocksExtension) from `@liveblocks/react-tiptap`. [`FloatingToolbar`](/docs/api-reference/liveblocks-react-tiptap#FloatingToolbar) adds a text selection toolbar. ```tsx file="Editor.tsx" "use client"; import { useLiveblocksExtension, FloatingToolbar } from "@liveblocks/react-tiptap"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { Threads } from "./Threads"; export function Editor() { const liveblocks = useLiveblocksExtension(); const editor = useEditor({ extensions: [ liveblocks, StarterKit.configure({ // The Liveblocks extension comes with its own history handling history: false, }), ], }); return (
); } ```
Render threads and composer To add [Comments](/docs/ready-made-features/comments) to your text editor, we need to import a thread composer and list each thread on the page. Create a `Threads.tsx` file that uses [`FloatingComposer`](/docs/api-reference/liveblocks-react-tiptap#FloatingComposer) for creating new threads, alongside [`AnchoredThreads`](/docs/api-reference/liveblocks-react-tiptap#AnchoredThreads) and [`FloatingThreads`](/docs/api-reference/liveblocks-react-tiptap#FloatingThreads) for displaying threads on desktop and mobile. ```tsx file="Threads.tsx" import { useThreads } from "@liveblocks/react/suspense"; import { AnchoredThreads, FloatingComposer, FloatingThreads, } from "@liveblocks/react-tiptap"; import { Editor } from "@tiptap/react"; export function Threads({ editor }: { editor: Editor | null }) { const { threads } = useThreads({ query: { resolved: false } }); return ( <>
); } ```
Style your editor Tiptap text editor is unstyled by default, so we can create some custom styles for it in a `globals.css` file. Import `globals.css`, alongside the default Liveblocks styles. You can import them into the root layout of your app or directly into a CSS file with `@import`. ```css file="app/globals.css" isCollapsed isCollapsable .editor { position: relative; display: flex; width: 100%; height: 100%; } .tiptap { padding: 2px 12px; outline: none; width: 100%; } /* For mobile */ .floating-threads { display: none; } /* For desktop */ .anchored-threads { display: block; max-width: 300px; width: 100%; position: absolute; right: 12px; } @media (max-width: 640px) { .floating-threads { display: block; } .anchored-threads { display: none; } } ``` ```tsx file="app/layout.tsx" import "@liveblocks/react-ui/styles.css"; import "@liveblocks/react-tiptap/styles.css"; import "./globals.css"; ``` Next: authenticate and add your users Text Editor is set up and working now, but each user is anonymous—the next step is to authenticate each user as they connect, and attach their name, color, and avatar, to their cursors and mentions. Optional: add more features Tiptap is easy to extend, and a number of extensions are already available, making it possibly to quickly create complex rich-text applications. For example you can enable features such as tables, text highlights, embedded images, and more. This is all supported using Liveblocks.
## What to read next Congratulations! You now have set up the foundation for your collaborative Tiptap text editor inside your React application. - [@liveblocks/react-tiptap API Reference](/docs/api-reference/liveblocks-react-tiptap) - [Tiptap guides](/docs/guides?technologies=tiptap) - [Tiptap website](https://tiptap.dev) --- ## Examples using Tiptap --- meta: title: "Get started with Liveblocks and React" parentTitle: "Get started" description: "Learn how to get started with Liveblocks and React" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your React application using the hooks from the [`@liveblocks/react`](/docs/api-reference/liveblocks-react) package. ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Set up the Liveblocks client Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). ```tsx file="App.tsx" highlight="11-15" "use client"; import { LiveblocksProvider, RoomProvider, } from "@liveblocks/react/suspense"; import { Room } from "./Room"; export default function App() { return ( {/* ... */} ); } ``` Join a Liveblocks room After setting up the room, you can add collaborative components inside it, using [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. ```tsx file="App.tsx" highlight="14-16" "use client"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; import { Room } from "./Room"; export default function App() { return ( Loading…}> ); } ``` Use the Liveblocks hooks Now that we’re connected to a room, we can start using the Liveblocks hooks. The first we’ll add is [`useOthers`](/docs/api-reference/liveblocks-react#useOthers), a hook that provides information about which other users are connected to the room. ```tsx file="Room.tsx" highlight="6" "use client"; import { useOthers } from "./liveblocks.config"; export function Room() { const others = useOthers(); const userCount = others.length; return
There are {userCount} other user(s) online
; } ```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation to start building collaborative experiences for your React application. - [@liveblocks/react API Reference](/docs/api-reference/liveblocks-react) - [React guides](/docs/guides?technologies=react) - [How to use Liveblocks Presence with React](/docs/guides/how-to-use-liveblocks-presence-with-react) - [How to use Liveblocks Storage with React](/docs/guides/how-to-use-liveblocks-storage-with-react) --- ## Examples using React --- meta: title: "Get started with Liveblocks and Redux" parentTitle: "Get started" description: "Learn how to get started with Liveblocks and Redux" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start making your Redux state multiplayer by using the [store enhancer](https://redux.js.org/understanding/thinking-in-redux/glossary#store-enhancer) from the [`@liveblocks/redux`](/docs/api-reference/liveblocks-redux) package. ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client @liveblocks/redux ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Connect your Redux store to Liveblocks Create the Liveblocks client and use the `liveblocksEnhancer` in your Redux store setup. This will add a new state called `liveblocks` to your store, enabling you to interact with our Presence and Storage APIs. ```ts file="store.ts" highlight="7-9,25-27" "use client"; import { createClient } from "@liveblocks/client"; import { liveblocksEnhancer } from "@liveblocks/redux"; import { configureStore, createSlice } from "@reduxjs/toolkit"; const client = createClient({ publicApiKey: "pk_prod_xxxxxxxxxxxxxxxxxxxxxxxx", }); const initialState = {}; const slice = createSlice({ name: "state", initialState, reducers: { /* logic will be added here */ }, }); function makeStore() { return configureStore({ reducer: slice.reducer, enhancers: [ liveblocksEnhancer({ client, }), ], }); } const store = makeStore(); export default store; ``` Join a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate. To create a realtime experience, multiple users must be connected to the same room. ```tsx file="App.tsx" highlight="5,11,14" "use client"; import { useEffect } from "react"; import { useDispatch } from "react-redux"; import { actions } from "@liveblocks/redux"; export default function App() { const dispatch = useDispatch(); useEffect(() => { dispatch(actions.enterRoom("room-id")); return () => { dispatch(actions.leaveRoom("room-id")); }; }, [dispatch]); return ; } ``` Use the Liveblocks data from the store Now that we’re connected to a room, we can start using the Liveblocks data from the Redux store. ```tsx file="Room.tsx" highlight="6" "use client"; import { useSelector } from "react-redux"; export function Room() { const others = useSelector((state) => state.liveblocks.others); const userCount = others.length; return
There are {userCount} other user(s) online
; } ```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation to start building collaborative experiences for your Redux store. - [@liveblocks/redux API Reference](/docs/api-reference/liveblocks-redux) - [Redux guides](/docs/guides?technologies=redux) - [How to create a collaborative online whiteboard with Redux](/docs/guides/how-to-create-a-collaborative-online-whiteboard-with-react-redux-and-liveblocks) - [How to create a collaborative to-do list with Redux](/docs/guides/how-to-create-a-collaborative-to-do-list-with-react-redux-and-liveblocks) --- ## Examples using Redux --- meta: title: "Get started with Liveblocks and SolidJS" parentTitle: "Get started" description: "Learn how to get started with Liveblocks and SolidJS" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your SolidJS application using the APIs from the [`@liveblocks/client`](/docs/api-reference/liveblocks-client) package. Liveblocks does not have a package for SolidJS. If you would like to have one, or even better if you have ideas about what kind of API you would like to use, please let us know about it on this [GitHub issue](https://github.com/liveblocks/liveblocks/issues/672). ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up the Liveblocks client The first step in connecting to Liveblocks is creating a client which will be responsible for communicating with the back end. ```ts file="room.js" const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); ``` Join a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate. To create a realtime experience, multiple users must be connected to the same room. ```js const { room, leave } = client.enterRoom("my-room"); ``` Use the Liveblocks methods Now that we’re connected to a room, we can start using Liveblocks subscriptions. The first we’ll add is `others`, a subscription that provides information about which other users are connected to the room. ```js highlight="8-10" import { createSignal, onCleanup, onMount } from "solid-js"; import { room } from "./room.js"; export function Room() { const [other, setOthers] = createSignal(room.getOthers()); onMount(() => { const unsubscribeOthers = room.subscribe("others", (updatedOthers) => { setOthers(updatedOthers); }); onCleanup(() => { unsubscribeOthers(); }); }) return (
There are {others.length} other user(s) online
); } ```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation to start building collaborative experiences for your SolidJS application. - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) --- ## Examples using SolidJS --- meta: title: "Get started with Liveblocks and Svelte" parentTitle: "Get started" description: "Learn how to get started with Liveblocks and Svelte" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Svelte application using the APIs from the [`@liveblocks/client`](/docs/api-reference/liveblocks-client) package. Liveblocks does not have a package for Svelte. If you would like to have one, or even better if you have ideas about what kind of API you would like to use, please let us know on [GitHub](https://github.com/liveblocks/liveblocks/issues/1). ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up the Liveblocks client The first step in connecting to Liveblocks is creating a client which will be responsible for communicating with the back end. ```ts file="room.js" const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); ``` Join a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate. To create a realtime experience, multiple users must be connected to the same room. ```js const { room, leave } = client.enterRoom("my-room"); ``` Use the Liveblocks methods Now that we’re connected to a room, we can start using Liveblocks subscriptions. The first we’ll add is `others`, a subscription that provides information about which other users are connected to the room. ```js highlight="7-9"
There are {others.length} other user(s) online
```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation to start building collaborative experiences for your Svelte application. - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) --- ## Examples using Svelte --- meta: title: "Get started with Liveblocks and Vue.js" parentTitle: "Get started" description: "Learn how to get started with Liveblocks and Vue.js" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Vue.js application using the APIs from the [`@liveblocks/client`](/docs/api-reference/liveblocks-client) package. Liveblocks does not have a package for Vue.js. If you would like to have one, or even better if you have ideas about what kind of API you would like to use, please let us know about it on this [GitHub issue](https://github.com/liveblocks/liveblocks/issues/1). ## Quickstart Install Liveblocks Every package should use the same version. ```bash npm install @liveblocks/client ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up the Liveblocks client The first step in connecting to Liveblocks is creating a client which will be responsible for communicating with the back end. ```ts file="room.js" const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); ``` Join a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate. To create a realtime experience, multiple users must be connected to the same room. ```js const { room, leave } = client.enterRoom("my-room"); ``` Use the Liveblocks methods Now that we’re connected to a room, we can start using Liveblocks subscriptions. The first we’ll add is `others`, a subscription that provides information about which other users are connected to the room. ```js highlight="7-9" ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions. ## What to read next Congratulations! You now have set up the foundation to start building collaborative experiences for your Vue.js application. - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) --- ## Examples using Vue.js --- meta: title: "Get started with Liveblocks, Yjs, BlockNote, and React" parentTitle: "Get started" description: "Learn how to get started with Liveblocks, Yjs, BlockNote, and React." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your React application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and BlockNote Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/yjs yjs @blocknote/core @blocknote/react @blocknote/mantine ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Set up the Liveblocks client Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). ```tsx file="App.tsx" highlight="11-15" "use client"; import { LiveblocksProvider, RoomProvider, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( {/* ... */} ); } ``` Join a Liveblocks room After setting up the room, you can add collaborative components inside it, using [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. ```tsx file="App.tsx" highlight="14-16" "use client"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( Loading…}> ); } ``` Set up the collaborative BlockNote text editor Now that we set up Liveblocks, we can start integrating BlockNote and Yjs in the `Editor.tsx` file. ```tsx file="Editor.tsx" import { useEffect, useState } from "react"; import { BlockNoteEditor } from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { useCreateBlockNote } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/styles.css"; import * as Y from "yjs"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; import { useRoom } from "@liveblocks/react/suspense"; type EditorProps = { doc: Y.Doc, provider: any; } function Editor() { const room = useRoom(); const yProvider = getYjsProviderForRoom(room); const yDoc = yProvider.getYDoc(); return ; } function BlockNote({ doc, provider }: EditorProps) { const editor: BlockNoteEditor = useCreateBlockNote({ collaboration: { provider, // Where to store BlockNote data in the Y.Doc: fragment: doc.getXmlFragment("document-store"), // Information for this user: user: { name: "My Username", color: "#ff0000", }, }, }); return ; } ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions. ## What to read next Congratulations! You now have set up the foundation for your collaborative BlockNote text editor inside your React application. - [Yjs guides](/docs/guides?technologies=yjs) - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [BlockNote website](https://www.blocknotejs.org/) --- ## Examples using BlockNote --- meta: title: "Get started with Liveblocks, CodeMirror, Yjs, and JavaScript" parentTitle: "Get started" description: "Learn how to get started Liveblocks, CodeMirror, Yjs, and JavaScript." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your JavaScript application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and CodeMirror Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs codemirror @codemirror/lang-javascript y-codemirror.next ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Create an HTML element ```html
```
Set up your collaborative CodeMirror code editor ```js file="app.js" import { createClient } from "@liveblocks/client"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; import * as Y from "yjs"; import { yCollab } from "y-codemirror.next"; import { EditorView, basicSetup } from "codemirror"; import { EditorState } from "@codemirror/state"; import { javascript } from "@codemirror/lang-javascript"; // Set up Liveblocks client const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); // Enter a multiplayer room const { room, leave } = client.enterRoom("my-room"); // Set up Yjs document, shared text, and Liveblocks Yjs provider const yProvider = getYjsProviderForRoom(room); const yDoc = yProvider.getYDoc() const yText = yDoc.getText("codemirror"); // Set up CodeMirror and extensions const state = EditorState.create({ doc: yText.toString(), extensions: [ basicSetup, javascript(), yCollab(ytext, yProvider.awareness, { undoManager }), ], }); // Attach CodeMirror to element const parent = document.querySelector("#editor"); view = new EditorView({ state, parent, }); ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation for your collaborative CodeMirror code editor inside your JavaScript application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [CodeMirror website](https://codemirror.net) --- meta: title: "Get started with Liveblocks, CodeMirror, Yjs, and React" parentTitle: "Get started" description: "Learn how to get started with CodeMirror, Yjs, and Liveblocks." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your React application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and CodeMirror Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/yjs yjs codemirror @codemirror/lang-javascript y-codemirror.next ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Set up the Liveblocks client Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). ```tsx file="App.tsx" highlight="11-15" "use client"; import { LiveblocksProvider, RoomProvider, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( {/* ... */} ); } ``` Join a Liveblocks room After setting up the room, you can add collaborative components inside it, using [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. ```tsx file="App.tsx" highlight="14-16" "use client"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( Loading…}> ); } ``` Set up the collaborative CodeMirror editor Now that we set up Liveblocks, we can start integrating CodeMirror and Yjs in the `Editor.tsx` file. To make the editor collaborative, we can rely on the `yCollab` from `y-codemirror.next`. ```tsx "use client"; import * as Y from "yjs"; import { yCollab } from "y-codemirror.next"; import { EditorView, basicSetup } from "codemirror"; import { EditorState } from "@codemirror/state"; import { javascript } from "@codemirror/lang-javascript"; import { useCallback, useEffect, useState } from "react"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; import { useRoom } from "@/liveblocks.config"; import styles from "./Editor.module.css"; export default function Editor() { const room = useRoom(); const yProvider = getYjsProviderForRoom(room); const [element, setElement] = useState(); const ref = useCallback((node: HTMLElement | null) => { if (!node) return; setElement(node); }, []); // Set up Liveblocks Yjs provider and attach CodeMirror editor useEffect(() => { let view: EditorView; if (!element || !room) { return; } // Get document const yDoc = yProvider.getYDoc(); const yText = yDoc.getText("codemirror"); const undoManager = new Y.UndoManager(yText); // Set up CodeMirror and extensions const state = EditorState.create({ doc: yText.toString(), extensions: [ basicSetup, javascript(), yCollab(yText, yProvider.awareness, { undoManager }), ], }); // Attach CodeMirror to element view = new EditorView({ state, parent: element, }); return () => { view?.destroy(); }; }, [element, room]); return
; } ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions. ## What to read next Congratulations! You now have set up the foundation for your collaborative CodeMirror editor inside your React application. - [Yjs and CodeMirror guides](/docs/guides?technologies=yjs%2Ccodemirror) - [How to create a collaborative code editor with CodeMirror, Yjs, Next.js, and Liveblocks](/docs/guides/how-to-create-a-collaborative-code-editor-with-codemirror-yjs-nextjs-and-liveblocks) - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [CodeMirror website](https://codemirror.net) --- ## Examples using CodeMirror --- meta: title: "Get started with Liveblocks, CodeMirror, Yjs, and Svelte" parentTitle: "Get started" description: "Learn how to get started Liveblocks, CodeMirror, Yjs, and Svelte." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Svelte application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and CodeMirror Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs codemirror @codemirror/lang-javascript y-codemirror.next ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up your collaborative CodeMirror code editor ```html file="Editor.svelte"
```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation for your collaborative CodeMirror code editor inside your Svelte application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [CodeMirror website](https://codemirror.net) --- meta: title: "Get started with Liveblocks, CodeMirror, Yjs, and Vue.js" parentTitle: "Get started" description: "Learn how to get started Liveblocks, CodeMirror, Yjs, and Vue.js." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Vue.js application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and CodeMirror Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs codemirror @codemirror/lang-javascript y-codemirror.next ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up your collaborative CodeMirror code editor ```html file="Editor.vue" ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions. ## What to read next Congratulations! You now have set up the foundation for your collaborative CodeMirror code editor inside your Vue.js application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [CodeMirror website](https://codemirror.net) --- meta: title: "Get started with Liveblocks, Monaco, Yjs, and JavaScript" parentTitle: "Get started" description: "Learn how to get started Liveblocks, Monaco, Yjs, and JavaScript." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your JavaScript application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Monaco Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs monaco-editor y-monaco ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Create an HTML element ```html
```
Set up your collaborative Monaco code editor ```js file="app.js" import { createClient } from "@liveblocks/client"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; import * as Y from "yjs"; import * as monaco from "monaco-editor"; import { MonacoBinding } from "y-monaco"; // Set up Liveblocks client const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); // Enter a multiplayer room const { room, leave } = client.enterRoom("my-room"); // Set up Yjs document, shared text, and Liveblocks Yjs provider const yProvider = getYjsProviderForRoom(room); const yDoc = yProvider.getYDoc(); const yText = yDoc.getText("monaco"); // Set up the Monaco editor const parent = document.querySelector("#editor"); const editor = monaco.editor.create(parent, { value: "", language: "javascript" }) // Attach Yjs to Monaco const monacoBinding = new MonacoBinding( yText, editorRef.getModel(), new Set([editor]), yProvider.awareness ); ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation for your collaborative Monaco code editor inside your JavaScript application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [Monaco website](https://microsoft.github.io/monaco-editor/) --- meta: title: "Get started with Liveblocks, Yjs, Monaco, and React" parentTitle: "Get started" description: "Learn how to get started with Liveblocks, Yjs, Monaco, and React." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your React application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Monaco Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/yjs yjs @monaco-editor/react y-monaco y-protocols ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Set up the Liveblocks client Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). ```tsx file="App.tsx" "use client"; import { LiveblocksProvider, RoomProvider, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( // +++ {/* ... */} // +++ ); } ``` Join a Liveblocks room After setting up the room, you can add collaborative components inside it, using [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. ```tsx file="App.tsx" highlight="14-16" "use client"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; import { CollaborativeEditor } from "./CollaborativeEditor"; export default function App() { return ( Loading…
}> ); } ```
Set up the collaborative Monaco code editor Now that we set up Liveblocks, we can start integrating Monaco and Yjs in the `Editor.tsx` file. To make the editor collaborative, we can rely on `MonacoBinding` from `y-monaco`. ```tsx file="CollaborativeEditor.tsx" "use client"; import * as Y from "yjs"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; import { useRoom } from "@/liveblocks.config"; import { useCallback, useEffect, useState } from "react"; import { Editor } from "@monaco-editor/react"; import { editor } from "monaco-editor"; import { MonacoBinding } from "y-monaco"; import { Awareness } from "y-protocols/awareness"; // Collaborative text editor with simple rich text, live cursors, and live avatars export function CollaborativeEditor() { const [editorRef, setEditorRef] = useState(); const room = useRoom(); const yProvider = getYjsProviderForRoom(room); // Set up Liveblocks Yjs provider and attach Monaco editor useEffect(() => { let binding: MonacoBinding; if (editorRef) { const yDoc = yProvider.getYDoc(); const yText = yDoc.getText("monaco"); // Attach Yjs to Monaco binding = new MonacoBinding( yText, editorRef.getModel() as editor.ITextModel, new Set([editorRef]), yProvider.awareness as Awareness ); } return () => { binding?.destroy(); }; }, [editorRef, room]); const handleOnMount = useCallback((e: editor.IStandaloneCodeEditor) => { setEditorRef(e); }, []); return ( ); } ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation for your collaborative Monaco code editor inside your React application. - [Yjs and Monaco guides](/docs/guides?technologies=yjs%2Cmonaco) - [How to create a collaborative code editor with Monaco, Yjs, Next.js, and Liveblocks](/docs/guides/how-to-create-a-collaborative-code-editor-with-monaco-yjs-nextjs-and-liveblocks) - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [Monaco website](https://microsoft.github.io/monaco-editor/) --- ## Examples using Monaco --- meta: title: "Get started with Liveblocks, Monaco, Yjs, and Svelte" parentTitle: "Get started" description: "Learn how to get started Liveblocks, Monaco, Yjs, and Svelte." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Svelte application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Monaco Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs monaco-editor y-monaco ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up your collaborative Monaco code editor ```html file="Editor.svelte"
```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation for your collaborative Monaco code editor inside your Svelte application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [Monaco website](https://microsoft.github.io/monaco-editor/) --- meta: title: "Get started with Liveblocks, Monaco, Yjs, and Vue.js" parentTitle: "Get started" description: "Learn how to get started Liveblocks, Monaco, Yjs, and Vue.js." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Vue.js application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Monaco Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs monaco-editor y-monaco ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up your collaborative Monaco code editor ```html file="Editor.vue" ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions. ## What to read next Congratulations! You now have set up the foundation for your collaborative Monaco code editor inside your Vue.js application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [Monaco website](https://microsoft.github.io/monaco-editor/) --- meta: title: "Get started with Liveblocks, Quill, Yjs, and JavaScript" parentTitle: "Get started" description: "Learn how to get started Liveblocks, Quill, Yjs, and JavaScript." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your JavaScript application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Quill Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs quill quill-cursors y-quill ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Create an HTML element ```html
```
Set up your collaborative Quill text editor ```js file="app.js" import { createClient } from "@liveblocks/client"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; import * as Y from "yjs"; import Quill from "quill"; import { QuillBinding } from "y-quill"; import QuillCursors from "quill-cursors"; // Set up Liveblocks client const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); // Enter a multiplayer room const { room, leave } = client.enterRoom("my-room"); // Set up Yjs document, shared text, and Liveblocks Yjs provider const yProvider = getYjsProviderForRoom(room); const yDoc = yProvider.getYDoc(); const yText = yDoc.getText("quill"); // Attach cursors plugin Quill.register("modules/cursors", QuillCursors); // Set up Quill editor and modules const parent = document.querySelector("#editor"); const quill = new Quill(parent, { placeholder: "Start collaborating…", theme: "snow", modules: { cursors: true, toolbar: [ [{ header: [1, 2, false] }], ["bold", "italic", "underline"], ["code-block"], ], history: { // Local undo shouldn’t undo changes made by other users userOnly: true, }, }, }); // Attach Yjs to Quill const binding = new QuillBinding(yText, quill, provider.awareness); ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation for your collaborative Quill text editor inside your JavaScript application. - [Yjs and Quill guides](/docs/guides?technologies=yjs%2Cquill) - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [Quill website](https://quilljs.com) --- meta: title: "Get started with Liveblocks, Yjs, Quill, and React" parentTitle: "Get started" description: "Learn how to get started with Liveblocks, Yjs, Quill, and React." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your React application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Quill Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/yjs yjs quill quill-cursors react-quill y-quill ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Set up the Liveblocks client Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). ```tsx file="App.tsx" highlight="11-15" "use client"; import { LiveblocksProvider, RoomProvider, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( {/* ... */} ); } ``` Join a Liveblocks room After setting up the room, you can add collaborative components inside it, using [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. ```tsx file="App.tsx" highlight="14-16" "use client"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( Loading…}> ); } ``` Set up the collaborative Quill text editor Now that we set up Liveblocks, we can start integrating Quill and Yjs in the `Editor.tsx` file. To make the editor collaborative, we can rely on `QuillBinding` and `QuillCursors` from `y-quill` and `quill-cursors`. ```tsx file="Editor.tsx" "use client"; import Quill from "quill"; import ReactQuill from "react-quill"; import QuillCursors from "quill-cursors"; import { QuillBinding } from "y-quill"; import * as Y from "yjs"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; import { useRoom } from "@/liveblocks.config"; import { useCallback, useEffect, useRef, useState } from "react"; Quill.register("modules/cursors", QuillCursors); // Collaborative text editor with simple rich text, live cursors, and live avatars export function CollaborativeEditor() { const room = useRoom(); const yProvider = getYjsProviderForRoom(room); const yDoc = yProvider.getYDoc(); const yText = yDoc.getText("quill"); return ; } type EditorProps = { yText: Y.Text; provider: any; }; function QuillEditor({ yText, provider }: EditorProps) { const reactQuillRef = useRef(null); // Set up Yjs and Quill useEffect(() => { let quill; let binding: QuillBinding; if (!reactQuillRef.current) { return; } quill = reactQuillRef.current.getEditor(); binding = new QuillBinding(yText, quill, provider.awareness); return () => { binding?.destroy?.(); }; }, [yText, provider]); return ( ); } ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions. ## What to read next Congratulations! You now have set up the foundation for your collaborative Quill text editor inside your React application. - [Yjs and Quill guides](/docs/guides?technologies=yjs%2Cquill) - [How to create a collaborative text editor with Quill, Yjs, Next.js, and Liveblocks](/docs/guides/how-to-create-a-collaborative-text-editor-with-quill-yjs-nextjs-and-liveblocks) - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [Quill website](https://quilljs.com) --- ## Examples using Quill --- meta: title: "Get started with Liveblocks, Quill, Yjs, and Svelte" parentTitle: "Get started" description: "Learn how to get started Liveblocks, Quill, Yjs, and Svelte." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Svelte application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Quill Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs quill quill-cursors y-quill ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up your collaborative Quill text editor ```html file="Editor.svelte"
```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation for your collaborative Quill text editor inside your Svelte application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [Quill website](https://quilljs.com) --- meta: title: "Get started with Liveblocks, Quill, Yjs, and Vue.js" parentTitle: "Get started" description: "Learn how to get started Liveblocks, Quill, Yjs, and Vue.js." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Vue.js application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Quill Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs quill quill-cursors y-quill ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up your collaborative Quill text editor ```html file="Editor.vue" ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions. ## What to read next Congratulations! You now have set up the foundation for your collaborative Tiptap text editor inside your Vue.js application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [Tiptap website](https://tiptap.dev) --- meta: title: "Get started with Liveblocks, Slate, Yjs, and React" parentTitle: "Get started" description: "Learn how to get started with Liveblocks, Slate, Yjs, and React." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your React application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Slate Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/react @liveblocks/yjs yjs slate slate-react @slate-yjs/core ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Set up the Liveblocks client Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate, and to create a realtime experience, multiple users must be connected to the same room. Set up a Liveblocks client with [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider), and join a room with [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider). ```tsx file="App.tsx" highlight="11-15" "use client"; import { LiveblocksProvider, RoomProvider, } from "@liveblocks/react/suspense"; import { Editor } from "./Editor"; export default function App() { return ( {/* ... */} ); } ``` Join a Liveblocks room After setting up the room, you can add collaborative components inside it, using [`ClientSideSuspense`](/docs/api-reference/liveblocks-react#ClientSideSuspense) to add loading spinners to your app. ```tsx file="App.tsx" highlight="14-16" "use client"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; import { CollaborativeEditor } from "./CollaborativeEditor"; export default function App() { return ( Loading…}> ); } ``` Set up the collaborative Slate text editor Now that we set up Liveblocks, we can start integrating Slate and Yjs in the `CollaborativeEditor.tsx` file. To make the editor collaborative, we can rely on `withYjs` from `@slate-yjs/core`. ```tsx file="CollaborativeEditor.tsx" "use client"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; import { useEffect, useMemo, useState } from "react"; import { createEditor, Editor, Transforms } from "slate"; import { Editable, Slate, withReact } from "slate-react"; import { withYjs, YjsEditor } from "@slate-yjs/core"; import * as Y from "yjs"; import { useRoom } from "../liveblocks.config"; import styles from "./CollaborativeEditor.module.css"; export function CollaborativeEditor() { const room = useRoom(); const [connected, setConnected] = useState(false); // Set up Yjs const yProvider = getYjsProviderForRoom(room); const yDoc = yProvider.getYDoc(); const sharedType = yDoc.get("slate", Y.XmlText) as Y.XmlText; useEffect(() => { yProvider.on("sync", setConnected); return () => { yProvider?.off("sync", setConnected); }; }, [room]); if (!connected || !sharedType) { return
Loading…
; } return ; } const emptyNode = { children: [{ text: "" }], }; function SlateEditor({ sharedType }: { sharedType: Y.XmlText }) { const editor = useMemo(() => { const e = withReact(withYjs(createEditor(), sharedType)); // Ensure editor always has at least 1 valid child const { normalizeNode } = e; e.normalizeNode = (entry) => { const [node] = entry; if (!Editor.isEditor(node) || node.children.length > 0) { return normalizeNode(entry); } Transforms.insertNodes(editor, emptyNode, { at: [0] }); }; return e; }, []); useEffect(() => { YjsEditor.connect(editor); return () => YjsEditor.disconnect(editor); }, [editor]); return (
); } ``` And here is the `Editor.module.css` file to make sure your multiplayer text editor looks nice and tidy. ```css file="CollaborativeEditor.module.css" isCollapsed isCollapsable .container { display: flex; flex-direction: column; position: relative; border-radius: 12px; background: #fff; width: 100%; height: 100%; color: #111827; } .editor { border-radius: inherit; flex-grow: 1; width: 100%; height: 100%; } .editor:focus { outline: none; } .editorContainer { position: relative; padding: 1em; height: 100%; } .editor p { margin: 1em 0; } ```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint. This approach is great for prototyping and marketing pages where defining your own security isn’t always required. If you want to implement your own security logic to define if certain users should have access to a given room, you’ll need to implement an authentication endpoint.
## What to read next Congratulations! You now have set up the foundation for your collaborative Slate text editor inside your React application. - [Yjs and Slate guides](/docs/guides?technologies=yjs%2Cslate) - [How to create a collaborative text editor with Slate, Yjs, Next.js, and Liveblocks](/docs/guides/how-to-create-a-collaborative-text-editor-with-slate-yjs-nextjs-and-liveblocks) - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [Slate website](https://docs.slatejs.org/) --- ## Examples using Slate --- meta: title: "Get started with Liveblocks, Tiptap, Yjs, and JavaScript" parentTitle: "Get started" description: "Learn how to get started Liveblocks, Tiptap, Yjs, and JavaScript." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your JavaScript application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Tiptap Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor y-prosemirror ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Create an HTML element ```html
```
Set up your collaborative Tiptap text editor ```js file="app.js" import { createClient } from "@liveblocks/client"; import { getYjsProviderForRoom } from "@liveblocks/yjs"; import * as Y from "yjs"; import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import Collaboration from "@tiptap/extension-collaboration"; import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; // Set up Liveblocks client const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); // Enter a multiplayer room const { room, leave } = client.enterRoom("my-room"); // Set up Yjs document and Liveblocks Yjs provider const yProvider = getYjsProviderForRoom(room); const yDoc = yProvider.getYDoc(); // Set up the Tiptap editor const element = document.querySelector("editor"); const editor = new Editor({ element, extensions: [ StarterKit.configure({ // The Collaboration extension comes with its own history handling history: false, }), // Register the Yjs document with Tiptap Collaboration.configure({ document: yDoc, }), CollaborationCursor.configure({ provider: yProvider, }), ], }); ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation for your collaborative Tiptap text editor inside your JavaScript application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [Tiptap website](https://tiptap.dev) --- meta: title: "Get started with Liveblocks, Tiptap, Yjs, and Svelte" parentTitle: "Get started" description: "Learn how to get started Liveblocks, Tiptap, Yjs, and Svelte." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Svelte application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Tiptap Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor y-prosemirror ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up your collaborative Tiptap text editor ```html file="Editor.svelte"
```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation for your collaborative Tiptap text editor inside your Svelte application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [Tiptap website](https://tiptap.dev) --- meta: title: "Get started with Liveblocks, Tiptap, Yjs, and Vue.js" parentTitle: "Get started" description: "Learn how to get started Liveblocks, Tiptap, Yjs, and Vue.js." --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start adding collaboration to your Vue.js application using the APIs from the [`@liveblocks/yjs`](/docs/api-reference/liveblocks-yjs) package. ## Quickstart Install Liveblocks, Yjs, and Tiptap Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/yjs yjs @tiptap/vue-3 @tiptap/pm @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor y-prosemirror ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-client#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework javascript ``` Set up your collaborative Tiptap text editor ```html file="Editor.vue" ``` Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions. ## What to read next Congratulations! You now have set up the foundation for your collaborative Tiptap text editor inside your Vue.js application. - [@liveblocks/yjs API Reference](/docs/api-reference/liveblocks-yjs) - [@liveblocks/client API Reference](/docs/api-reference/liveblocks-client) - [Tiptap website](https://tiptap.dev) --- meta: title: "Get started with Liveblocks and Zustand" parentTitle: "Get started" description: "Learn how to get started with Liveblocks and Zustand" --- Liveblocks is a realtime collaboration infrastructure for building performant collaborative experiences. Follow the following steps to start making your Zustand store multiplayer by using the middleware from the [`@liveblocks/zustand`](/docs/api-reference/liveblocks-zustand) package. ## Quickstart Install Liveblocks Every Liveblocks package should use the same version. ```bash npm install @liveblocks/client @liveblocks/zustand ``` Initialize the `liveblocks.config.ts` file We can use this file later to [define types for our application](/docs/api-reference/liveblocks-react#Typing-your-data). ```bash npx create-liveblocks-app@latest --init --framework react ``` Connect your Zustand store to Liveblocks Create the Liveblocks client and use the `middleware` in your Zustand store setup. This will add a new state called{" "} `liveblocks` to your store, enabling you to interact with our Presence and Storage APIs. ```ts file="store.ts" highlight="12-14,17-22" "use client"; import create from "zustand"; import { createClient } from "@liveblocks/client"; import { liveblocks } from "@liveblocks/zustand"; import type { WithLiveblocks } from "@liveblocks/zustand"; type State = { // Your Zustand state type will be defined here }; const client = createClient({ publicApiKey: "pk_prod_xxxxxxxxxxxxxxxxxxxxxxxx", }); const useStore = create>()( liveblocks( (set) => ({ // Your state and actions will go here }), { client } ) ); export default useStore; ``` Join a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate. To create a realtime experience, multiple users must be connected to the same room. ```tsx file="App.tsx" highlight="13,15" "use client"; import React, { useEffect } from "react"; import useStore from "./store"; import "./App.css"; const App = () => { const { liveblocks: { enterRoom, leaveRoom }, } = useStore(); useEffect(() => { enterRoom("room-id"); return () => { leaveRoom("room-id"); }; }, [enterRoom, leaveRoom]); return ; }; export default App; ``` Use the Liveblocks data from the store Now that we’re connected to a room, we can start using the Liveblocks data from the Zustand store. ```tsx file="Room.tsx" highlight="6" "use client"; import useStore from "./store"; export function Room() { const others = useStore((state) => state.liveblocks.others); const userCount = others.length; return
There are {userCount} other user(s) online
; } ```
Next: set up authentication By default, Liveblocks is configured to work without an authentication endpoint where everyone automatically has access to rooms. This approach is great for prototyping and marketing pages where setting up your own security isn’t always required. If you want to limit access to a room for certain users, you’ll need to set up an authentication endpoint to enable permissions.
## What to read next Congratulations! You now have set up the foundation to start building collaborative experiences for your Zustand store. - [@liveblocks/zustand API Reference](/docs/api-reference/liveblocks-zustand) - [Zustand guides](/docs/guides?technologies=zustand) - [How to use Liveblocks Presence with Zustand](/docs/guides/how-to-create-a-collaborative-to-do-list-with-react-zustand-and-liveblocks) - [How to use Liveblocks Storage with Zustand](/docs/guides/how-to-use-liveblocks-storage-with-zustand) --- ## Examples using Zustand --- meta: title: "Documentation" description: "Explore the documentation to learn how to build realtime collaborative experiences with Liveblocks." showTitle: false --- ## API Reference } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ## Examples ## Community } openInNewWindow /> } openInNewWindow /> } openInNewWindow /> --- meta: title: "Account management" parentTitle: "Platform" description: "Learn how to manage your Liveblocks account and team members." --- In this section, you’ll learn everything you need to manage your Liveblocks accounts and teams. - [Create an account](/docs/platform/account-management/create-an-account) - [Manage team members](/docs/platform/account-management/manage-team-members) --- meta: title: "Create an account" parentTitle: "Platform" description: "Learn how to create a Liveblocks account." --- When you create a new account with Liveblocks, you’re automatically creating a team account on a Starter plan. This plan is free to use (subject to the [Fair Use Policy](/docs/platform/limits/fair-use-policy)) forever. You cannot invite others to collaborate on your Liveblocks team account until you upgrade to a paid plan. To create a new account, visit [liveblocks.io/signup](/api/auth/signup). You can choose to sign up with your email address, or with a GitHub or Google provider.
Create a Liveblocks account
### Sign up with email If you choose to sign up with your email address, you’ll be prompted to enter it in a form. After signing up, you can then verify your new account by clicking a link in an email Liveblocks sends you. In future, when signing in with your email, you’ll be asked to verify your account every time you log in. ### Sign up with a provider If you choose to sign up with Google or GitHub provider, you will be asked to authorize Liveblocks to access your provider account. This connection will then become the default login connection on your account. --- meta: title: "Manage team members" parentTitle: "Platform" description: "Learn how to manage team members on your Liveblocks account." --- Teams are made up of members, and each member can be assigned a role. These roles define what you can and cannot do within a team on Liveblocks. As your project scales, and you add more team members, you can assign each member a role to ensure that they have the right permissions to work on your projects. ## Inviting team members To invite new members to your team, select the team from the scope selector, then open the **Settings** tab and navigate to **Members**.
Liveblocks team members
Enter the email address of the person you’d like to add, select their role, and press the **Invite** button.
Invite team member to Liveblocks
## Access roles Liveblocks offers two types of roles for team members, each providing different levels of access and permissions. ### Owner role As a team owner, you have full administrative control over your team. This includes the ability to manage all aspects of account & project settings, security, and billing. Team owners can manage API keys in all projects and also change the roles of other team members, including promoting members to owners. However, the only way an owner can renounce their role is by either choosing to leave, or by deleting the team altogether. Teams can have more than one owner. For continuity, we recommend that at least two individuals have owner permissions. Additional owners can be added without any impact on existing ownership. Keep in mind that role changes, including assignment and revocation of team member roles, are an exclusive capability of those with the owner role. ### Member role Those with the member role have the ability to create and manage projects. They are also granted permissions to manage API keys for all projects. However, there are certain team-level settings that are off-limits to members. These include editing team settings, such as billing information, and the ability to invite new users to the team. This restriction is in place to maintain the division of responsibilities and control between members and owners. --- meta: title: "Analytics" parentTitle: "Platform" description: "With analytics, you’re able to gather insights into collaborative experiences for a given project with metrics like daily active users, monthly active users, connections, active rooms, and more." --- With analytics, you’re able to gather insights into collaborative experiences for a given project with metrics such as daily active users, monthly active users, connections, active rooms, and more.
Liveblocks analytics
You can adjust the period through the timeframe selector by picking between 1 hour, 1 day, 1 week, and 1 month.
Liveblocks analytics timeframe selector
--- meta: title: "Limits" parentTitle: "Platform" description: "A list of all the limits and limitations that apply on Liveblocks." --- ## General limits To prevent abuse of our platform, we apply the following limits to all accounts. | | Starter | Pro | Enterprise | | ------------------------------------ | ------------------------------------------------ | -------------------------------------------- | ---------- | | Monthly active users | | | Custom | | Projects | | | Unlimited | | Average connections per MAU | Up to | Up to | Custom | | Maximum data stored size per room | Up to | Up to | Custom | | Simultaneous connections per room | Up to | Up to | Custom | | Simultaneous connections per project | Up to | Up to | Custom | | Team members per account | | | Custom | | Monthly public key connections | | | Custom | #### Comments {/* prettier-ignore */} Comments is free up to monthly active users and comments per month. You can get higher limits for an additional . | Comments | Included | Paid add-on | | -------------------- | ----------------------------- | ------------------------- | | Monthly active users | | | | Monthly comments | | | #### Notifications {/* prettier-ignore */} Notifications is free up to monthly active users and notifications per month. You can get higher limits for an additional . | Notifications | Included | Paid add-on | | --------------------- | ---------------------------------- | ------------------------------ | | Monthly active users | | | | Monthly notifications | | | #### Text editor {/* prettier-ignore */} Text editor is free up to monthly active users and data stored. You can get higher limits for an additional . | Text editor | Included | Paid add-on | | -------------------- | --------------------------------------- | ----------------------------------- | | Monthly active users | | | | Total GB stored | | | #### Sync Datastore {/* prettier-ignore */} Sync Datastore is free up to monthly active users and data stored. You can get higher limits for an additional . | Sync Datastore | Included | Paid add-on | | -------------------- | -------------------------------------- | ---------------------------------- | | Monthly active users | | | | Total GB stored | | | ### Monthly active users Learn more about how monthly active users are calculated for each plan on our [plans page](/docs/platform/plans). ### Simultaneous connections per room Learn more about handling simultaneous room connection limits in our guide about [joining rooms at maximum capacity](/docs/guides/what-happens-when-a-user-joins-a-room-at-maximum-capacity). ## Other limits ### `roomId` limit A `roomId` cannot exceed 128 characters. ### `userId` limit A `userId` cannot exceed 128 characters. ### `userInfo` limit `userInfo` sent from the authentication endpoint cannot exceed 1024 characters once serialized to JSON. ### Broadcast event limit Broadcast event messages have a limit of 1 MB. ### Liveblocks Storage limits - A `LiveObject` cannot exceed 128 kB when totalling the size of the keys and values. - A `LiveMap` can be any size, so long as each individual value does not exceed 128kB. - A `LiveList` can be any size, so long as each individual value does not exceed 128kB. Note that when one realtime data structure is nested inside another, it does not count towards the limit. Only the JSON leaves of your data structure count towards the limit. For example, if a `LiveList` is nested inside a `LiveObject`, the `LiveList` and its contents do not count towards the `LiveObject`’s data limit. --- meta: title: "Fair Use Policy" parentTitle: "Limits" description: "Fair Use Policy for all Liveblocks plans." --- All Liveblocks subscription plans are subject to this Fair Use Policy. ## Usage guidelines We have established guidelines for our community’s usage of our plans. Most users should fall within the specified ranges, however, if usage is excessive, we will notify you and work to address the issue. Our aim is to be accommodating while maintaining the stability of our infrastructure. ### Typical usage guidelines | | Starter | Pro | Enterprise | | ------------------------------------ | ------------------------------------------------ | -------------------------------------------- | ---------- | | Monthly active users | | | Custom | | Projects | | | Unlimited | | Average connections per MAU | Up to | Up to | Custom | | Maximum data stored size per room | Up to | Up to | Custom | | Simultaneous connections per room | Up to | Up to | Custom | | Simultaneous connections per project | Up to | Up to | Custom | | Team members per account | | | Custom | | Monthly public key connections | | | Custom | To learn more about how monthly active users are calculated for each plan, take a look at the [plans](/docs/platform/plans) docs. ### Other guidelines The `userId` must be used to represent a single user. ### Commercial usage **Starter accounts** are restricted to non-commercial use only. All commercial usage of the platform requires either a Pro or Enterprise plan. Commercial usage refers to any usage of the Liveblocks platform in projects where financial gain is being sought by any party involved in the production, including paid employees or consultants. ### General limits [Take a look at our limits guide](/docs/platform/limits) to learn more about the limits we apply to all accounts. ### Learn more For further information regarding this policy and acceptable use of our Services, please refer to our [Terms of Service](/terms) or your Enterprise Service Agreement. --- meta: title: "Plans" parentTitle: "Platform" description: "Learn about the different plans available on Liveblocks." --- Liveblocks offers three plans: Starter, Pro, and Enterprise. The Starter plan is free and includes all the features you need to get started, while the Pro and Enterprise plans offer higher limits, enhanced features and resources. ## Starter plan The Starter plan is **free** and designed for developers getting started with Liveblocks. Key features include: - Up to monthly active users - Up to simultaneous connections per room - Pre-built components - Realtime infrastructure - team members per account - Community support {/* This file is ignoring prettier at some points because it's splitting paragraphs in two */} #### Comments {/* prettier-ignore */} Comments is free up to monthly active users and comments per month. | Comments | Included | | -------------------- | ----------------------------- | | Monthly active users | | | MAU overage rate | — | | Monthly comments | | #### Notifications {/* prettier-ignore */} Notifications is free up to monthly active users and comments per month. | Notifications | Included | | --------------------- | ---------------------------------- | | Monthly active users | | | MAU overage rate | — | | Monthly notifications | | #### Text Editor {/* prettier-ignore */} Text Editor is free up to monthly active users and data stored. | Text Editor | Included | | -------------------- | --------------------------------------- | | Monthly active users | | | MAU overage rate | — | | Total GB stored | | #### Sync Datastore {/* prettier-ignore */} Sync Datastore is free up to monthly active users and data stored. | Sync Datastore | Included | | -------------------- | -------------------------------------- | | Monthly active users | | | MAU overage rate | — | | Total GB stored | | ## Pro plan The Pro plan starts at **** and is designed for companies adding collaboration in production. Key features include: - Up to monthly active users - Up to simultaneous connections per room - team members per account - More simultaneous connections - Products priced separately - Email support #### Comments {/* prettier-ignore */} Comments is free up to monthly active users and comments per month. You can get higher limits with a paid add-on. | Comments | Included | Paid add-on | | -------------------- | ----------------------------- | ------------------------- | | Monthly active users | | | | Monthly comments | | | #### Notifications {/* prettier-ignore */} Notifications is free up to monthly active users and notifications per month. You can get higher limits with a paid add-on. | Notifications | Included | Paid add-on | | --------------------- | ---------------------------------- | ------------------------------ | | Monthly active users | | | | Monthly notifications | | | #### Text Editor {/* prettier-ignore */} Text Editor is free up to monthly active users and data stored. You can get higher limits with a paid add-on. | Text Editor | Included | Paid add-on | | -------------------- | --------------------------------------- | ----------------------------------- | | Monthly active users | | | | Total GB stored | | | #### Sync Datastore {/* prettier-ignore */} Sync Datastore is free up to monthly active users and data stored. You can get higher limits with a paid add-on. | Sync Datastore | Included | Paid add-on | | -------------------- | -------------------------------------- | ---------------------------------- | | Monthly active users | | | | Total GB stored | | | ## Enterprise plan The Enterprise plan is tailored for organizations with custom needs and advanced security. Key features include: - Up to 100M monthly active users - Tiered usage pricing - 99.9% Uptime SLA - SOC 2 Type 2 report - HIPAA compliance with BAA - Support Slack - Implementation support - Dedicated Slack support #### Comments Comments can be included in the enterprise plan. | Comments | | | -------------------- | ------ | | Monthly active users | Custom | | Monthly comments | Custom | #### Notifications Notifications can be included in the enterprise plan. | Notifications | | | --------------------- | ------ | | Monthly active users | Custom | | Monthly notifications | Custom | #### Text Editor Text Editor can be included in the enterprise plan. | Text Editor | | | -------------------- | ------ | | Monthly active users | Custom | | Total GB stored | Custom | #### Sync Datastore Sync Datastore can be included in the enterprise plan. | Sync Datastore | | | -------------------- | ------ | | Monthly active users | Custom | | Total GB stored | Custom | ## General billing information ### What is a monthly active user (MAU)? Monthly active users (MAU) represents the number of users that have used one or more Liveblocks features within a given month. Users are identified by the `userId` property provided in your [authentication endpoint](/docs/authentication). This `userId` generally comes from your database or your authentication provider. One unique `userId` corresponds to one user, whether they connected to a room 1 day for 5 minutes or kept coming back every day for 8 hours. One user will be counted as one monthly active user, no matter how frequently they connect to Liveblocks rooms in any given month as long as it falls under our [Fair Use Policy](/docs/platform/limits/fair-use-policy). You must use the 1.0 or newer Liveblocks packages to have monthly active users counted appropriately. We will count each connection as one MAU if you do not update your packages to 1.0. See the [upgrade guide](/docs/platform/upgrading/1.0) for more information. ### How is data storage calculated? Storage for Text Editor and Sync Datastore is a cumulative calculation that represents all data that is currently stored. It does not reset monthly, and the total usage will continue to accrue over time as you add more content and data. You can monitor your usage at any time in the [dashboard](/dashboard). ### Do you count monthly active users during testing? If you connect to rooms and call the Liveblocks API during any automated testing, you may want to provide a static `userId` to avoid hitting limits. For example, you could do this by setting an environment variable and checking the process when you call `authorize` from `@liveblocks/node`. ```dotenv file=".env.test" TEST_USERID="machine" ``` ```ts file="liveblocks.config.ts" export default async function auth(req, res) { const room = req.body.room; const response = await authorize({ room, secret, userId: process.env.NODE_ENV === "test" ? process.env.TEST_USERID : "user123", }); return res.status(response.status).end(response.body); } ``` --- meta: title: "Projects" parentTitle: "Platform" description: "To use Liveblocks, you need to create a project, a place to group your collaborative rooms. Learn how to set up and configure projects in this guide." --- To use Liveblocks, you need to create a project, a place to group your collaborative rooms. Learn how to set up and configure projects in this guide. ## Creating a project You can create a new project from the [Liveblocks dashboard](/dashboard) by clicking on the **Create project…** button.
Start creating a project from the Liveblocks dashboard
### Project environment A project’s environment can either be set to **Development** or **Production**, helping you map projects to your deployment model. We recommend setting up a new project for each different environment your collaborative application uses.
Creating a project from the Liveblocks dashboard
Secret [API keys](#project-api-keys) are treated slightly differently depending on the environment: - **Development** environments allow you to read your secret key any time after generation. It’s recommended to use this setting in development, preview, and staging environments. - **Production** environments encrypt your secret key, meaning it can only be read when it’s first generated. If you forget your secret key, you must roll a new one. It’s recommended to use this environment in your production application. A project’s environment cannot be changed later. ## Managing a project Each Liveblocks project has a separate dashboard to monitor usage, configure settings, manage API keys, and more. ### Project overview The **Overview** tab displays an overview of your project usage, providing information on active rooms, users, and connections.
Liveblocks project overview
### Project rooms The **Rooms** tab displays all the rooms in your project. Use the search bar next to the page title to find rooms. By default, rooms are sorted by the last connection date (most recently active rooms). You can also sort them by room ID, number of threads, document size, or creation date by clicking on the column labels.
Liveblocks project rooms
You can learn more about any given by room by clicking on it. On the room detail view, you’re able to view the stored document’s data, and common actions you can take on that such as [attaching a schema](/docs/platform/schema-validation) or deleting the document’s data altogether.
Liveblocks project rooms detail
### Project API keys [#project-api-keys] The **API keys** tab enables you to manage, view, and roll your **public** and **secret** API keys.
Liveblocks project API keys
### Project schemas The **Schemas** tab enables you to manage your document’s schemas. Schemas can be attached to any room’s Storage document to validate incoming changes and ensure data integrity and facilitate future potential data migrations for your application. See our [schema validation](/docs/platform/schema-validation) docs to learn more. ### Project webhooks The **Webhooks** tab enables you to configure your webhook endpoints allowing you to respond to Liveblocks events, such as a user entering a room, or storage being updated. See our [webhooks](/docs/platform/webhooks) docs to learn more. ### Project settings The **Settings** tab enables you to rename and delete your project. --- meta: title: "Liveblocks REST API" parentTitle: "Platform" description: "Liveblocks REST API allows developers to interact programmatically with their Liveblocks account and services using HTTP requests. With the API, developers can retrieve, set, and update room-related data, permissions, schemas, and more." --- Liveblocks REST API allows developers to interact programmatically with their Liveblocks account and services using HTTP requests. With the API, developers can retrieve, set, and update room-related data, users, permissions, schemas, and more. The Liveblocks API is organized around REST. The API has predictable resource-oriented URLs, accepts form-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, and verbs. See the [API reference](api-reference/rest-api-endpoints) for more information. ``` https://api.liveblocks.io/v2 ``` --- meta: title: "Schema validation" parentTitle: "Platform" description: "Learn how to use schemas to ensure data integrity in your application" --- ## Why schema validation? Schema validation is essential for future-proofing your application and ensuring that implementing new features or data structures into your app will keep the integration you have established with Liveblocks. We currently mitigate the risk of introducing client-side errors by allowing you to type your storage in `liveblocks.config.ts`. Still, we want to go one step further to help you protect your application by attaching a schema to a room that rejects invalid modifications. By using schema validation, you will be able to: - Trust any incoming storage modifications - Paired with webhooks, strengthen your database synchronization - Write migration scripts for your room storage more easily ## How schema validation works The primary purpose of schema validation is to prevent corrupted data from being loaded from the server. To add schema validation to your application, take the following steps: 1. Define the shape of your storage with a schema definition (similar to how you would describe the storage type in TypeScript) 2. Attach your schema to a room By default, a room storage accepts any modifications coming from the client. But once you attach a schema to a room, Liveblocks will reject any modifications to the storage that do not match the schema you provided. Situations like this should only happen in development mode, and the developer is responsible for fixing them. ## Schema actions You can interact with the schemas you create through the dashboard or by calling the [REST API](/docs/api-reference/rest-api-endpoints). In this guide, we will cover the following operations: - [Creating a schema](#creating-a-schema) - [Deleting a schema](#deleting-a-schema) - [Attaching a schema to a room](#attaching-a-schema-to-a-room) - [Detaching a schema from a room](#detaching-a-schema-from-a-room) You can also use the REST API to [list schemas](/docs/api-reference/rest-api-endpoints#list-schemas), [get a schema](/docs/api-reference/rest-api-endpoints#get-schema), and [update a schema](/docs/api-reference/rest-api-endpoints#update-schema). ### Creating a schema Creating a schema via the dashboard is straightforward. Simply navigate to the “Schemas” page in the project you want to add the schema, and click on the “Create schema” button. Provide a name and a definition that describes the shape of the data you want to store in your rooms.
Another way to create a schema is via the [create schema API](/docs/api-reference/rest-api-endpoints#post-create-new-schema). A schema body is a multi-line string using the Liveblocks [schema syntax](/docs/platform/schema-validation/syntax). In the dashboard, a sample schema could be represented as: ```ts type Storage { todos: LiveList> } type Todo { text: string checked?: boolean } ``` To create a schema, provide the name and body in the JSON payload. Example: ```ts fetch("https://api.liveblocks.io/v2/schemas", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ name: "todo-list", body: ` type Storage { todos: LiveList> } type Todo { text: string checked?: boolean } `, }), }); ``` Note that in this example, the outermost `body` field is the body of the HTTP request (required by the `fetch` API), whereas the innermost `body` field is consumed by our API to read the schema text. Learn more about our [schema syntax](/docs/platform/schema-validation/syntax) and what kind of validations are currently supported. ### Deleting a schema In the dashboard, you can delete a schema by navigating to the schema in question and clicking on the trash icon next to the schema version you want to delete. If you have attached a schema to a room, you will need to detach the schema from the room before you can delete it. If this schema is frozen, the trash icon will be greyed out.
If your schema is in a detached state, you can also delete it by using the [delete schema API](/docs/api-reference/rest-api-endpoints#delete-a-schema). ```ts fetch("https://api.liveblocks.io/v2/schemas/{id}", { method: "DELETE", }); ``` ### Attaching a schema to a room You can attach a schema to a room via the dashboard by navigating to the room in question and clicking on the “Attach schema” button.
Alternatively, you can use the [attach schema API](/docs/api-reference/rest-api-endpoints#post-attach-schema-to-room). For example: ```ts fetch("https://api.liveblocks.io/v2/rooms/{roomId}/schema", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ schema: "todo-list@1", }), }); ``` To attach a schema to a room, you must first create a room and initialize storage. ### Detaching a schema from a room You can use the dashboard to remove a schema from a room by navigating to the room in question and clicking on the dropdown menu which displays the currently attached schema. From there, you can click on the “Detach schema” button.
To detach a schema from a room using the [detach schema API](/docs/api-reference/rest-api-endpoints#delete-detach-schema-to-room), use the following code: ```ts fetch("https://api.liveblocks.io/v2/rooms/{roomId}/schema", { method: "DELETE", }); ``` ## The validation process in practice If you would like to work through an example, you can follow along by using the [Collaborative To-Do List](/examples/collaborative-todo-list/nextjs-todo-list). Let’s create a new schema called `todo-list` in your dashboard, with the following definition: ```ts type Storage { todos: LiveList> } type Todo { text: string checked?: boolean } ``` Schemas are automatically versioned to facilitate migrations. By saving the new schema, we have created the first version, named `todo-list@1`. To attach the schema we just created to a room, you can: - Navigate to the "next-js-todo-list-v2" room and click on "Attach Schema", as shown in the video above - Call the following endpoint: ```ts POST https://api.liveblocks.io/v2/rooms/nextjs-todo-list-v2/schema { "schema": "todo-list@1" } ``` To demonstrate the importance of schema validation and how unsafe operations will now fail, we will change the `checked` field in our schema as required, which means that new items added to the list will have to be checked by default. The new schema definition will look like this: ```ts type Storage { todos: LiveList> } type Todo { text: string checked: boolean } ``` After updating the schema, and attaching the new version to our room, we can now run our app locally by calling `npm run dev` and adding a new item to the list. The new item will cause the following error to be thrown: ``` Error: Storage mutations rejected by server: Missing required property 'checked' on type 'LiveList>'. ```
Other examples where schema validation would catch the error include: - Attempting to update the value of the `checked` variable to a number, as it is defined as a boolean in the schema - Attempting to delete the `checked` variable: it was not defined as optional in the schema ## How to handle validation errors When a schema validation error occurs, the LiveBlocks server will reject the operation When an instance like this occurs, it’s indicative of a bug in your app. In production, an error like this should never occur. This is because you should address validation errors in the same way you address TypeScript errors. You don't ship to production when your app has TypeScript errors, and neither do you ship to prod when there are schema validation errors. Validation errors are the responsibility of the developer to resolve. --- meta: title: "Schema syntax" parentTitle: "Schema validation" description: "Liveblocks schema syntax" --- This document describes the language rules for writing your own Liveblocks schemas. It is an exhaustive account of all features that are implemented and supported as part of the the public beta and is [open source](https://github.com/liveblocks/liveblocks/tree/main/schema-lang). We support: scalars, arrays, objects, optionals, `LiveObject`, `LiveList`, `LiveMap`, and most unions. We’re sharing our plans for other syntaxes so you can give us early feedback [here](https://github.com/liveblocks/liveblocks/discussions/674). ## Storage root Each schema must include the `Storage` type, a special type of “root” object. ```ts type Storage { } ``` ## Scalars Familiar scalar types are globally available when you create a schema: - `string` - `number` - `boolean` - `null` A sample schema using only scalar types could look like this: ```ts type Storage { name: string age: number hasSiblings: boolean favoritePet: string | null } ``` And here are some updates that would be accepted and rejected by the schema: ```ts // ✅ Valid storage updates root.set("name", "Marie Curie"); root.set("age", 66); root.set("hasSiblings", true); root.set("favoritePet", "Cooper"); root.set("favoritePet", null); // ❌ Invalid storage updates root.set("name", true); root.set("hasSiblings", null); root.set("favoritePet", 0); ``` You can also use literal types to restrict values even further: ```ts type Event { statusCode: 200 | 400 info: string } type Storage { theme: "light" | "dark" history: LiveList } ``` ## Optionals Each field inside an object type can be marked optional using the `?` operator. An optional field means that it can be deleted. For example, to make the `age` field optional: ```ts highlight="3" type Storage { name: string age?: number height: number hasSiblings: boolean } ``` Accepted and rejected updates: ```ts // ✅ root.delete("age"); // ❌ Field 'name' is not optional root.delete("name"); ``` Notice that we made the decision to make every field mandatory by default, as opposed to GraphQL. ## Objects Our language supports two different ways to declare object types: - Named object types ```ts type Scientist { name: string age: number } type Storage { scientist: Scientist } ``` - Anonymous object types (inlined) ```ts type Storage { scientist: { name: string, age: number } } ``` These definitions are equivalent. Accepted and rejected updates: ```ts // ✅ root.set("scientist", { name: "Marie Curie", age: 66 }); // ❌ Required field 'age' is missing root.set("scientist", { name: "Marie Curie" }); ``` ## LiveObject To use an object type definition as a “live” object, wrap it in the built-in [`LiveObject`](/docs/api-reference/liveblocks-client#LiveObject) construct, like so: ```ts type Scientist { name: string age: number } type Storage { scientist: LiveObject // ^^^^^^^^^^ } ``` Accepted and rejected updates: ```ts // ✅ root.set("scientist", new LiveObject({ name: "Marie Curie"; age: 66 })); // ❌ Should be a LiveObject root.set("scientist", { name: "Marie Curie"; age: 66 }); ``` ## Arrays Arrays can be defined like this: ```ts type Storage { animals: string[] } ``` Accepted and rejected updates: ```ts // ✅ root.set("animals", ["🦁", "🦊", "🐵"])); // ❌ Should contain strings root.set("animals", [1, 2, 2]); ``` ## LiveList To use a “live” array instead of a normal array, wrap your item type in a [`LiveList`](/docs/api-reference/liveblocks-client#LiveList) when you reference it. For example: ```ts type Storage { animals: LiveList // ^^^^^^^^ } ``` Accepted and rejected updates: ```ts // ✅ root.set("animals", new LiveList(["🦁", "🦊", "🐵"])); // ❌ Should be a LiveList root.set("animals", ["🦁", "🦊", "🐵"]); ``` ## LiveMap It’s also possible to define a [`LiveMap`](/docs/api-reference/liveblocks-client#LiveMap) in your schema. For example: ```ts type Shape { x: number y: number fill: "red" | "yellow" | "blue" } type Storage { shapes: LiveMap // ^^^^^^^ } ``` The first argument to a `LiveMap` construct must always be `string`. Accepted and rejected updates: ```ts // ✅ root.set( "shapes", new LiveMap([["shapeId", { x: 100, y: 100, fill: "blue" }]]) ); // ❌ Required field 'fill' is missing root.set("shapes", new LiveMap([["shapeId", { x: 100, y: 100 }]])); ``` ## Unions You can model a choice between two types using a union, which will be familiar from TypeScript. Here are some examples: ```ts type Storage { ids: (string | number)[] selectedId: string | null person: LiveObject | null people: LiveList> | null } ``` --- meta: title: "Sync Datastore" parentTitle: "Platform" description: "Embed a custom collaborative experience into your application using Liveblocks Storage and Liveblocks Yjs." --- Build a custom collaborative experience using Sync Datastore. This can include anything from whiteboards, flowcharts, spreadsheets, and more. Liveblocks permanently stores all sync engine data in each [room](/docs/concepts/how-liveblocks-works#Rooms), handling scaling and maintenance for you. ## Overview } /> } /> ## Examples using Sync Datastore --- meta: title: "Liveblocks Storage" parentTitle: "Sync Datastore" description: "Liveblocks Storage is a realtime sync engine designed for multiplayer creative tools such as Figma, Pitch, and Spline." --- Liveblocks Storage is a realtime sync engine designed for multiplayer creative tools such as Figma, Pitch, and Spline. `LiveList`, `LiveMap`, and `LiveObject` conflict-free data types can be used to build all sorts of multiplayer tools. Liveblocks permanently stores Storage data in each [room](/docs/concepts/how-liveblocks-works#Rooms), handling scaling and maintenance for you. ## API Reference ### Presence } /> } /> } /> } /> ### Broadcast } /> } /> } /> } /> ### Storage } /> } /> } /> } /> } /> } /> ## Examples using Liveblocks Storage --- meta: title: "Liveblocks Yjs" parentTitle: "Sync Datastore" description: "Liveblocks Yjs is a realtime sync engine designed for collaborative text editors such as Notion and Google Docs." --- Liveblocks Yjs is a realtime sync engine designed for building collaborative text editors such as Notion and Google Docs. Liveblocks permanently stores Yjs data in each [room](/docs/concepts/how-liveblocks-works#Rooms), handling scaling and maintenance for you. ## Text Editor vs Sync Datastore If you’re using Tiptap or Lexical, we recommend using [Text Editor](/docs/ready-made-features/text-editor) instead. This is an alternative to Sync Datastore, with extra features specifically created for those editors. Sync Datastore, powered by Liveblocks Yjs and Liveblocks Storage, is intended for use in other editors, or for completely custom solutions. ## Yjs API Reference } /> } /> } /> ## Examples using Liveblocks Yjs --- meta: title: "Troubleshooting" parentTitle: "Platform" description: "Troubleshoot common errors" --- ## Common issues [#common] Try the [Liveblocks DevTools extension](/devtools) to visualize your collaborative experiences as you build them, in realtime. ### ReferenceError: process is not defined [#process-not-defined] When calling `client.enterRoom()`, you stumble upon the following error: ```text ReferenceError: process is not defined ``` The `@liveblocks/client` package expects to be consumed by a JavaScript bundler, like Webpack, Babel, ESbuild, Rollup, etc. If you see this error, you have most likely directly loaded the `@liveblocks/client` source code through a ` ``` And replace `app.js` content with the code below, build your app with `npm run build` and open `static/index.html` in multiple browser windows. ```jsx file="app.js" highlight="12-16" import { createClient } from "@liveblocks/client"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, }); const whoIsHere = document.getElementById("who_is_here"); room.subscribe("others", (others) => { whoIsHere.innerHTML = `There are ${others.count} other users online`; }); } run(); ``` _If you want to make your app feel less "brutalist" while following along, create a file `static/index.css` with the following CSS._ ```css file="static/index.css" isCollapsed isCollapsable body { background-color: rgb(243, 243, 243); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; } .container { display: flex; flex-direction: column; width: 100%; margin-left: auto; margin-right: auto; margin-top: 3rem; max-width: 28rem; } input { box-sizing: border-box; padding: 0.5rem 0.875rem; margin: 0.5rem 0; width: 100%; background-color: white; box-shadow: 0 0 #000, 0 0 #000, 0 0 #000, 0 0 #000, 0 1px 2px 0 rgba(0, 0, 0, 0.05); border-radius: 0.5rem; color: black; border: 0 solid; font-size: 16px; } input:hover { box-shadow: 0 0 #000, 0 0 #000, 0 0 #000, 0 0 #000, 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } input:focus { outline: none; } .todo_container { display: flex; padding: 0.5rem 0.875rem; align-items: center; justify-content: space-between; } .todo { flex-grow: 1; } .delete_button { padding: 0; margin: 0; font-size: 16px; border: 0 solid; appearance: button; background-color: transparent; } .delete_button:focus { outline: none; } .who_is_here { align-self: flex-end; font-size: 11px; color: #aaa; } .someone_is_typing { position: absolute; font-size: 11px; color: #aaa; } ``` ## Show if someone is typing [#someone-is-typing]
Any users in the room can be typing, so we need to have a state `isTyping` per connected user. This state is only temporary, it is not persisted after users leave the room. Liveblocks has a concept of "presence" to handle this kind of temporary states. For example, a user "presence" can be used to share the cursor position or the selected shape if your building a design tool. We’re going to use [`room.updatePresence`][] hook to set the `presence` of the current user. First, add an input to `static/index.html` ```html file="static/index.html" highlight="11" Liveblocks - Todo list
``` Then listen to `keydown` and `blur` to detect when the user is typing. ```jsx file="App.js" highlight="13,19-32" import { createClient } from "@liveblocks/client"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, }); const whoIsHere = document.getElementById("who_is_here"); const todoInput = document.getElementById("todo_input"); room.subscribe("others", (others) => { whoIsHere.innerHTML = `There are ${others.count} other users online`; }); todoInput.addEventListener("keydown", (e) => { // Clear the input when the user presses "Enter". // We'll add todo later on if (e.key === "Enter") { room.updatePresence({ isTyping: false }); todoInput.value = ""; } else { room.updatePresence({ isTyping: true }); } }); todoInput.addEventListener("blur", () => { room.updatePresence({ isTyping: false }); }); } run(); ``` Now that we set the `isTyping` state when necessary, add a new `div` to display a message when at least one other user has `isTyping` equals to `true`. ```html file="static/index.html" highlight="12" Liveblocks - Todo list
``` ```jsx highlight="14,19-23" file="app.js" import { createClient } from "@liveblocks/client"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, }); const whoIsHere = document.getElementById("who_is_here"); const todoInput = document.getElementById("todo_input"); const someoneIsTyping = document.getElementById("someone_is_typing"); room.subscribe("others", (others) => { whoIsHere.innerHTML = `There are ${others.count} other users online`; someoneIsTyping.innerHTML = others .toArray() .some((user) => user.presence?.isTyping) ? "Someone is typing..." : ""; }); todoInput.addEventListener("keydown", (e) => { // Clear the input when the user presses "Enter". // We'll add todo later on if (e.key === "Enter") { room.updatePresence({ isTyping: false }); todoInput.value = ""; } else { room.updatePresence({ isTyping: true }); } }); todoInput.addEventListener("blur", () => { room.updatePresence({ isTyping: false }); }); } run(); ``` ## Sync and persist to-dos [#add-liveblocks-storage] Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. As opposed to the `presence`, some collaborative features require that every user interacts with the same piece of state. For example, in Google Doc, it is the paragraphs, headings, images in the document. In Figma, it’s all the shapes that make your design. That’s what we call the room’s `storage`.
The room’s storage is a conflicts-free state that multiple users can edit at the same time. It is persisted to our backend even after everyone leaves the room. Liveblocks provides custom data structures inspired by [CRDTs](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type) that can be nested to create the state that you want. - [`LiveObject`][] - Similar to JavaScript object. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. - [`LiveList`][] - An ordered collection of items synchronized across clients. Even if multiple users add/remove/move elements simultaneously, LiveList will solve the conflicts to ensure everyone sees the same collection of items. - [`LiveMap`][] - Similar to a JavaScript Map. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. We’re going to store the list of todos in a `LiveList`. Initialize the storage with the `initialStorage` option when entering the room. Then we use [`LiveList.push`][] when the user press "Enter". ```jsx highlight="1,8-11,17-19,28" file="src/App.js" import { createClient, LiveList } from "@liveblocks/client"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); async function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, initialStorage: { todos: new LiveList() }, }); const whoIsHere = document.getElementById("who_is_here"); const todoInput = document.getElementById("todo_input"); const someoneIsTyping = document.getElementById("someone_is_typing"); const { root } = await room.getStorage(); let todos = root.get("todos"); room.subscribe("others", (others) => { /* ... */ }); todoInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { room.updatePresence({ isTyping: false }); todos.push({ text: todoInput.value }); todoInput.value = ""; } else { room.updatePresence({ isTyping: true }); } }); todoInput.addEventListener("blur", () => { room.updatePresence({ isTyping: false }); }); } run(); ``` At this point, the todos are added to the storage but they are not rendered! Add a container for our todos and use [`room.subscribe(todos)`][] to get rerender the app whenever the todos are updated. ```html file="static/index.html" highlight="13" Liveblocks - Todo list
``` ```jsx highlight="16,34-56" file="src/App.js" import { createClient, LiveList } from "@liveblocks/client"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); async function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, initialStorage: { todos: new LiveList() }, }); const whoIsHere = document.getElementById("who_is_here"); const todoInput = document.getElementById("todo_input"); const someoneIsTyping = document.getElementById("someone_is_typing"); const todosContainer = document.getElementById("todos_container"); const { root } = await room.getStorage(); let todos = root.get("todos"); room.subscribe("others", (others) => { /* ... */ }); todoInput.addEventListener("keydown", (e) => { /* ... */ }); todoInput.addEventListener("blur", () => { /* ... */ }); function renderTodos() { todosContainer.innerHTML = ""; for (let i = 0; i < todos.length; i++) { const todo = todos.get(i); const todoContainer = document.createElement("div"); todoContainer.classList.add("todo_container"); const todoText = document.createElement("div"); todoText.classList.add("todo"); todoText.innerHTML = todo.text; todoContainer.appendChild(todoText); todosContainer.appendChild(todoContainer); } } room.subscribe(todos, () => { renderTodos(); }); renderTodos(); } run(); ``` Finally, add a delete button for each todo and call [`LiveList.delete`][] to remove a todo from the list by index. ```jsx highlight="48-54" file="src/App.js" import { createClient, LiveList } from "@liveblocks/client"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); async function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, initialStorage: { todos: new LiveList() }, }); const whoIsHere = document.getElementById("who_is_here"); const todoInput = document.getElementById("todo_input"); const someoneIsTyping = document.getElementById("someone_is_typing"); const todosContainer = document.getElementById("todos_container"); const { root } = await room.getStorage(); let todos = root.get("todos"); room.subscribe("others", (others) => { /* ... */ }); todoInput.addEventListener("keydown", (e) => { /* ... */ }); todoInput.addEventListener("blur", () => { /* ... */ }); function renderTodos() { todosContainer.innerHTML = ""; for (let i = 0; i < todos.length; i++) { const todo = todos.get(i); const todoContainer = document.createElement("div"); todoContainer.classList.add("todo_container"); const todoText = document.createElement("div"); todoText.classList.add("todo"); todoText.innerHTML = todo.text; todoContainer.appendChild(todoText); const deleteButton = document.createElement("button"); deleteButton.classList.add("delete_button"); deleteButton.innerHTML = "✕"; deleteButton.addEventListener("click", () => { todos.delete(i); }); todoContainer.appendChild(deleteButton); todosContainer.appendChild(todoContainer); } } room.subscribe(todos, () => { renderTodos(); }); renderTodos(); } run(); ``` In this tutorial, we discovered what’s a room, how to connect and enter a room. And how to use the room’s api to interact with its presence and storage. You can see some stats about the room you created in your [dashboard](https://liveblocks.io/dashboard/rooms).
Liveblocks dashboard
## Next steps - [API reference](/docs/api-reference/liveblocks-client) - [Authentication](/docs/authentication) [`@liveblocks/client`]: /docs/api-reference/liveblocks-client [`client.enter`]: /docs/api-reference/liveblocks-client#Client.enter [`createclient`]: /docs/api-reference/liveblocks-client#createClient [`livelist.delete`]: /docs/api-reference/liveblocks-client#LiveList.delete [`livelist.push`]: /docs/api-reference/liveblocks-client#LiveList.push [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`room.subscribe("others")`]: /docs/api-reference/liveblocks-client#Room.subscribe.others [`room.subscribe(todos)`]: /docs/api-reference/liveblocks-client#Room.subscribe(storageItem) [`room.updatepresence`]: /docs/api-reference/liveblocks-client#Room.updatePresence --- meta: title: "How to create a collaborative to-do list with React and Liveblocks" description: "Build a collaborative to-do list with React and Liveblocks" --- In this 15-minute tutorial, we’ll be building a collaborative to-do list using React and Liveblocks. As users edit the list, changes will be automatically synced and persisted, allowing for a list that updates in realtime across clients. Users will also be able to see who else is currently online, and when another user is typing.
This guide assumes that you’re already familiar with React, [Next.js](https://nextjs.org/) and [TypeScript](https://www.typescriptlang.org/). If you’re using a state-management library such as Redux or Zustand, we recommend reading one of our dedicated to-do list tutorials: - [React + Redux tutorial](/docs/tutorials/collaborative-to-do-list/react-redux) - [React + Zustand tutorial](/docs/tutorials/collaborative-to-do-list/react-zustand) The source code for this guide is [available on GitHub](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-todo-list). ## Install Liveblocks into your project [#install-liveblocks] ### Install Liveblocks packages Create a new app with [`create-next-app`](https://nextjs.org/docs): ```bash npx create-next-app@latest next-todo-list --typescript ``` For this tutorial, we won’t use the `src` directory or the experimental `app` directory. Then run the following command to install the Liveblocks packages: ```bash npm install @liveblocks/client @liveblocks/react ``` ## Join a Liveblocks room Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate. To create a realtime experience, multiple users must be connected to the same room. Create a file in the current directory within `/app`, and name it `Room.tsx`. Pass the location of your endpoint to `LiveblocksProvider`. ```tsx file="/pages/index.tsx" "use client"; import { ReactNode } from "react"; import { LiveblocksProvider, RoomProvider, ClientSideSuspense, } from "@liveblocks/react/suspense"; export default function Page() { return ( Loading…}> ); } function TodoList() { return (
{/* We’re starting to implement the to-do list in the next section */}
); } ``` ## Show who’s currently in the room [#who-is-here] Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. Now that Liveblocks is set up, we can start using the hooks to display how many users are currently online.
We’ll be doing this by adding [`useOthers`][], a selector hook that provides us information about which _other_ users are online. [`useOthers`][] takes a selector function that receives an array, `others`, containing information about each user. We can get the current user count from the length of that array. Add the following code to `pages/index.tsx`, and open your app in multiple windows to see it in action. ```tsx file="pages/index.tsx" import { LiveblocksProvider, RoomProvider, ClientSideSuspense, useOthers, } from "@liveblocks/react/suspense"; function WhoIsHere() { const userCount = useOthers((others) => others.length); return (
There are {userCount} other users online
); } function TodoList() { return (
); } /* Page */ ``` _For a tidier UI, replace the content of `styles/globals.css` file with the following css._ ```css file="styles/globals.css" isCollapsed isCollapsable body { background-color: rgb(243, 243, 243); } .container { display: flex; flex-direction: column; width: 100%; margin-left: auto; margin-right: auto; margin-top: 3rem; max-width: 28rem; } input { box-sizing: border-box; padding: 0.5rem 0.875rem; margin: 0.5rem 0; width: 100%; background-color: white; box-shadow: 0 0 #000, 0 0 #000, 0 0 #000, 0 0 #000, 0 1px 2px 0 rgba(0, 0, 0, 0.05); border-radius: 0.5rem; color: black; border: 0 solid; font-size: 16px; } input:hover { box-shadow: 0 0 #000, 0 0 #000, 0 0 #000, 0 0 #000, 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } input:focus { outline: none; } .todo_container { display: flex; padding: 0.5rem 0.875rem; align-items: center; justify-content: space-between; } .todo { flex-grow: 1; } .delete_button { padding: 0; margin: 0; font-size: 16px; border: 0 solid; appearance: button; background-color: transparent; } .delete_button:focus { outline: none; } .who_is_here { align-self: flex-end; font-size: 11px; color: #aaa; } .someone_is_typing { position: absolute; font-size: 11px; color: #aaa; } ``` ## Show if someone is typing [#someone-is-typing] Next, we’ll add some code to show a message when another user is typing.
Any online user could start typing, and we need to keep track of this, so it’s best if each user holds their own `isTyping` property. Luckily, Liveblocks uses the concept of _presence_ to handle these temporary states. A user’s presence can be used to represent the position of a cursor on screen, the selected shape in a design tool, or in this case, if they’re currently typing or not. Let’s define a new type `Presence` with the property `isTyping` in `liveblocks.config.ts` to ensure all our presence hooks are typed properly. ```tsx file="liveblocks.config.ts" declare global { interface Liveblocks { // +++ Presence: { isTyping: boolean }; // +++ } } ``` We can then call [`useUpdateMyPresence`][] whenever we wish to update the user’s current presence, in this case whether they’re typing or not. ```tsx file="pages/index.tsx" import { LiveblocksProvider, RoomProvider, ClientSideSuspense, useOthers, useUpdateMyPresence, } from "@liveblocks/react/suspense"; import { useState } from "react"; /* WhoIsHere */ function TodoList() { const [draft, setDraft] = useState(""); const updateMyPresence = useUpdateMyPresence(); return (
{ setDraft(e.target.value); updateMyPresence({ isTyping: true }); }} onKeyDown={(e) => { if (e.key === "Enter") { updateMyPresence({ isTyping: false }); setDraft(""); } }} onBlur={() => updateMyPresence({ isTyping: false })} />
); } /* Page */ ``` Now that we’re keeping track of everyone’s state, we can create a new component called `SomeoneIsTyping`, and use this to display a message whilst anyone else is typing. To check if anyone is typing, we’re iterating through `others` and returning true if `isTyping` is true for any user. ```tsx file="pages/index.tsx" import { LiveblocksProvider, RoomProvider, ClientSideSuspense, useOthers, useUpdateMyPresence, } from "@liveblocks/react/suspense"; import { useState } from "react"; /* WhoIsHere */ function SomeoneIsTyping() { const someoneIsTyping = useOthers((others) => others.some((other) => other.presence.isTyping) ); return (
{someoneIsTyping ? "Someone is typing..." : ""}
); } function TodoList() { const [draft, setDraft] = useState(""); const updateMyPresence = useUpdateMyPresence(); return (
); } /* Page */ ``` We also need to make sure that we pass an `initialPresence` for `isTyping` to `RoomProvider`. ```tsx file="pages/index.tsx" /* WhoIsHere */ /* SomeoneIsTyping */ /* TodoList */ export default function Page() { return ( // +++ // +++ Loading…}> ); } ``` ## Sync and persist to-dos [#add-liveblocks-storage] Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. To-do list items will be stored even after all users disconnect, so we won’t be using presence to store these values. For this, we need something new.
We’re going to use a [`LiveList`][] to store the list of todos inside the room’s storage, a type of storage that Liveblocks provides. A `LiveList` is similar to a JavaScript array, but its items are synced in realtime across different clients. Even if multiple users insert, delete, or move items simultaneously, the `LiveList` will still be consistent for all users in the room. First, let's declare a new type `Storage` in `liveblocks.config.ts`, like we did for `Presence`. This will ensure that our storage hooks are properly typed. ```tsx file="liveblocks.config.ts" import { LiveList } from "@liveblocks/client"; declare global { interface Liveblocks { Presence: { isTyping: boolean }; // +++ Storage: { todos: LiveList<{ text: string }>; }; // +++ } } ``` Go back to `Page` to initialize the storage with the `initialStorage` prop on the `RoomProvider`. ```tsx highlight="3,14" file="pages/index.tsx" /* ... */ import { LiveList } from "@liveblocks/client"; /* WhoIsHere */ /* SomeoneIsTyping */ /* TodoList */ export default function Page() { return ( Loading…}> ); } ``` ### Accessing storage [#accessing-liveblocks-storage] We’re going to use the [`useStorage`][] hook to get the list of todos previously created. `useStorage` allows us to select part of the storage from the `root` level. We can find our `todos` `LiveList` at `root.todos`, and we can map through our list to display each item. ```tsx file="pages/index.tsx" import { LiveblocksProvider, RoomProvider, ClientSideSuspense, useOthers, useUpdateMyPresence, useStorage, } from "@liveblocks/react/suspense"; import { LiveList } from "@liveblocks/client"; import { useState } from "react"; /* WhoIsHere */ /* SomeoneIsTyping */ function TodoList() { const [draft, setDraft] = useState(""); const updateMyPresence = useUpdateMyPresence(); const todos = useStorage((root) => root.todos); return (
{ if (e.key === "Enter") { updateMyPresence({ isTyping: false }); setDraft(""); } }} /> {todos.map((todo, index) => { return (
{todo.text}
); })}
); } /* Page */ ``` ### Setting storage [#setting-liveblocks-storage] To modify the list, we can use the [`useMutation`][] hook. This is a hook that works similarly to `useCallback`, with a dependency array, allowing you to create a reusable storage mutation. `useMutation` gives you access to the storage root, a [`LiveObject`][]. From here we can use [`LiveObject.get`][] to retrieve the `todos` list, then use [`LiveList.push`][] and [`LiveList.delete`][] to modify our todo list. These functions are then passed into the appropriate events. ```tsx file="pages/index.tsx" import { LiveblocksProvider, RoomProvider, ClientSideSuspense, useOthers, useUpdateMyPresence, useStorage, useMutation, } from "@liveblocks/react/suspense"; import { LiveList } from "@liveblocks/client"; import { useState } from "react"; /* WhoIsHere */ /* SomeoneIsTyping */ export default function TodoList() { const [draft, setDraft] = useState(""); const updateMyPresence = useUpdateMyPresence(); const todos = useStorage((root) => root.todos); const addTodo = useMutation(({ storage }, text) => { storage.get("todos").push({ text }) }, []); const deleteTodo = useMutation(({ storage }, index) => { storage.get("todos").delete(index); }, []); return (
{ if (e.key === "Enter") { updateMyPresence({ isTyping: false }); addTodo(draft); setDraft(""); } }} /> {todos.map((todo, index) => { return (
{todo.text}
); })}
); } /* Page */ ``` Voilà! We have a working collaborative to-do list, with persistent data storage. ## Summary In this tutorial, we’ve learnt about the concept of rooms, presence, and others. We’ve also learnt how to put all these into practice, and how to persist state using storage too. You can see some stats about the room you created in your [dashboard](https://liveblocks.io/dashboard/rooms).
Liveblocks dashboard
## Next steps - [API reference](/docs/api-reference/liveblocks-react) - [Authentication](/docs/authentication) [`livelist.delete`]: /docs/api-reference/liveblocks-client#LiveList.delete [`livelist.push`]: /docs/api-reference/liveblocks-client#LiveList.push [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`liveobject.get`]: /docs/api-reference/liveblocks-client#LiveList.push [`liveobject`]: /docs/api-reference/liveblocks-client#LiveList.push [`roomprovider`]: /docs/api-reference/liveblocks-react#RoomProvider [`create-react-app`]: https://create-react-app.dev/ [`createroomcontext`]: /docs/api-reference/liveblocks-react#createRoomContext [`useothers`]: /docs/api-reference/liveblocks-react#useOthers [`useupdatemypresence`]: /docs/api-reference/liveblocks-react#useUpdateMyPresence [`usestorage`]: /docs/api-reference/liveblocks-react#useStorage [`usemutation`]: /docs/api-reference/liveblocks-react#useMutation --- meta: title: "How to create a collaborative to-do list with React, Redux, and Liveblocks" description: "Build a collaborative to-do list with React, Redux, and Liveblocks" --- In this 15-minute tutorial, we’ll be building a collaborative to-do list using React, [Redux](https://redux.js.org/), and Liveblocks. As users edit the list, changes will be automatically synced and persisted, allowing for a list that updates in realtime across clients. Users will also be able to see who else is currently online, and when another user is typing.
This guide assumes that you’re already familiar with [React](https://reactjs.org/) and [Redux](https://redux.js.org/). If you’re not using Redux, we recommend reading one of our dedicated to-do list tutorials: - [React tutorial](/docs/tutorials/collaborative-to-do-list/react) - [React + Zustand tutorial](/docs/tutorials/collaborative-to-do-list/react-zustand) The source code for this guide is [available on github](https://github.com/liveblocks/liveblocks/tree/main/examples/redux-todo-list). ## Install Liveblocks into your project [#install-liveblocks] ### Install Liveblocks packages First, we need to create a new app with [`create-react-app`](https://create-react-app.dev/): ```bash npx create-react-app redux-todo-app ``` Then run the following command to install the Liveblocks packages and Redux: ```bash npm install redux react-redux @reduxjs/toolkit @liveblocks/client @liveblocks/redux ``` [`@liveblocks/client`](/docs/api-reference/liveblocks-client) lets you interact with Liveblocks servers. [`@liveblocks/redux`](/docs/api-reference/liveblocks-redux) contains a Liveblocks enhancer for a redux store. ### Connect to Liveblocks servers [#connect-liveblocks-servers] In order to use Liveblocks, we’ll need to sign up and get an API key. [Create an account](/api/auth/signup), then navigate to [the dashboard](/dashboard/apikeys) to find your public key (it starts with `pk_`). With a secret key, you can control who can access the room. it’s more secure but you need your own back-end endpoint. For this tutorial, we’ll go with a public key. For more info, see the [authentication guide](/docs/authentication). Create a new file `src/store.js` and initialize the Liveblocks client with your public API key. Then add our [enhancer](/docs/api-reference/liveblocks-redux#enhancer) to your store configuration. ```js file="src/store.js" import { createClient } from "@liveblocks/client"; import { liveblocksEnhancer } from "@liveblocks/redux"; import { configureStore, createSlice } from "@reduxjs/toolkit"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); const initialState = {}; const slice = createSlice({ name: "state", initialState, reducers: { /* logic will be added here */ }, }); export function makeStore() { return configureStore({ reducer: slice.reducer, enhancers: [ liveblocksEnhancer({ client, }), ], }); } const store = makeStore(); export default store; ``` We need to edit `src/index.js` to add the react-redux provider: ```js highlight="6,7,11,13" file="src/index.js" import React from "react"; import ReactDOM from "react-dom"; import "./index.css"; import App from "./App"; import { Provider } from "react-redux"; import store from "./store"; ReactDOM.render( , document.getElementById("root") ); ``` ### Connect to a Liveblocks room [#connect-liveblocks-room] Liveblocks uses the concept of _rooms_, separate virtual spaces where people can collaborate. To create a multiplayer experience, multiple users must be connected to the same room. We are going to dispatch the actions provided by `@liveblocks/redux` to [`enter`](/docs/api-reference/liveblocks-redux#actions-enter) and [`leave`](/docs/api-reference/liveblocks-redux#actions-leave) the room. In our main component, we want to connect to the Liveblocks room when the component does mount, and leave the room when it unmounts. ```js file="src/App.js" import React, { useEffect } from "react"; import { useDispatch } from "react-redux"; import { actions } from "@liveblocks/redux"; import "./App.css"; export default function App() { const dispatch = useDispatch(); useEffect(() => { dispatch(actions.enterRoom("redux-demo-room")); return () => { dispatch(actions.leaveRoom("redux-demo-room")); }; }, [dispatch]); return
To-do list app
; } ``` ## Show who’s currently in the room [#who-is-here] Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. Now that Liveblocks is set up, we can start updating our code to display how many users are currently online.
We’ll be doing this by using the injected object [`liveblocks.others`](/docs/api-reference/liveblocks-redux#liveblocks-state-others) to show who’s currently inside the room. ```js highlight="2,7-17,35-37" file="src/App.js" import React, { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { actions } from "@liveblocks/redux"; import "./App.css"; function WhoIsHere() { const othersUsersCount = useSelector( (state) => state.liveblocks.others.length ); return (
There are {othersUsersCount} other users online
); } export default function App() { const dispatch = useDispatch(); useEffect(() => { dispatch(actions.enterRoom("redux-demo-room")); return () => { dispatch(actions.leaveRoom("redux-demo-room")); }; }, [dispatch]); return (
); } ``` _For a tidier look, here's some styling to place within `src/App.css`._ ```css file="src/App.css" isCollapsed isCollapsable body { background-color: rgb(243, 243, 243); } .container { display: flex; flex-direction: column; width: 100%; margin-left: auto; margin-right: auto; margin-top: 3rem; max-width: 28rem; } input { box-sizing: border-box; padding: 0.5rem 0.875rem; margin: 0.5rem 0; width: 100%; background-color: white; box-shadow: 0 0 #000, 0 0 #000, 0 0 #000, 0 0 #000, 0 1px 2px 0 rgba(0, 0, 0, 0.05); border-radius: 0.5rem; color: black; border: 0 solid; font-size: 16px; } input:hover { box-shadow: 0 0 #000, 0 0 #000, 0 0 #000, 0 0 #000, 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } input:focus { outline: none; } .todo_container { display: flex; padding: 0.5rem 0.875rem; align-items: center; justify-content: space-between; } .todo { flex-grow: 1; } .delete_button { padding: 0; margin: 0; font-size: 16px; border: 0 solid; appearance: button; background-color: transparent; } .delete_button:focus { outline: none; } .who_is_here { align-self: flex-end; font-size: 11px; color: #aaa; } .someone_is_typing { position: absolute; font-size: 11px; color: #aaa; } ``` ## Show if someone is typing [#someone-is-typing] Next, we'll add some code to show a message when another user is typing.
Any online user could start typing, and we need to keep track of this, so it's best if each user holds their own `isTyping` property. Luckily, Liveblocks uses the concept of presence to handle these temporary states. A user's presence can be used to represent the position of a cursor on screen, the selected shape in a design tool, or in this case, if they're currently typing or not. We want to add some data to our redux store, `draft` will contain the value of the input. `isTyping` will be set when the user is writing a draft. The enhancer option [`presenceMapping: { isTyping: true }`](/docs/api-reference/liveblocks-redux#enhancer-option-presence-mapping) means that we want to automatically sync the part of the state named `isTyping` to Liveblocks Presence. ```js highlight="10-11,18-21,25,33" file="src/store.js" import { createClient } from "@liveblocks/client"; import { liveblocksEnhancer } from "@liveblocks/redux"; import { configureStore, createSlice } from "@reduxjs/toolkit"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); const initialState = { draft: "", isTyping: false, }; const slice = createSlice({ name: "state", initialState, reducers: { setDraft: (state, action) => { state.isTyping = action.payload === "" ? false : true; state.draft = action.payload; }, }, }); export const { setDraft } = slice.actions; export function makeStore() { return configureStore({ reducer: slice.reducer, enhancers: [ liveblocksEnhancer({ client, presenceMapping: { isTyping: true }, }), ], }); } const store = makeStore(); export default store; ``` Now that we set the `isTyping` state when necessary, let's create a new component called `SomeoneIsTyping` to display a message when at least one other user has `isTyping` equals to `true`. ```js highlight="5,11-19,22,40-47" file="src/App.js" import React, { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { actions } from "@liveblocks/redux"; import { setDraft } from "./store"; import "./App.css"; /* WhoIsHere */ function SomeoneIsTyping() { const someoneIsTyping = useSelector((state) => state.liveblocks.others.some((user) => user.presence?.isTyping) ); return someoneIsTyping ? (
Someone is typing
) : null; } export default function App() { const draft = useSelector((state) => state.draft); const dispatch = useDispatch(); useEffect(() => { dispatch(actions.enterRoom("redux-demo-room")); return () => { dispatch(actions.leaveRoom("redux-demo-room")); }; }, [dispatch]); return (
dispatch(setDraft(e.target.value))} >
); } ``` ## Sync and persist to-dos [#add-liveblocks-storage] Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. To-do list items will be stored even after all users disconnect, so we won't be using presence to store these values. For this, we need something new.
Add an array of todos to your redux store, and tell the enhancer to sync and persist them with Liveblocks. To achieve that, we are going to use the enhancer option [`storageMapping: { todos: true }`](/docs/api-reference/liveblocks-redux#enhancer-option-storage-mapping). It means that the part of the state named `todos` will be synced with Liveblocks Storage. ```js highlight="10,23-30,34,42" file="src/store.js" import { createClient } from "@liveblocks/client"; import { liveblocksEnhancer } from "@liveblocks/redux"; import { configureStore, createSlice } from "@reduxjs/toolkit"; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); const initialState = { todos: [], draft: "", isTyping: false, }; const slice = createSlice({ name: "state", initialState, reducers: { setDraft: (state, action) => { state.isTyping = action.payload === "" ? false : true; state.draft = action.payload; }, addTodo: (state) => { state.isTyping = false; state.todos.push({ text: state.draft }); state.draft = ""; }, deleteTodo: (state, action) => { state.todos.splice(action.payload, 1); }, }, }); export const { setDraft, addTodo, deleteTodo } = slice.actions; export function makeStore() { return configureStore({ reducer: slice.reducer, enhancers: [ liveblocksEnhancer({ client, storageMapping: { todos: true }, presenceMapping: { isTyping: true }, }), ], }); } const store = makeStore(); export default store; ``` We can display the list of todos and use the actions `addTodo` and `deleteTodo` to update our list: ```js highlight="5,13,38-41,45-57" file="src/App.js" import React, { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { actions } from "@liveblocks/redux"; import { setDraft, addTodo, deleteTodo } from "./store"; import "./App.css"; /* WhoIsHere */ /* SomeoneIsTyping */ export default function App() { const todos = useSelector((state) => state.todos); const draft = useSelector((state) => state.draft); const dispatch = useDispatch(); useEffect(() => { dispatch(actions.enterRoom("redux-demo-room")); return () => { dispatch(actions.leaveRoom("redux-demo-room")); }; }, [dispatch]); return (
dispatch(setDraft(e.target.value))} onKeyDown={(e) => { if (e.key === "Enter") { dispatch(addTodo()); } }} > {todos.map((todo, index) => { return (
{todo.text}
); })}
); } ``` Voilà! We have a working collaborative to-do list, with persistent data storage. ## Summary In this tutorial, we’ve learnt about the concept of rooms, presence, and others. We've also learnt how to put all these into practice, and how to persist state using storage too. You can see some stats about the room you created in your [dashboard](https://liveblocks.io/dashboard/rooms).
Liveblocks dashboard
## Next steps - [API reference](/docs/api-reference/liveblocks-redux) - [Authentication](/docs/authentication) --- meta: title: "How to create a collaborative to-do list with React, Zustand, and Liveblocks" description: "Build a collaborative to-do list with React, Zustand, and Liveblocks" --- In this 15-minute tutorial, we’ll be building a collaborative to-do list using React, [Zustand](https://github.com/pmndrs/zustand), and Liveblocks. As users edit the list, changes will be automatically synced and persisted, allowing for a list that updates in realtime across clients. Users will also be able to see who else is currently online, and when another user is typing.
This guide assumes that you’re already familiar with [React](https://reactjs.org/) and [Zustand](https://github.com/pmndrs/zustand). If you’re not using Zustand, we recommend reading one of our dedicated to-do list tutorials: - [React tutorial](/docs/tutorials/collaborative-to-do-list/react) - [React + Redux tutorial](/docs/tutorials/collaborative-to-do-list/react-redux) The source code for this guide is [available on github](https://github.com/liveblocks/liveblocks/tree/main/examples/zustand-todo-list). ## Install Liveblocks into your project [#install-liveblocks] ### Install Liveblocks packages First, we need to create a new app with [`create-react-app`](https://create-react-app.dev/): ```bash npx create-react-app zustand-todo-app --template typescript ``` To start a plain JavaScript project, you can omit the `--template typescript` flag. Then install the Liveblocks packages and Zustand: ```bash npm install zustand @liveblocks/client @liveblocks/zustand ``` [`@liveblocks/client`](/docs/api-reference/liveblocks-client) lets you interact with Liveblocks servers. [`@liveblocks/zustand`](/docs/api-reference/liveblocks-zustand) contains a middleware for Zustand. ### Connect to Liveblocks servers [#connect-liveblocks-servers] In order to use Liveblocks, we’ll need to sign up and get an API key. [Create an account](/api/auth/signup), then navigate to [the dashboard](/dashboard/apikeys) to find your public key (it starts with `pk_`). With a secret key, you can control who can access the room. it’s more secure but you need your own back-end endpoint. For this tutorial, we’ll go with a public key. For more info, see the [authentication guide](/docs/authentication). Create a new file `src/store.ts` and initialize the Liveblocks client with your public API key. Then add our [liveblocks](/docs/api-reference/liveblocks-zustand#liveblocks) to your store configuration. ```ts file="src/store.ts" import create from "zustand"; import { createClient } from "@liveblocks/client"; import { liveblocks } from "@liveblocks/zustand"; import type { WithLiveblocks } from "@liveblocks/zustand"; type State = { // Your Zustand state type will be defined here }; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); const useStore = create>()( liveblocks( (set) => ({ // Your state and actions will go here }), { client } ) ); export default useStore; ``` ### Connect to a Liveblocks room [#connect-liveblocks-room] Liveblocks uses the concept of _rooms_, separate virtual spaces where people can collaborate. To create a collaborative experience, multiple users must be connected to the same room. Our middleware injected the object `liveblocks` to the store. Inside that object, the first methods that we are going to use are [`enterRoom`](/docs/api-reference/liveblocks-zustand#liveblocks-state-enter-room) and [`leaveRoom`](/docs/api-reference/liveblocks-zustand#liveblocks-state-leave-room). In our main component, we want to connect to the Liveblocks room when the component does mount, and leave the room when it unmounts. ```tsx file="src/App.tsx" import React, { useEffect } from "react"; import useStore from "./store"; import "./App.css"; export default function App() { const { liveblocks: { enterRoom, leaveRoom }, } = useStore(); useEffect(() => { enterRoom("zustand-todo-app"); return () => { leaveRoom("zustand-todo-app"); }; }, [enterRoom, leaveRoom]); return
To-do list app
; } ``` ## Show who’s currently in the room [#who-is-here] Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. Now that Liveblocks is set up, we’re going to use the injected object [`liveblocks.others`](/docs/api-reference/liveblocks-zustand#liveblocks-state-others) to show who’s currently inside the room.
```tsx highlight="6-14,30" file="src/App.tsx" import React, { useEffect } from "react"; import useStore from "./store"; import "./App.css"; function WhoIsHere() { const othersUsersCount = useStore((state) => state.liveblocks.others.length); return (
There are {othersUsersCount} other users online
); } export default function App() { const { liveblocks: { enterRoom, leaveRoom }, } = useStore(); useEffect(() => { enterRoom("zustand-todo-app"); return () => { leaveRoom("zustand-todo-app"); }; }, [enterRoom, leaveRoom]); return (
); } ``` _For a tidier look, here's some styling to place within `src/App.css`._ ```css file="src/App.css" isCollapsed isCollapsable body { background-color: rgb(243, 243, 243); } .container { display: flex; flex-direction: column; width: 100%; margin-left: auto; margin-right: auto; margin-top: 3rem; max-width: 28rem; } input { box-sizing: border-box; padding: 0.5rem 0.875rem; margin: 0.5rem 0; width: 100%; background-color: white; box-shadow: 0 0 #000, 0 0 #000, 0 0 #000, 0 0 #000, 0 1px 2px 0 rgba(0, 0, 0, 0.05); border-radius: 0.5rem; color: black; border: 0 solid; font-size: 16px; } input:hover { box-shadow: 0 0 #000, 0 0 #000, 0 0 #000, 0 0 #000, 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); } input:focus { outline: none; } .todo_container { display: flex; padding: 0.5rem 0.875rem; align-items: center; justify-content: space-between; } .todo { flex-grow: 1; } .delete_button { padding: 0; margin: 0; font-size: 16px; border: 0 solid; appearance: button; background-color: transparent; } .delete_button:focus { outline: none; } .who_is_here { align-self: flex-end; font-size: 11px; color: #aaa; } .someone_is_typing { position: absolute; font-size: 11px; color: #aaa; } ``` ## Show if someone is typing [#someone-is-typing] Next, we'll add some code to show a message when another user is typing.
Any online user could start typing, and we need to keep track of this, so it's best if each user holds their own `isTyping` property. Luckily, Liveblocks uses the concept of presence to handle these temporary states. A user's presence can be used to represent the position of a cursor on screen, the selected shape in a design tool, or in this case, if they're currently typing or not. We want to add some data to our Zustand store, `draft` will contain the value of the input. `isTyping` will be set when the user is writing a draft. The middleware option [`presenceMapping: { isTyping: true }`](/docs/api-reference/liveblocks-zustand#middleware-option-presence-mapping) means that we want to automatically sync the part of the state named `isTyping` to Liveblocks Presence. ```ts highlight="7-9,19-21,25" file="src/store.ts" import create from "zustand"; import { createClient } from "@liveblocks/client"; import { liveblocks } from "@liveblocks/zustand"; import type { WithLiveblocks } from "@liveblocks/zustand"; type State = { draft: string; isTyping: boolean; setDraft: (draft: string) => void; }; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); const useStore = create>()( liveblocks( (set) => ({ draft: "", isTyping: false, setDraft: (draft) => set({ draft, isTyping: draft !== "" }), }), { client, presenceMapping: { isTyping: true }, } ) ); export default useStore; ``` Now that we set the `isTyping` state when necessary, create a new component called `SomeoneIsTyping` to display a message when at least one other user has `isTyping` equals to `true`. ```tsx highlight="8-16,20-21,35-42" file="src/App.tsx" import React, { useEffect } from "react"; import useStore from "./store"; import "./App.css"; /* WhoIsHere */ function SomeoneIsTyping() { const others = useStore((state) => state.liveblocks.others); const someoneIsTyping = others.some((user) => user.presence.isTyping); return someoneIsTyping ? (
Someone is typing
) : null; } export default function App() { const { draft, setDraft, liveblocks: { enterRoom, leaveRoom }, } = useStore(); useEffect(() => { enterRoom("zustand-todo-app"); return () => { leaveRoom("zustand-todo-app"); }; }, [enterRoom, leaveRoom]); return (
setDraft(e.target.value)} >
); } ``` ## Sync and persist to-dos [#add-liveblocks-storage] Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. To-do list items will be stored even after all users disconnect, so we won't be using presence to store these values. For this, we need something new.
Add an array of todos to your Zustand store, and tell the middleware to sync and persist them with Liveblocks. To achieve that, we are going to use the middleware option [`storageMapping: { todos: true }`](/docs/api-reference/liveblocks-zustand#middleware-option-storage-mapping). It means that the part of the state named `todos` should be automatically synced with Liveblocks Storage. ```ts highlight="6,8-9,19,21-29,34" file="src/store.ts" /* ... */ type State = { draft: string; isTyping: boolean; todos: { text: string }[]; setDraft: (draft: string) => void; addTodo: () => void; deleteTodo: (index: number) => void; }; /* ... */ const useStore = create>()( liveblocks( (set) => ({ draft: "", isTyping: false, todos: [], setDraft: (draft) => set({ draft, isTyping: draft !== "" }), addTodo: () => set((state) => ({ todos: state.todos.concat({ text: state.draft }), draft: "", })), deleteTodo: (index) => set((state) => ({ todos: state.todos.filter((_, i) => index !== i), })), }), { client, presenceMapping: { isTyping: true }, storageMapping: { todos: true }, } ) ); export default useStore; ``` We can display the list of todos and use the functions `addTodo` and `deleteTodo` to update our list: ```tsx highlight="13-15,39-43,46-60" file="src/App.tsx" import React, { useEffect } from "react"; import useStore from "./store"; import "./App.css"; /* WhoIsHere */ /* SomeoneIsTyping */ export default function App() { const { draft, setDraft, todos, addTodo, deleteTodo, liveblocks: { enterRoom, leaveRoom, isStorageLoading }, } = useStore(); useEffect(() => { enterRoom("zustand-todo-app"); return () => { leaveRoom("zustand-todo-app"); }; }, [enterRoom, leaveRoom]); if (isStorageLoading) { return
Loading...
; } return (
setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { addTodo(); } }} > {todos.map((todo, index) => { return (
{todo.text}
); })}
); } ``` Voilà! We have a working collaborative to-do list, with persistent data storage. ## Summary In this tutorial, we’ve learnt about the concept of rooms, presence, and others. We've also learnt how to put all these into practice, and how to persist state using storage too. You can see some stats about the room you created in your [dashboard](https://liveblocks.io/dashboard/rooms).
Liveblocks dashboard
## Next steps - [API reference](/docs/api-reference/liveblocks-zustand) - [Authentication](/docs/authentication) --- meta: title: "How to create a notification settings panel" description: "Learn how to create a notifications preference interface, allowing users to choose which notifications they should receive." --- [Notifications](/docs/ready-made-features/notifications) allows you to add in-app notifications to your product. However, it also allows you to send unread notifications via other channels, such as email, Slack, Microsoft Teams—an effective way to link back to your app and keep users engaged. Additionally, Liveblocks allows your end users to individually choose which notifications they should receive, and on which channel. This guide shows you how to create a notification preferences panel, which enables this.
Notification settings
## Set up webhooks first Before starting, it’s important that you’ve already set up a notification channel in our dashboard, and set up a webhook endpoint. Here are two guides that take you through how to do this: - [How to send email notifications of unread comments](/docs/guides/how-to-send-email-notifications-of-unread-comments). - [How to send email notifications for unread text editor mentions](/docs/guides/how-to-send-email-notifications-for-unread-text-editor-mentions). ## Enabling a notification channel If you’ve followed one of the previous guides, you should already have enabled a notification kind for a certain channel in the Notifications dashboard (this is different to setting up a webhook), however if you already have a production project, make sure of [what to check before enabling a notification kind](/docs/guides/what-to-check-before-enabling-a-new-notification-kind) before continuing, as you may change the behavior of your app.
Notifications dashboard page
Each notification channel has a unique identifier, such as `email` or `slack`, but they all function in the same way. These distinct names allow developers to customize notifications for different platforms. ## Building the interface Now that the back end’s set up, you can use Liveblocks hooks to build the interface. Each user in your app can set their own preferences for notifications, and after enabling a notification kind, each user’s will be set to the default value in the Notifications dashboard. With [`useNotificationSettings`](/docs/api-reference/liveblocks-react#useNotificationSettings) you can see the default value in your app. For example, the value if thread notifications on the email channel are enabled: ```tsx import { useNotificationSettings } from "@liveblocks/react"; function NotificationSettings() { // +++ const [{ isLoading, error, settings }, updateSettings] = useNotificationSettings(); // +++ if (isLoading || error) { return null; } // +++ // { email: { thread: true } } console.log(settings); // +++ } ``` You can also use this function to toggle values—**this will disable corresponding webhook events for the current user, meaning they no longer receive notifications of that type, on that channel**. All other webhook events are sent as normal, only the current user is affected. Here’s how to create a checkbox that toggles the current user’s email/thread setting, disabling the corresponding webhook. ```tsx import { useNotificationSettings } from "@liveblocks/react"; function NotificationSettings() { const [{ isLoading, error, settings }, updateSettings] = useNotificationSettings(); if (isLoading || error) { return null; } return ( <> {settings.email ? ( <> // +++ // +++ ) : null} ); } ``` Also make sure to check if the notification kind is enabled before creating the UI. This way, if it’s toggled in the dashboard, it will gracefully disappear/reappear. We’re doing this above by checking if `settings.email` exists before displaying the `input`. ### Custom notification kinds You can also use [custom notification kinds](/docs/ready-made-features/notifications/concepts#Custom-notifications) as highlighted below, where we’re using a custom `$documentInvite` kind. ```tsx import { useNotificationSettings } from "@liveblocks/react"; function NotificationSettings() { const [{ isLoading, error, settings }, updateSettings] = useNotificationSettings(); if (isLoading || error) { return null; } return ( <> {settings.email ? ( <> // +++ // +++ ) : null} ); } ``` Note that you can [type your app](/docs/api-reference/liveblocks-react#Typing-your-data) to receive hints for custom notifications. ```tsx file="liveblocks.config.ts" declare global { interface Liveblocks { // Custom activities data for custom notification kinds ActivitiesData: { // +++ $documentInvite: { documentId: string; // Example }; // +++ }; // ... } } ``` ### Extend it further If you extend this further, you can create a whole notifications settings panel, with settings for each notification kind on channel. Below we’ve created an interface with three kinds of two channels, email and Slack. Like with `settings.email`, we’re also checking if `settings.slack` exists before using it. ```tsx import { useNotificationSettings } from "@liveblocks/react"; function NotificationSettings() { const [{ isLoading, error, settings }, updateSettings] = useNotificationSettings(); if (isLoading || error) { return null; } return ( <> {settings.email ? ( <> ) : null} {settings.thread ? ( <> ) : null} ); } ``` You now have a notification settings interface that allows each user to choose their own settings! This works by toggling webhooks on specific channels and kinds. ## Other methods Should you need to access and modify user notification settings outside of React, we also provide [JavaScript functions](/docs/api-reference/liveblocks-client#Client.getNotificationSettings), [Node.js methods](/docs/api-reference/liveblocks-node#get-users-userId-notification-settings), and [REST API endpoints](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-users-userId-notification-settings). --- meta: title: "How to filter rooms using query language" description: "Learn how to filter for certain rooms using their names and metadata with our query language" --- When retrieving rooms with our REST API, it’s possible to filter for specific threads using [custom metadata](/docs/ready-made-features/comments/metadata) and room ID prefixes using our custom query language. This enables the [Get Rooms REST API](/docs/api-reference/rest-api-endpoints#get-rooms) to have filtering that works the same as with [`liveblocks.getRooms`](/docs/api-reference/liveblocks-node#get-rooms). ## Query language You can filter rooms by their metadata and room ID prefixes, which is helpful when you’re using a [naming pattern](/docs/authentication/access-token#Naming-pattern) for your rooms. Filters can be combined using `AND` logic. ```js // Rooms with IDs that begin with "liveblocks:" roomId^'liveblocks:' // Rooms with { roomType: 'whiteboard' } string metadata metadata['roomType']:'whiteboard' // Combine queries with AND roomId^'liveblocks:' AND metadata['roomType']:'whiteboard' // Use multiple metadata filters at once metadata['roomType']:'whiteboard' AND metadata['creator']:'florent' ``` The `AND` is optional and can actually be omitted, but we’re using it here for clarity. Note that room metadata can contain `strings` or `arrays`, but only `strings` can be filtered. If you wish to return a single specific room, instead use the [Get Room API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId) or [`liveblocks.getRoom`](/docs/api-reference/liveblocks-node#get-rooms-roomId). ### How to use To use the query language with the [REST API](/docs/api-reference/rest-api-endpoints#get-rooms) pass your query string to the `query` parameter. For example, given this query: ```js roomId^'liveblocks:' AND metadata['roomType']:'whiteboard' ``` Encode it, and add it to the `query` parameter: ``` https://api.liveblocks.io/v2/rooms?query=roomId%5E'liveblocks%3A'%20AND%20metadata%5B'roomType'%5D%3A'whiteboard' ``` To learn more on _setting_ custom metadata on rooms, make sure to [read our guide](/docs/rooms/metadata). --- meta: title: "How to filter threads with query language" description: "Learn how to filter for certain threads using their metadata with our query language" --- When using Comments and retrieving threads with our REST API, it’s possible to filter for specific threads using [custom metadata](/docs/ready-made-features/comments/metadata) and our custom query language. This enables the [Get Threads REST API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-threads) to have filtering that works the same as with [`useThreads`](/docs/api-reference/liveblocks-react#useThreads) and [`liveblocks.getThreads`](/docs/api-reference/liveblocks-node#get-rooms-roomId-threads). ## Query language You can filter threads by their metadata, allowing you to select for certain properties, values, or even for string prefixes. Filters can be combined using `AND` logic. ```js // Resolved threads resolved:true // Threads with { status: 'open' } string metadata metadata['status']:'open' // Threads with `{ org }` string metadata that starts with "liveblocks:" metadata['org']^'liveblocks:' // Threads with { priority: 3 } number metadata metadata['priority']:3 // Threads with { pinned: false } boolean metadata metadata['pinned']:false // Threads without a `color` property metadata['color']:null // Combine queries with AND resolved:true AND metadata['priority']:3 // A more complex combination metadata['status']:'closed' AND metadata['org']^'liveblocks:' ``` The `AND` is optional and can actually be omitted, but we’re using it here for clarity. ### How to use To use the query language with the [REST API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-threads) pass your query string to the `query` parameter. For example, given this query: ```js metadata['status']:'open' AND metadata['priority']:3 ``` Encode it, and add it to the `query` parameter: ``` https://api.liveblocks.io/v2/rooms/{roomId}/threads?query=metadata%5B'status'%5D%3A'open'%20AND%20metadata%5B'priority'%5D%3A3 ``` To learn more on _setting_ custom metadata on threads, make sure to [read our guide](/docs/ready-made-features/comments/metadata). --- meta: title: "Grant access to individual rooms with access tokens" description: "Learn how to grant access to individual rooms with access tokens" --- If you’re looking to build an application with permissions at organization, group, and user levels, we recommend using [ID tokens](/docs/authentication/id-token) instead. Access tokens have [limitations when granting nested permissions](#limitations). With [access tokens](/docs/authentication/access-token) we always recommend using a [naming pattern](/docs/authentication/access-token#Naming-pattern) to grant access to multiple rooms at once, for example every room in a user’s organization. ```ts // ✅ Grants access to every `acme` organization room session.allow(`session.allow(`acme:*`, session.FULL_ACCESS); ``` However, it may not always be possible to grant access to every room with a wildcard and naming pattern. One example would be if a user is invited to _only one room_ in a _different_ organization. There’s a way to work around this limitation. ## Grant access to individual rooms When using [`authEndpoint`](/docs/api-reference/liveblocks-client#createClientAuthEndpoint), Liveblocks provides the current room ID in the `request`. Below is a Next.js example, where the current room ID is taken from the body, and the user is allowed access to the room. Note that `room` is `undefined` when [Notifications](/docs/ready-made-features/comments/email-notifications) is authenticating, which is why we’re checking if it exists. Notifications works across rooms, and it doesn’t require any permissions. ```tsx highlight="17,19-21" import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "sk_prod_xxxxxxxxxxxxxxxxxxxxxxxx", }); export async function POST(request: Request) { // Get the current user from your database const user = __getUserFromDB__(request); // Start an auth session inside your endpoint const session = liveblocks.prepareSession( user.id, { userInfo: user.metadata } // Optional ); const { room } = request.body; if (room && __shouldUserHaveAccess__(user, room)) { session.allow(room, session.FULL_ACCESS); } // Authorize the user and return the result const { status, body } = await session.authorize(); return new Response(body, { status }); } ``` This approach relies on you creating the `__shouldUserHaveAccess__` function, and determining whether the user is allowed inside the room. --- meta: title: "How to migrate to Liveblocks Comments" description: "Learn how to import your threads, comments, and reaction to Liveblocks Comments in this migration guide." --- To migrate your threads, comments, and reactions to Liveblocks Comments, you can create a migration script using our [Node.js methods](https://liveblocks.io/docs/api-reference/liveblocks-node) or [REST API](https://liveblocks.io/docs/api-reference/rest-api-endpoints). This guide will take you through all the Liveblocks features required to create a migration script in Node.js. Note that each Node.js method also has an equivalent REST API which could be used instead. Before starting to migrate, make sure to [set up authentication](https://liveblocks.io/docs/authentication) in your app, deciding on either _ID tokens_ or _access tokens_. ## Creating rooms The first step is to create the multiplayer room where the comments are stored. This is equivalent to a document in your project. Make sure to read the sections on permissions under [access tokens](/docs/authentication/access-token#permissions) or [ID tokens](/docs/authentication/id-token#permissions) to fully understand setting up your room. Create a room using [`liveblocks.createRoom`](/docs/api-reference/liveblocks-node#post-rooms) or the [Create Room API](/docs/api-reference/rest-api-endpoints#post-rooms), and set permissions if you’re using ID tokens. ```ts // The unique ID for the room const roomId = "my-room-id"; // Create a room const room = await liveblocks.createRoom(roomId, { // If you're using ID tokens, set your permissions/accesses, examples below defaultAccesses: [ // "room:write"` ], // groupsAccesses: { // my-group: ["room:write"] // } // usersAccesses: { // my-user: ["room:write"] // } }); ``` ## Creating threads Next up is creating threads in the room. Before starting, make sure you understand the [concepts behind Comments](/docs/ready-made-features/comments/concepts). When a thread is created, the first comment is also created, and you can do this with [`liveblocks.createThread`](/docs/api-reference/liveblocks-node#post-rooms-roomId-threads) or the [Create Thread API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads). ```ts // The unique ID for the room const roomId = "my-room-id"; // Create a room const room = await liveblocks.createRoom(roomId, { defaultAccesses: [], }); //+++ // Create a thread const thread = await liveblocks.createThread({ roomId, data: { comment: { // ID of the user that created the thread userId: "florent@example.com", body: { version: 1, content: [ // The initial comment's body text goes here // { // type: "paragraph", // children: [{ text: "Hello " }, { text: "world", bold: true }], // }, ], }, }, }, }); // +++ ``` Read under [`liveblocks.createThread`](/docs/api-reference/liveblocks-node#post-rooms-roomId-threads) to learn how to create a Comment body, and check in [GitHub](https://github.com/liveblocks/liveblocks/blob/64a2f5707785b95b1f56d7ff3b53a234dfc9ccd7/packages/liveblocks-core/src/protocol/Comments.ts#L55) for information about each comment body element. ## Adding further comments To add more comments to the new thread, use [`liveblocks.createComment`](/docs/api-reference/liveblocks-node#post-rooms-roomId-threads-threadId-comments) or the [Create Comment API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads-threadId-comments). We’re just sharing a simple code snippet below, but of course, we recommend you use a loop. ```ts // The unique ID for the room const roomId = "my-room-id"; // Create a room const room = await liveblocks.createRoom(roomId, { defaultAccesses: [], }); // Create a thread const thread = await liveblocks.createThread({ /* ... */ }); // +++ // Adding a comment to the existing thread const comment = await liveblocks.createComment({ roomId, threadId: thread.id, data: { // ID of the user that created the comment userId: "pierre@example.com", // Optional, when the comment was created createdAt: new Date(), body: { version: 1, content: [ // The comment's body text goes here // { // type: "paragraph", // children: [{ text: "Hello " }, { text: "world", bold: true }], // }, ], }, }, }); // +++ ``` ## Adding reactions To add reactions to each comment, use [`liveblocks.addCommentReaction`](/docs/api-reference/liveblocks-node#post-rooms-roomId-threads-threadId-comments-commentId-add-reaction) or the [Add Comment Reaction API](/docs/api-reference/rest-api-endpoints#post-rooms-roomId-threads-threadId-comments-commentId-add-reaction). ```ts // The unique ID for the room const roomId = "my-room-id"; // Create a room const room = await liveblocks.createRoom(roomId, { defaultAccesses: [], }); // Create a thread const thread = await liveblocks.createThread({ /* ... */ }); // Adding a comment to the existing thread const comment = await liveblocks.createComment({ /* ... */ }); // +++ // Add a reaction to a comment const reaction = await liveblocks.addCommentReaction({ roomId, threadId: thread.id, commentId: comment.id, data: { // The reaction emoji emoji: "✅", // ID of the user that reacted userId: "guillaume@example.com", // Optional, the time the reaction was added createdAt: new Date(), }, }); // +++ ``` ## Migrating users There’s no need to migrate users to Comments, as the only user information Liveblocks stores [is each user’s ID](/docs/ready-made-features/comments/users-and-mentions). Other user info is retrieved in-app by Comments with [`resolveUserInfo`](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers). ## Putting it together To create a migration script, put everything together and loop through all the functions we’ve listed above. Below is an example of a migration script, though you’ll need to make changes based on the format of the comment system you’re migrating from. `oldDocumentId` and `oldDocumentThreads` represent your current data. ```tsx import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); async function migrateDocument({ oldDocumentId, oldDocumentThreads }) { console.log(`Migrating document: ${oldDocumentId}`); // The unique ID for the room const roomId = oldDocumentId; // Create a Liveblocks room const room = await liveblocks.createRoom(roomId, { // If you're using ID tokens, set your permissions/accesses, examples below defaultAccesses: [ // "room:write"` ], // groupsAccesses: { // my-group: ["room:write"] // } // usersAccesses: { // my-user: ["room:write"] // } }); // Loop through your existing threads for the current room for (const oldThread of oldDocumentThreads) { const [firstComment, ...otherComments] = oldThread.comments; // Create a Liveblocks thread const thread = await liveblocks.createThread({ roomId: room.id, data: { comment: { // ID of the user that created the thread userId: firstComment.userId, body: { version: 1, content: __convertCommentToLiveblocksFormat__(firstComment.body), }, }, }, }); // Loop through this existing thread's comments for (const oldComment of otherComments) { // Create a Liveblocks comment const comment = await liveblocks.createComment({ roomId: room.id, threadId: thread.id, data: { // ID of the user that created the comment userId: oldComment.userId, // Optional, when the comment was created createdAt: new Date(oldComment.timestamp), body: { version: 1, content: __convertCommentToLiveblocksFormat__(oldComment.body), }, }, }); // Loop through this existing comment's reactions for (const oldReaction of oldComment.reactions) { // Add a reaction to a Liveblocks comment await liveblocks.addCommentReaction({ roomId: room.id, threadId: thread.id, commentId: comment.id, data: { // The reaction emoji emoji: oldReaction.emoji, // ID of the user that reacted userId: oldReaction.userId, // Optional, the time the reaction was added createdAt: new Date(oldReaction.timestamp), }, }); } } } console.log(`Document migrated: ${oldDocumentId}`); } ``` --- meta: title: "How to modify Liveblocks Storage from the server" description: "Learn about the different methods you can use to modify Liveblocks Storage within Node.js" --- In realtime applications, Liveblocks Storage is generally modified from the browser with [`useMutation`](/docs/api-reference/liveblocks-react#useMutation) or through [conflict-free data methods](/docs/api-reference/liveblocks-client#Storage). However, sometimes it can be useful to modify your realtime storage from server-side Node.js too. ## What we’re building In this guide, we’ll be building a function that allows you to easily modify storage from the server. We’ll do this by running `@liveblocks/client` server-side using Node.js polyfills, and by signing in with a service account `userId`. When using this solution, the service account user will appear in presence, and you must account for this in your front end (e.g. filtering out the service account when displaying live avatars). We are investigating other solutions, and hope to provide a superior API in future. ```ts await modifyStorage("my-room-name", (root) => { root.get("list").push("item3"); }); ``` ## Set up Liveblocks server config This tutorials assumes you’ve already set up Liveblocks on the client-side, and you’ve created a `liveblocks.config.ts` file containing your types. The first thing we need to do is to install the required Node.js polyfills. ```bash npm i node-fetch ws ``` After this we can create a server config file, which we’ll name `liveblocks.server.config.ts`. In this file we’re implementing the following. Creating a node client with `new Liveblocks`. Creating a regular client to be used on the server, `serverClient`. Authenticating inside the regular client. Using the same `userId` for server changes, so that MAUs do not increase. Adding Node.js polyfills to the regular client. Creating a typed enter room function. Here’s the full file: ```ts file="liveblocks.server.config.ts" import { createClient } from "@liveblocks/client"; import { Liveblocks } from "@liveblocks/node"; import fetch from "node-fetch"; import WebSocket from "ws"; // 1. Creating a node client const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); // 2. Creating a regular client export const serverClient = createClient({ // 3. Authenticating inside the client authEndpoint: async (room) => { const session = liveblocks.prepareSession( // 4. Using a specific userId for all server changes "_SERVICE_ACCOUNT" ); session.allow(room, session.FULL_ACCESS); const { body } = await session.authorize(); return JSON.parse(body); }, // 5. Adding polyfills polyfills: { fetch: fetch as any, WebSocket, }, }); // 6. Creating a typed enter room function export const enterRoom = (roomId: string) => { return serverClient.enter(roomId, { // Match the options in your browser code }); }; ``` ## Create the modify storage function Using `serverClient` and `enterRoom` from the previous file, we can create a typed `modifyStorage` function that allows us to join a room, modify storage (batching all changes into one request), before leaving the room. ```ts import type { LiveObject } from "@liveblocks/client"; import { enterRoom } from "./liveblocks.server.config"; export async function modifyStorage( roomId: string, storageChanges: (root: LiveObject) => void ) { const roomContext = enterRoom(roomId); const { room } = roomContext; const { root } = await room.getStorage(); // Make storage adjustments in a batch, so they all happen at once room.batch(() => { storageChanges(root); }); // If storage changes are not synchronized, wait for them to finish if (room.getStorageStatus() !== "synchronized") { await room.events.storageStatus.waitUntil( (status) => status === "synchronized" ); } // Leave when storage has been synchronized roomContext.leave(); } ``` Note that the `Liveblocks["Storage"]` type originates from your [config file](/docs/api-reference/liveblocks-react#Typing-your-data) and needs no import. ## Start modifying storage We can now start modify storage from the server! Import `modifyStorage`, pass a room name, and use the callback to modify as you like. ```ts file="route.ts" highlight="6-8" import { modifyStorage } from "./modifyStorage"; export async function POST() { console.log("Updating storage"); await modifyStorage("my-liveblocks-room", (root) => { root.get("list").push("item3"); }); console.log("Storage update complete!"); } ``` ## Account for the service user in your app Remember to account for the service user appearing in your presence. In our `liveblocks.server.config.ts` we authenticated with `"_SERVICE_ACCOUNT"` as the `userId`, so we’ll filter it out when using others in our application. ```tsx import { shallow, useOthers } from "@liveblocks/react/suspense"; function LiveAvatars() { // Others, with the service account filtered out const others = useOthers( (others) => others.filter((other) => other.id !== "_SERVICE_ACCOUNT"), shallow ); // ... } ``` A `shallow` equality check is necessary here, because `filter` creates a new array every time. --- meta: title: "How to migrate your existing rooms IDs to use access token naming patterns" description: "Learn how to migrate your current rooms IDs to match access token naming patterns." --- When using [access token authentication](/docs/authentication/access-token) we recommend using a naming pattern for your room IDs. If you’ve already created rooms, it’s possible to rename them with `@liveblocks/node` or our REST API. ## Update your room IDs To rename a room ID, you can use the [liveblocks.updateRoomId](/docs/api-reference/liveblocks-node#post-rooms-update-roomId) endpoint to update a room ID. ```ts import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const room = await liveblocks.updateRoomId({ roomId: "my-room-id", newRoomId: "new-room-id", }); // { type: "room", id: "new-room-id", ... } console.log(room); ``` When you change a room ID, currently connected users will disconnect, but there is a way around this. ## Handle active users connected to a room To avoid users disconnecting after the change, you can listen for a room ID changed error, `4006`, with [`useErrorListener`](/docs/api-reference/liveblocks-react#useErrorListener) or [`room.subscribe("error")`](/docs/api-reference/liveblocks-client#Room.subscribe.error). This error is sent immediately after the ID is changed, and it returns the new room ID inside `error.message`. You can use this the new ID to redirect users to the new location of the room in your application. ```tsx import { useErrorListener } from "../liveblocks.config"; function App() { useErrorListener((error) => { if (error.code === 4006) { // Room ID has been changed, get the new ID and redirect const newRoomId = error.message; __redirect__(`https://example.com/document/${newRoomId}}`); } }); } ``` After implementing this, you can safely update the room ID, and users will be immediately redirected to the new location. --- meta: title: "How to send email notifications for unread text editor mentions" description: "Learn how to automatically send email notifications when a text mention or is created with Liveblocks Text Editor." --- Liveblocks allows you to embed a text editing experience with [Text Editor](/docs/ready-made-features/text-editor) using Tiptap or Lexical. Using our webhooks and REST API, it’s possible get unread text mentions and use them to email users when they’re mentioned in a document. Notifications can also be displayed in your app using [`useInboxNotifications`](/docs/api-reference/liveblocks-react#useInboxNotifications) and the [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) component.
An email showing a text mention in a text editor document
We have two examples containing ready-made email templates, built with React Email. These are great starting points for your Text Editor notification emails: [Tiptap emails example](/examples/collaborative-text-editor-emails/nextjs-tiptap-emails-resend), [Lexical emails example](/examples/collaborative-text-editor-emails/nextjs-lexical-emails-resend). ## What we’re building In this guide we’ll be learning how to send text mentions notifications, and more specifically, we’ll be looking at how to: - Trigger events based on unread comments using the [`NotificationEvent`](/docs/platform/webhooks#NotificationEvent) webhook event. - Fetch unread text mention and add styles to the surrounding text using the [`@liveblocks/emails`](/docs/api-reference/liveblocks-emails) package. - Send an email notification containing the mention and its surrounding text with [Resend](https://resend.com/). This guide assumes you already have a Liveblocks Text Editor project set up. If you don’t have one yet, you can get started with [Lexical](/docs/get-started/text-editor/lexical) or [Tiptap](/docs/get-started/text-editor/tiptap), and come back after you’re set up. You could also use our [ready-made email examples](/examples/collaborative-text-editor-emails) which have this set up already, along with some email templates. ## What are inbox notifications? Email notifications are built around the concept of inbox notifications, which are different from “normal” notifications in the sense that they can group multiple activities together and evolve over time. This makes more sense when sending email notifications because it helps to avoid flooding your users with too many emails. Learn more about Notifications for Lexical Text Editor in the [overview page](/docs/ready-made-features/text-editor/lexical#Notifications). ## Using webhooks Liveblocks provides a number of [webhooks](/docs/platform/webhooks) that can send requests to your API endpoint when certain events occurs. One webhook we provide is the [`NotificationEvent`](/docs/platform/webhooks#NotificationEvent) webhook, which is triggered for each mentioned users in a document, 30 minutes after activity has occurred, and this can be used to send emails to your users. The information it returns allows you to retrieve a text mention that have not yet been read by the user. Let’s take a look at how to set this up. ### Notification channels You can send notifications via different channels, such as email, Slack, Microsoft Teams, and Web Push. In our dashboard, you can enable notifications on certain channels, and in this case, we’ll be using the email channel. You must always enable the correct channel to ensure your [`NotificationEvent`](/docs/platform/webhooks#NotificationEvent) webhook events are triggered, and this guide will take you through setting it up. ## Create an endpoint in your project When a webhook event is triggered, it can send a POST request to the back end in your project. In this guide, we’ll be using a Next.js route handler (API endpoint) as an example, but other frameworks work similarly. In order to use webhooks, we’ll need to retrieve the `headers` and `body` from the request. Here’s the basic endpoint we’ll be starting from: ```ts export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Handle webhooks and notifications // ... return new Response(null, { status: 200 }); } ``` Create this endpoint in your project, and make it available on `localhost` at the following URL: ``` /api/liveblocks-notifications ``` Make a note of this endpoint URL, as you’ll be using it later. ### Testing webhooks locally Running webhooks locally can be difficult, but one way to do this is to use a tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or [`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily put your localhost server online. If your project is running on `localhost:3000`, you can run the following command to generate a temporary URL that’s available while your localhost server is running: ```bash npx localtunnel --port 3000 ``` `localtunnel` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-notifications # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-notifications ``` You now have a URL that can be used in the webhooks dashboard. ## Set up webhooks on the Liveblocks dashboard To use webhooks, you need to pass your endpoint URL to the webhooks dashboard inside your Liveblocks project, and tell the webhook to trigger when a comment has been created. Select your project From the [Liveblocks dashboard](/dashboard), navigate to the project you’d like to use with webhooks, or create a new project.
Create a Liveblocks project
Go to the notifications dashboard Click on the **“Notifications”** tab on the menu at the left.
Click notifications
Enable the textMention notification type Click on **“Edit”** at the top right, enable `textMention` notifications on the email channel, and publish your changes.
Enable textMention notifications
Go to the webhooks dashboard Click on the **“Webhooks”** tab on the menu at the left.
Click webhooks
Create an endpoint Click the **“Create endpoint…”** button on the webhooks dashboard to start setting up your webhook.
Click add endpoint
Add your endpoint URL Enter the URL of the endpoint. In a production app this will be the real endpoint, but for now enter your `localtunnel` URL from earlier.
Add endpoint URL
Get your webhook secret key Click **“Create endpoint”** at the bottom, then find your **“Webhook secret key”** on the next page, and copy it.
Copy your webhook secret key
Webhooks dashboard is set up! Note that you can filter specifically for `notification` events, but we’re ignoring this for now so we can test more easily. Let’s go back to the code.
## Verify the webhook request The [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides you with a function that verifies whether the current request is a real webhook request from Liveblocks. You can set this up by setting up a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest). Make sure to add your “Webhook secret key” from the Liveblocks dashboard—in a real project we’d recommend using an environment variable for this. ```ts highlight="1,3-5,11-21" import { WebhookHandler } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // Send notifications // ... return new Response(null, { status: 200 }); } ``` ## Check the event and notification permissions After verifying the request, we can then check we’re receiving the correct type of event on the correct channel. There are different `notification` events, and in this case we’d like to check for [text mention notification](/docs/platform/webhooks#TextMention-notification), as we’re specifically listening for new text mentions. We can do this using [`isTextMentionNotificationEvent`](/docs/api-reference/liveblocks-node#isTextMentionNotificationEvent), making sure to check for the `email` channel. ```ts import { WebhookHandler, isTextMentionNotificationEvent, } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // +++ // When an inbox notification has been created on the email channel if (isTextMentionNotificationEvent(event) && event.data.channel === "email") { // Check if user has access to room if (!__hasRoomAccess__(event.userId, event.roomId)) { return new Response(null, { status: 200 }); } // The user to send the email to const emailAddress = __getEmailAddressFromDB__(event.userId); // Send notifications // ... } // +++ return new Response(null, { status: 200 }); } ``` Note that we’re also checking if the user should receive a notification, and getting their email address—Liveblocks doesn’t have knowledge of your permissions system on the back end, so it’s your responsibility to check if this user should have access to the room. ## Fetching data for emails [`@liveblocks/emails`](/docs/api-reference/liveblocks-emails) provides functions for fetching unread text mentions and styling emails, returning them as either React components or an HTML string. In this guide we’ll use the React function, but the HTML function works almost identically, so you can still follow along if you’d prefer HTML emails. First set up your [`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js client and wrap [`prepareTextMentionNotificationEmailAsReact`](/docs/api-reference/liveblocks-emails#prepare-text-mention-notification-email-as-react) in `try/catch`, getting the data for the email. If you’d prefer your email content as an HTML string, use [`prepareTextMentionNotificationEmailAsHtml`](/docs/api-reference/liveblocks-emails#prepare-text-mention-notification-email-as-html). ```ts import { // +++ Liveblocks, // +++ WebhookHandler, isTextMentionNotificationEvent, } from "@liveblocks/node"; // +++ import { prepareTextMentionNotificationEmailAsReact } from "@liveblocks/emails"; // +++ // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // +++ // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); // +++ export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When an inbox notification has been created on the email channel if (isTextMentionNotificationEvent(event) && event.data.channel === "email") { // Check if user has access to room if (!__hasRoomAccess__(event.userId, event.roomId)) { return new Response(null, { status: 200 }); } // The user to send the email to const emailAddress = __getEmailAddressFromDB__(event.userId); // +++ let emailData; try { emailData = await prepareTextMentionNotificationEmailAsReact( liveblocks, event ); } catch (err) { console.log(err); return new Response("Could not fetch text mention notification data", { status: 500, }); } // The text mention has already been read if (!emailData) { return new Response(null, { status: 200 }); } // Create email // ... // +++ } return new Response(null, { status: 200 }); } ``` ## Create the emails Next, we need to create the emails with React, using `emailData` to build the content. ```tsx import { Liveblocks, WebhookHandler, isTextMentionNotificationEvent, } from "@liveblocks/node"; import { prepareTextMentionNotificationEmailAsReact } from "@liveblocks/emails"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When an inbox notification has been created on the email channel if (isTextMentionNotificationEvent(event) && event.data.channel === "email") { // Check if user has access to room if (!__hasRoomAccess__(event.userId, event.roomId)) { return new Response(null, { status: 200 }); } // The user to send the email to const emailAddress = __getEmailAddressFromDB__(event.userId); let emailData; try { emailData = await prepareTextMentionNotificationEmailAsReact( liveblocks, event ); } catch (err) { console.log(err); return new Response("Could not fetch text mention notification data", { status: 500, }); } // The text mention has already been read if (!emailData) { return new Response(null, { status: 200 }); } // +++ const email = (
@{emailData.mention.author.id} at {emailData.mention.createdAt}
{emailData.mention.reactContent}
); // +++ // Send emails // ... } return new Response(null, { status: 200 }); } ``` ## Resolving data We’ve now fully created a basic React email, and it’s ready to send. However, we’re displaying each user’s ID, and not their names. We can go back to [`prepareTextMentionNotificationEmailAsReact`](/docs/api-reference/liveblocks-emails#prepare-text-mention-notification-email-as-react) and use resolver functions to transform an ID into a name, for example `chris@example.com` -> `Chris`. These functions work similarly to [resolvers on the client](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers). ```tsx // ... emailData = await prepareTextMentionNotificationEmailAsReact( liveblocks, event, { // +++ resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, // "Chris" avatar: userData.avatar.src, // "https://example.com/chris.jpg" })); }, resolveRoomInfo: async ({ roomId }) => { const roomData = await __getRoomFromDB__(roomId); return { name: roomData.name, // "Untitled document" url: roomData.url, //`https://example.com/my-room-id` }; }, // +++ } ); // ... ``` ## Customizing text mention and surrounding text components We can also edit `prepareTextMentionNotificationEmailAsReact` to allow for [custom components](/docs/api-reference/liveblocks-emails#prepare-text-mention-notification-email-as-react-customizing-components) for the text mention and its surrounding text, for example we can customize the container, color mentions, and modify fonts. ```tsx // ... emailData = await prepareTextMentionNotificationEmailAsReact( liveblocks, event, { resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, // "Chris" avatar: userData.avatar.src, // "https://example.com/chris.jpg" })); }, resolveRoomInfo: async ({ roomId }) => { const roomData = await __getRoomFromDB__(roomId); return { name: roomData.name, // "Untitled document" url: roomData.url, //`https://example.com/my-room-id` }; }, // +++ components: { Container: ({ children }) => (
{children}
), // `user` is the optional data returned from `resolveUsers` Mention: ({ element, user }) => ( @{user?.name ?? element.id} ), Text: ({ children }) => (

{children}

), }, // +++ } ); // ... ``` Any component can be passed here, including those used in [`react-email`](https://react.email/), [learn more](/docs/api-reference/liveblocks-emails#prepare-text-mention-notification-email-as-react-customizing-components). If you’re using HTML instead of React, you can [apply custom CSS properties](/docs/api-reference/liveblocks-emails#prepare-text-mention-notification-email-as-html-styling-elements). ## Send notification emails Now that the React code has been generated, we can send the notification emails. [Resend](https://resend.com) is a great tool for easily sending emails, and in this code example, we’re using it to send notifications to each user. Make sure to add your API key from the [Resend dashboard](https://resend.com/overview) before running the code. ```tsx import { Liveblocks, WebhookHandler, isTextMentionNotificationEvent, } from "@liveblocks/node"; import { prepareTextMentionNotificationEmailAsReact } from "@liveblocks/emails"; // +++ import { Resend } from "resend"; // +++ // +++ // Create Resend client (add your API key) const resend = new Resend("re_123456789"); // +++ // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When an inbox notification has been created on the email channel if (isTextMentionNotificationEvent(event) && event.data.channel === "email") { // Check if user has access to room if (!__hasRoomAccess__(event.userId, event.roomId)) { return new Response(null, { status: 200 }); } // The user to send the email to const emailAddress = __getEmailAddressFromDB__(event.userId); let emailData; try { emailData = await prepareTextMentionNotificationEmailAsReact( liveblocks, event, { resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, // "Chris" avatar: userData.avatar.src, // "https://example.com/chris.jpg" })); }, resolveRoomInfo: async ({ roomId }) => { const roomData = await __getRoomFromDB__(roomId); return { name: roomData.name, // "Untitled document" url: roomData.url, //`https://example.com/my-room-id` }; }, components: { Container: ({ children }) => (
{children}
), // `user` is the optional data returned from `resolveUsers` Mention: ({ element, user }) => ( @{user?.name ?? element.id} ), }, } ); } catch (err) { console.log(err); return new Response("Could not fetch text mention notification data", { status: 500, }); } // The text mention has already been read if (!emailData) { return new Response(null, { status: 200 }); } const email = (
@{emailData.mention.author.id} at {emailData.mention.createdAt}
{emailData.mention.reactContent}
); // +++ // Send email to the user's email address try { const data = await resend.emails.send({ from: "My company ", to: emailAddress, subject: "New text mention", react: email, }); } catch (err) { console.error(err); } // +++ } return new Response(null, { status: 200 }); } ``` ## Allow users to toggle notifications Using Liveblocks hooks and methods, it’s possible to create a notifications settings interface, allowing end users to choose which notifications they’d like to receive, and on which channels, saving their preferences.
Notification settings
Learn more in our guide on [creating a notification settings panel](/docs/guides/how-to-create-a-notification-settings-panel). ## Recap Great, we’re successfully sending email notifications after new text mentions are created! In this guide we’ve learned: - How to use [webhooks](/docs/platform/webhooks) and the [`NotificationEvent`](/docs/platform/webhooks#NotificationEvent). - How to use the [`@liveblocks/emails`](/docs/api-reference/liveblocks-emails) package to fetch and render unread text mention data. - How to send email notifications with [Resend](https://resend.com). --- meta: title: "How to send email notifications of unread comments" description: "Learn how to automatically send email notifications of unread comments." --- Liveblocks [Comments](/docs/ready-made-features/comments) allows you to build a commenting experience. With our webhooks and REST API, it’s possible to aggregate a list of unread comments from the last 30 minutes into a single email, and send it to your users. Notifications can also be displayed in your app using [`useInboxNotifications`](/docs/api-reference/liveblocks-react#useInboxNotifications) and the [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) component.
An email showing 7 new comments, with comment bodies and links to each comment
We have an example containing ready-made email templates, built with React Email. This a great starting point for your Comments notification emails: [Comments emails example](/examples/comments-emails/nextjs-comments-emails-resend). We also have a second example that shows you how to [send simple HTML emails](/examples/comments-emails/nextjs-comments-emails-sendgrid). ## What we’re building In this guide we’ll be learning how to send emails notifying users about unread comments, and more specifically, we’ll be looking at how to: - Trigger events based on unread comments using the [`NotificationEvent`](/docs/platform/webhooks#NotificationEvent) webhook event. - Fetch unread comments and add styles to comment text using the [`@liveblocks/emails`](/docs/api-reference/liveblocks-emails) package. - Send an email notification containing a list of unread comments in thread format with [Resend](https://resend.com/). This guide assumes you already have a Liveblocks Comments project set up. If you don’t have one yet, you can [get started with Comments](/docs/get-started/comments), and come back after you’re set up. You could also use our [Comments emails example](/examples/comments-emails/nextjs-comments-emails-resend) which has this set up already, alongside ready-made email templates. ## What are inbox notifications? Email notifications are built around the concept of inbox notifications, which are different from “normal” notifications in the sense that they can group multiple activities together and evolve over time, which makes more sense when sending email notifications because it helps to avoid sending too many emails. In the case of Comments, inbox notifications are grouped per thread, which means that if there are 4 new comments in a thread you’re participating in, you will have a single inbox notification for it, instead of 4 “normal” notifications. Learn more about Notifications for Comments in the [overview page](/docs/ready-made-features/comments/email-notifications). ## Using webhooks Liveblocks provides a number of [webhooks](/docs/platform/webhooks) that can send requests to your API endpoint when certain events occurs. One webhook we provide is the [`NotificationEvent`](/docs/platform/webhooks#NotificationEvent) webhook, which is triggered for each participating user in a thread, 30 minutes after activity has occurred, and this can be used to send emails to your users. The information it returns allows you to retrieve comments that have not yet been read by the user, making it possible to aggregate multiple unread comments into a single notification email. Let’s take a look at how to set this up. ### Notification channels You can send notifications via different channels, such as email, Slack, Microsoft Teams, and Web Push. In our dashboard, you can enable notifications on certain channels, and in this case, we’ll be using the email channel. You must always enable the correct channel to ensure your [`NotificationEvent`](/docs/platform/webhooks#NotificationEvent) webhook events are triggered, and this guide will take you through setting it up. ## Create an endpoint in your project When a webhook event is triggered, it can send a POST request to the back end in your project. In this guide, we’ll be using a Next.js route handler (API endpoint) as an example, but other frameworks work similarly. In order to use webhooks, we’ll need to retrieve the `headers` and `body` from the request. Here’s the basic endpoint we’ll be starting from: ```ts export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Handle webhooks and notifications // ... return new Response(null, { status: 200 }); } ``` Create this endpoint in your project, and make it available on `localhost` at the following URL: ``` /api/liveblocks-notifications ``` Make a note of this endpoint URL, as you’ll be using it later. ### Testing webhooks locally Running webhooks locally can be difficult, but one way to do this is to use a tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or [`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily put your localhost server online. If your project is running on `localhost:3000`, you can run the following command to generate a temporary URL that’s available while your localhost server is running: ```bash npx localtunnel --port 3000 ``` `localtunnel` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-notifications # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-notifications ``` You now have a URL that can be used in the webhooks dashboard. ## Set up webhooks on the Liveblocks dashboard To use webhooks, you need to pass your endpoint URL to the webhooks dashboard inside your Liveblocks project, and tell the webhook to trigger when a comment has been created. Select your project From the [Liveblocks dashboard](/dashboard), navigate to the project you’d like to use with webhooks, or create a new project.
Create a Liveblocks project
Go to the notifications dashboard Click on the **“Notifications”** tab on the menu at the left.
Click notifications
Enable the thread notification type Click on **“Edit”** at the top right, enable `thread` notifications on the email channel, and publish your changes.
Enable thread notifications
Go to the webhooks dashboard Click on the **“Webhooks”** tab on the menu at the left.
Click webhooks
Create an endpoint Click the **“Create endpoint…”** button on the webhooks dashboard to start setting up your webhook.
Click add endpoint
Add your endpoint URL Enter the URL of the endpoint. In a production app this will be the real endpoint, but for now enter your `localtunnel` URL from earlier.
Add endpoint URL
Get your webhook secret key Click **“Create endpoint”** at the bottom, then find your **“Webhook secret key”** on the next page, and copy it.
Copy your webhook secret key
Webhooks dashboard is set up! Note that you can filter specifically for `notification` events, but we’re ignoring this for now so we can test more easily. Let’s go back to the code.
## Verify the webhook request The [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides you with a function that verifies whether the current request is a real webhook request from Liveblocks. You can set this up by setting up a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest). Make sure to add your “Webhook secret key” from the Liveblocks dashboard—in a real project we’d recommend using an environment variable for this. ```ts highlight="1,3-5,11-21" import { WebhookHandler } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // Send notifications // ... return new Response(null, { status: 200 }); } ``` ## Check the event and notification permissions After verifying the request, we can then check we’re receiving the correct type of event, on the correct channel. There are different `notification` events, and in this case we’d like to check for [thread notification](/docs/platform/webhooks#Thread-notification), as we’re specifically listening for new comments. We can do this using [`ThreadNotificationEvent`](/docs/api-reference/liveblocks-node#isThreadNotificationEvent), making sure to check for the `email` channel. ```ts import { WebhookHandler, isThreadNotificationEvent } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // +++ // When an inbox notification has been created on the email channel if (isThreadNotificationEvent(event) && event.data.channel === "email") { // Check if user has access to room if (!__hasRoomAccess__(event.userId, event.roomId)) { return new Response(null, { status: 200 }); } // The user to send the email to const emailAddress = __getEmailAddressFromDB__(event.userId); // Send notifications // ... } // +++ return new Response(null, { status: 200 }); } ``` Note that we’re also checking if the user should receive a notification, and getting their email address—Liveblocks doesn’t have knowledge of your permissions system on the back end, so it’s your responsibility to check if this user should have access to the room. ## Fetching data for emails [`@liveblocks/emails`](/docs/api-reference/liveblocks-emails) provides functions for fetching unread comments and styling emails, returning them as either React components or an HTML string. In this guide we’ll use the React function, but the HTML function works almost identically, so you can still follow along if you’d prefer HTML emails.
An email showing 7 new comments, with comment bodies and links to each comment
First set up your [`Liveblocks`](/docs/api-reference/liveblocks-node#Liveblocks-client) Node.js client and wrap [`prepareThreadNotificationEmailAsReact`](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-react) in `try/catch`, getting the data for the email. If you’d prefer your email content as an HTML string, use [`prepareThreadNotificationEmailAsHtml`](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-html). ```ts import { // +++ Liveblocks, // +++ WebhookHandler, isThreadNotificationEvent, } from "@liveblocks/node"; // +++ import { prepareThreadNotificationEmailAsReact } from "@liveblocks/emails"; // +++ // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // +++ // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); // +++ export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When an inbox notification has been created on the email channel if (isThreadNotificationEvent(event) && event.data.channel === "email") { // Check if user has access to room if (!__hasRoomAccess__(event.userId, event.roomId)) { return new Response(null, { status: 200 }); } // The user to send the email to const emailAddress = __getEmailAddressFromDB__(event.userId); // +++ let emailData; try { emailData = await prepareThreadNotificationEmailAsReact( liveblocks, event ); } catch (err) { console.log(err); return new Response("Could not fetch thread notification data", { status: 500, }); } // All comments have already been read if (!emailData) { return new Response(null, { status: 200 }); } // Create emails // ... // +++ } return new Response(null, { status: 200 }); } ``` ## Create the emails Next, we need to create the emails with React. [`prepareThreadNotificationEmailAsReact`](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-react) helps you identify two different thread notification types, _unread replies_ in a thread, or an _unread mention_ in a comment. We can choose to create different emails for these cases. ```tsx import { Liveblocks, WebhookHandler, isThreadNotificationEvent, } from "@liveblocks/node"; import { prepareThreadNotificationEmailAsReact } from "@liveblocks/emails"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When an inbox notification has been created on the email channel if (isThreadNotificationEvent(event) && event.data.channel === "email") { // Check if user has access to room if (!__hasRoomAccess__(event.userId, event.roomId)) { return new Response(null, { status: 200 }); } // The user to send the email to const emailAddress = __getEmailAddressFromDB__(event.userId); let emailData; try { emailData = await prepareThreadNotificationEmailAsReact( liveblocks, event ); } catch (err) { console.log(err); return new Response("Could not fetch thread notification data", { status: 500, }); } // All comments have already been read if (!emailData) { return new Response(null, { status: 200 }); } // +++ let email; switch (emailData.type) { case "unreadMention": { email = (
@{emailData.comment.author.id} at {emailData.comment.createdAt}
{emailData.comment.reactBody}
); break; } case "unreadReplies": { email = (
{emailData.comments.map((comment) => (
@{comment.author.id} at {comment.createdAt}
{comment.reactBody}
))}
); break; } } // +++ // Send emails // ... } return new Response(null, { status: 200 }); } ``` ## Resolving data We’ve now fully created a basic React email, and it’s ready to send. However, we’re displaying each user’s ID, and not their names. We can go back to [`prepareThreadNotificationEmailAsReact`](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-react) and use resolver functions to transform an ID into a name, for example `steven@example.com` -> `Steven`. These functions work similarly to [resolvers on the client](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers). ```tsx // ... emailData = await prepareThreadNotificationEmailAsReact(liveblocks, event, { // +++ resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, // "Steven" avatar: userData.avatar.src, // "https://example.com/steven.jpg" })); }, resolveRoomInfo: async ({ roomId }) => { const roomData = await __getRoomFromDB__(roomId); return { name: roomData.name, // "Untitled document" url: roomData.url, //`https://example.com/my-room-id` }; }, // +++ }); // ... ``` ## Customizing comment components We can also edit `prepareThreadNotificationEmailAsReact` to allow for [custom components in comment bodies](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-react-customizing-components), for example we can add margin around a paragraph, color mentions, and underline links. ```tsx // ... emailData = await prepareThreadNotificationEmailAsReact(liveblocks, event, { resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, // "Steven" avatar: userData.avatar.src, // "https://example.com/steven.jpg" })); }, resolveRoomInfo: async ({ roomId }) => { const roomData = await __getRoomFromDB__(roomId); return { name: roomData.name, // "Untitled document" url: roomData.url, //`https://example.com/my-room-id` }; }, // +++ components: { Paragraph: ({ children }) =>

{children}

, // `user` is the optional data returned from `resolveUsers` Mention: ({ element, user }) => ( @{user?.name ?? element.id} ), // If the link is rich-text render it, otherwise use the URL Link: ({ element, href }) => ( {element?.text ?? href} ), }, // +++ }); // ... ``` Any component can be passed here, including those used in [`react-email`](https://react.email/), [learn more](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-react-customizing-components). If you’re using HTML instead of React, you can [apply custom CSS properties](/docs/api-reference/liveblocks-emails#prepare-thread-notification-email-as-html-styling-elements). ## Send notification emails Now that the React code has been generated, we can send the notification emails. [Resend](https://resend.com) is a great tool for easily sending emails, and in this code example, we’re using it to send notifications to each user. Make sure to add your API key from the [Resend dashboard](https://resend.com/overview) before running the code. ```tsx import { Liveblocks, WebhookHandler, isThreadNotificationEvent, } from "@liveblocks/node"; import { prepareThreadNotificationEmailAsReact } from "@liveblocks/emails"; // +++ import { Resend } from "resend"; // +++ // +++ // Create Resend client (add your API key) const resend = new Resend("re_123456789"); // +++ // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When an inbox notification has been created on the email channel if (isThreadNotificationEvent(event) && event.data.channel === "email") { // Check if user has access to room if (!__hasRoomAccess__(event.userId, event.roomId)) { return new Response(null, { status: 200 }); } // The user to send the email to const emailAddress = __getEmailAddressFromDB__(event.userId); let emailData; try { emailData = await prepareThreadNotificationEmailAsReact( liveblocks, event, { resolveUsers: async ({ userIds }) => { const usersData = await __getUsersFromDB__(userIds); return usersData.map((userData) => ({ name: userData.name, // "Steven" avatar: userData.avatar.src, // "https://example.com/steven.jpg" })); }, resolveRoomInfo: async ({ roomId }) => { const roomData = await __getRoomFromDB__(roomId); return { name: roomData.name, // "Untitled document" url: roomData.url, //`https://example.com/my-room-id` }; }, components: { Paragraph: ({ children }) => (

{children}

), // `user` is the optional data returned from `resolveUsers` Mention: ({ element, user }) => ( @{user?.name ?? element.id} ), // If the link is rich-text render it, otherwise use the URL Link: ({ element, href }) => ( {element?.text ?? href} ), }, } ); } catch (err) { console.log(err); return new Response("Could not fetch thread notification data", { status: 500, }); } // All comments have already been read if (!emailData) { return new Response(null, { status: 200 }); } let email; switch (emailData.type) { case "unreadMention": { email = (
@{emailData.comment.author.id} at {emailData.comment.createdAt}
{emailData.comment.reactBody}
); break; } case "unreadReplies": { email = (
{emailData.comments.map((comment) => (
@{comment.author.id} at {comment.createdAt}
{comment.reactBody}
))}
); break; } } // +++ // Send email to the user's email address try { const data = await resend.emails.send({ from: "My company ", to: emailAddress, subject: "New comment", react: email, }); } catch (err) { console.error(err); } // +++ } return new Response(null, { status: 200 }); } ``` ## Allow users to toggle notifications Using Liveblocks hooks and methods, it’s possible to create a notifications settings interface, allowing end users to choose which notifications they’d like to receive, and on which channels, saving their preferences.
Notification settings
Learn more in our guide on [creating a notification settings panel](/docs/guides/how-to-create-a-notification-settings-panel). ## Recap Great, we’re successfully sending email notifications after new comments are created! In this guide we’ve learned: - How to use [webhooks](/docs/platform/webhooks) and the [`NotificationEvent`](/docs/platform/webhooks#NotificationEvent). - How to use the [`@liveblocks/emails`](/docs/api-reference/liveblocks-emails) package to fetch and render unread thread data. - How to send email notifications with [Resend](https://resend.com). --- meta: title: "How to send email notifications when comments are created" description: "Learn how to automatically send email notifications when a comment or thread is created with Liveblocks Comments." --- Liveblocks allows you to build a commenting experience with [Comments](/docs/ready-made-features/comments). Using our webhooks and REST API, it’s possible to send email notifications to users when they’re mentioned in comments. This guide is about sending an email immediately after every comment is posted. If you’d prefer aggregate multiple notifications into one email, and only notify users about unread comments, you should read this guide on [how to send email notifications of unread comments](/docs/guides/how-to-send-email-notifications-of-unread-comments). ## What we’re building In this guide we’ll be learning how to send comments notifications, and more specifically, we’ll be looking at how to: - Trigger events when comments are created using the [CommentCreated](/docs/platform/webhooks#CommentCreatedEvent) webhook event. - Fetch a comment’s data using the [@liveblocks/node](/docs/api-reference/liveblocks-node) package. - Create notifications containing the comment’s [formatted text](/docs/guides/how-to-send-email-notifications-when-comments-are-created#Formatting-a-comment's-body). - Send an email notification with [Resend](https://resend.com/). This guide assumes you already have a Liveblocks Comments project set up. If you haven’t already got one, you can [get started with Comments](/docs/get-started/comments), and come back after you’re set up. You could also use our [basic Comments example](/examples/comments/nextjs-comments). ## Create an endpoint in your project When a webhook event is triggered, it can send a POST request to the back end in your project, and from within there we can send the email. In this guide, we’ll be using a Next.js route handler (API endpoint) as an example, but other frameworks work similarly. In order to use webhooks, we’ll need to retrieve the `headers` and `body` from the request. Here’s the basic endpoint we’ll be starting from: ```ts export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Handle webhooks and notifications // ... return new Response(null, { status: 200 }); } ``` Create this endpoint in your project, and make it available on `localhost`, for example at the following URL: ``` /api/liveblocks-notifications ``` Make a note of this endpoint URL, as you’ll be using it later. ### Testing webhooks locally Running webhooks locally can be difficult, but one way to do this is to use a tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or [`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily put your localhost server online. If your project is running on `localhost:3000`, you can run the following command to generate a temporary URL that’s available while your localhost server is running: ```bash npx localtunnel --port 3000 ``` `localtunnel` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-notifications # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-notifications ``` You now have a URL that can be used in the webhooks dashboard. ## Set up webhooks on the Liveblocks dashboard To use webhooks, you need to pass your endpoint URL to the webhooks dashboard inside your Liveblocks project, and tell the webhook to trigger when a comment has been created. Select your project From the [Liveblocks dashboard](/dashboard), navigate to the project you’d like to use with webhooks, or create a new project.
Create a Liveblocks project
Go to the webhooks dashboard Click on the **“Webhooks”** tab on the menu at the left.
Click webhooks
Create an endpoint Click the **“Create endpoint…”** button on the webhooks dashboard to start setting up your webhook.
Click add endpoint
Add your endpoint URL Enter the URL of the endpoint. In a production app this will be the real endpoint, but for now enter your `localtunnel` URL from earlier.
Add endpoint URL
Get your webhook secret key Click **“Create endpoint”** at the bottom, then find your **“Webhook secret key”** on the next page, and copy it.
Copy your webhook secret key
Webhooks dashboard is set up! Note that you can filter specifically for `commentCreated` events, but we’re ignoring this for now so we can test more easily. Let’s go back to the code.
## Verify the webhook request The [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides you with a function that verifies whether the current request is a real webhook request from Liveblocks. You can set this up by setting up a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest). Make sure to add your “Webhook secret key” from the Liveblocks dashboard—in a real project we’d recommend using an environment variable for this. ```ts highlight="1,3-5,11-21" import { WebhookHandler } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // Send notifications // ... return new Response(null, { status: 200 }); } ``` We can then check we’re receiving the correct type of event, get the data from the webhook, and handle sending the notification inside there. ```ts highlight="23-29" import { WebhookHandler } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When a comment has been created if (event.type === "commentCreated") { const { roomId, threadId, commentId } = event.data; // Send notifications // ... } return new Response(null, { status: 200 }); } ``` We now have the `roomId`, `threadId`, and `commentId` of the created comment, along with some [other information](/docs/platform/webhooks#CommentCreatedEvent). ## Get comment and thread data Note that a thread is different to a comment—a thread is “top-level”, and each thread contains a list of comments. When you create a thread with [`useCreateThread`](/docs/api-reference/liveblocks-react#useCreateThread), you also create the first comment in the thread. The next step is to use the [Liveblocks client](/docs/api-reference/liveblocks-node#Liveblocks-client) from `@liveblocks/node` to retrieve the entire comment’s data, along with the thread participants. In Liveblocks Comments, a participant refers to a user that has commented or been mentioned in a thread—we’ll be sending a notification to each of these users. To do this we’ll need to add our project’s secret key to the Liveblocks client, before awaiting the following functions: [`getComment`](/docs/api-reference/liveblocks-node#get-comment) and [`getThreadParticipants`](/docs/api-reference/liveblocks-node#get-thread-participants). ```ts highlight="1,7-9,31-43" import { Liveblocks, WebhookHandler } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When a comment has been created if (event.type === "commentCreated") { const { roomId, threadId, commentId } = event.data; try { // Get comment data and participants const [comment, { participantIds }] = await Promise.all([ liveblocks.getComment({ roomId, threadId, commentId }), liveblocks.getThreadParticipants({ roomId, threadId }), ]); // Send notifications // ... } catch (err) { console.log(err); return new Response("Could not fetch comment data", { status: 500 }); } } return new Response(null, { status: 200 }); } ``` ## Formatting a comment’s body Now that we have the comment data and a list of participants, we have one more step before sending the notifications—formatting the comment’s text, found inside `comment.body`. Let’s take a look at how it works for an example comment, using [`stringifyCommentBody`](/docs/api-reference/liveblocks-node#stringify-comment-body) to transform `comment.body`.
Comment with example body: 'Thank you so much @Emil Joyce!', with 'so much' in bold
```ts import { stringifyCommentBody } from "@liveblocks/node"; // Format comment text into a string const stringComment = await stringifyCommentBody(comment.body); // "Thank you so much emil.joyce@example.com!" console.log(stringComment); ``` As you can see on line 6, we’re converting the body into a plain string, which means we lose the formatting. We’re also seeing the user’s ID, instead of the name—this is because we need to provide the user’s information, as the comment only stores the user’s ID. By providing two options, we can transform the comment into HTML, keeping the formatting, and add the user information. ```ts import { stringifyCommentBody } from "@liveblocks/node"; // Format comment text into an HTML string const htmlComment = await stringifyCommentBody(comment.body, { // Transform into HTML format: "html", // Provider user information async resolveUsers({ userIds }) { // ["emil.joyce@example.com", ...] console.log(userIds); // Return each user's name [{ name: "Emil Joyce" } /*, ... */]; }, }); // "

Thank you so much @Emil Joyce!

" console.log(stringComment); ``` On line 18, you can now see that we’re creating an HTML string, and using the mentioned user’s name. Note that you can also easily transform your comment into Markdown, or a completely custom format, learn more under [`stringifyCommentBody`](/docs/api-reference/liveblocks-node#stringify-comment-body). Let’s use this HTML formatting function in our endpoint, getting user information from your database. ```ts highlight="1,38-50" import { Liveblocks, WebhookHandler, stringifyCommentBody } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When a comment has been created if (event.type === "commentCreated") { const { roomId, threadId, commentId } = event.data; try { // Get comment data and participants const [comment, { participantIds }] = await Promise.all([ liveblocks.getComment({ roomId, threadId, commentId }), liveblocks.getThreadParticipants({ roomId, threadId }), ]); // HTML comment body const htmlComment = await stringifyCommentBody(comment.body, { format: "html", async resolveUsers({ userIds }) { // Get the correct users from your database const users = await __getUsers__(userIds); return users.map((user) => ({ name: user.name, }); }, }); // Send notifications // ... } catch (err) { console.log(err); return new Response("Could not fetch comment data", { status: 500 }); } } return new Response(null, { status: 200 }); } ``` ## Send notifications Now that the comment’s body is in our preferred format, we can send the notifications. Earlier we retrieved `participants`, a list of `userIds` that have been mentioned in the thread. You most likely have user information in your database, which you can retrieve from these `userIds`. These are the same `userIds` that are passed to [`resolveUsers`](/docs/api-reference/liveblocks-client#resolveUsers) in your `liveblocks.config.ts` file. After getting each user’s email, simply loop through and send the formatted comment. ```ts highlight="52-64" import { Liveblocks, WebhookHandler, stringifyCommentBody } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When a comment has been created if (event.type === "commentCreated") { const { roomId, threadId, commentId } = event.data; try { // Get comment data and participants const [comment, { participantIds }] = await Promise.all([ liveblocks.getComment({ roomId, threadId, commentId }), liveblocks.getThreadParticipants({ roomId, threadId }), ]); // HTML comment body const htmlComment = await stringifyCommentBody(comment.body, { format: "html", async resolveUsers({ userIds }) { // Get the correct users from your database const users = await __getUsers__(userIds); return users.map((user) => ({ name: user.name, }); }, }); // Get participating users from your database const users = await __getUsers__(participantIds); // Send notifications for (const user of users) { // Send email to the user's email address // send({ // from: "hello@my-company.com", // to: user.email, // title: "New comment", // html: htmlComment // }); } } catch (err) { console.log(err); return new Response("Could not fetch comment data", { status: 500 }); } } return new Response(null, { status: 200 }); } ``` ## Sending emails with Resend [Resend](https://resend.com) is a great tool for easily sending emails, and in this code example, we’re using it to send the notifications to each user. Make sure to add your API key from the [Resend dashboard](https://resend.com/overview) before running the code. ```ts highlight="2,4-5,59-69" file="route.ts" import { Liveblocks, WebhookHandler, stringifyCommentBody } from "@liveblocks/node"; import { Resend } from "resend"; // Create Resend client (add your API key) const resend = new Resend("re_123456789"); // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When a comment has been created if (event.type === "commentCreated") { const { roomId, threadId, commentId } = event.data; try { // Get comment data and participants const [comment, { participantIds }] = await Promise.all([ liveblocks.getComment({ roomId, threadId, commentId }), liveblocks.getThreadParticipants({ roomId, threadId }), ]); // HTML comment body const htmlComment = stringifyCommentBody(comment.body, { format: "html", async resolveUsers({ userIds }) { // Get the correct users from your database const users = await __getUsers__(userIds); return users.map((user) => ({ name: user.name, }); }, }); // Get participating users from your database const users = await __getUsers__(participantIds); // Send email to the users' email addresses try { const data = await resend.emails.send({ from: "My company ", to: [users.map((user) => user.email)], subject: "New comment", html: htmlComment, }); } catch (err) { console.error(err); } } catch (err) { console.log(err); return new Response("Could not fetch comment data", { status: 500 }); } } return new Response(null, { status: 200 }); } ``` ## Recap Great, we’re successfully sending email notifications after new comments are created! In this guide we’ve learned: - How to use [webhooks](/docs/platform/webhooks) and the [`CommentCreatedEvent`](/docs/platform/webhooks#CommentCreatedEvent). - How to use the `@liveblocks/node` package to get [comment data](/docs/api-reference/liveblocks-node#get-comment) and [thread participants](/docs/api-reference/liveblocks-node#get-thread-participants). - How to shape a comment’s body into HTML with [`stringifyCommentBody`](/docs/api-reference/liveblocks-node#stringify-comment-body). - How to send email notifications with [Resend](https://resend.com). --- meta: title: "How to synchronize your Liveblocks Storage document data to a PlanetScale MySQL database" description: "Learn how to automatically update your PlanetScale MySQL database with changes from your Storage-based application." --- Liveblocks allows you to build collaborative applications with [Storage](/storage), a persisted conflict-free data store. Using our webhooks and REST API, you can then retrieve the Storage document data as it changes, and store it in your database. ## What we’re building In this guide we’ll be linking a Storage application up to a [PlanetScale](https://planetscale.com/) MySQL database so that Storage document data is automatically synchronized. This is enabled through the following: - [StorageUpdated](/docs/platform/webhooks#StorageUpdatedEvent) webhook event - [Get Storage Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-storage) REST API This guide assumes you already have a Liveblocks Storage project set up. If you haven’t already got one, you can select _Custom_ on our [getting started page](https://liveblocks.io/docs/get-started/custom), choose a framework, and come back after you’re set up. You could also use an example such as our [collaborative spreadsheet)(/examples/collaborative-spreadsheet-advanced/nextjs-spreadsheet-advanced). ### This specific webhook is throttled Note that the [StorageUpdated](/docs/platform/webhooks#StorageUpdatedEvent) webhook event is throttled at a rate of _once every 5 seconds_. This is because Storage can update up to _60 times per second_, and it would be impractical to run the webhook this frequently. ## Create an endpoint in your project When a webhook event is triggered, it can send a POST request to the back end in your project, and from within there we can update the database. In this guide, we’ll be using a Next.js route handler (API endpoint) as an example, but other frameworks work similarly. In order to use webhooks, we’ll need to retrieve the `headers` and `body` from the request. Here’s the basic endpoint we’ll be starting from: ```ts export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Handle webhooks and database // ... return new Response(null, { status: 200 }); } ``` Create this endpoint in your project, and make it available on `localhost` at the following URL: ``` /api/liveblocks-database-sync ``` ### Testing webhooks locally Running webhooks locally can be difficult, but one way to do this is to use a tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or [`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily put your localhost server online. If your project is running on `localhost:3000`, you can run the following command to generate a temporary URL that’s available while your localhost server is running: ```bash npx localtunnel --port 3000 ``` `localtunnel` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-database-sync # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-database-sync ``` You now have a URL that can be used in the webhooks dashboard. ## Set up webhooks on the Liveblocks dashboard To use webhooks, you need to pass your endpoint URL to the webhooks dashboard inside your Liveblocks project, and tell the webhook to trigger when Storage document data has changed. Select your project From the [Liveblocks dashboard](/dashboard), navigate to the project you’d like to use with webhooks, or create a new project.
Create a Liveblocks project
Go to the webhooks dashboard Click on the **“Webhooks”** tab on the menu at the left.
Click webhooks
Create an endpoint Click the **“Create endpoint…”** button on the webhooks dashboard to start setting up your webhook.
Click add endpoint
Add your endpoint URL Enter the URL of the endpoint. In a production app this will be the real endpoint, but for now enter your `localtunnel` URL from earlier.
Add endpoint URL
Get your secret key Click **“Create endpoint”** at the bottom, then find your **“Webhook secret key”** on the next page, and copy it.
Copy your webhook secret key
Webhooks dashboard is set up! Note that you can filter specifically for `storageUpdated` events, but we’re ignoring this for now so we can test more easily. Let’s go back to the code.
## Verify the webhook request The [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides you with a function that verifies whether the current request is a real webhook request from Liveblocks. You can set this up by setting up a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest). Make sure to add your “Signing Secret” from the Liveblocks dashboard—in a real project we’d recommend using an environment variable for this. ```ts highlight="1,3-5,11-21" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // Update database // ... return new Response(null, { status: 200 }); } ``` We can then check we’re receiving the correct type of event, get the updated `roomId`, and handle updating the database inside there. ```ts highlight="23-29" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Storage document data has been updated if (event.type === "storageUpdated") { const { roomId } = event.data; // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Get the current room’s Storage document data Before updating our database, we need to get the current room’s data. We can do this through the [Get Storage Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-storage) REST API. You use the REST API, you need to add your secret key from your project page. ```ts highlight="7-8,30-43" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Storage document data has been updated if (event.type === "storageUpdated") { const { roomId } = event.data; // Get Storage data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/storage`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Storage document data as a string const storageData = await response.text(); // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Create a PlanetScale MySQL database We’re ready to set up our PlanetScale database! We’ll be creating a simple `documents` table that contains the following fields: | Field | Description | Type | Key | | ------------- | ---------------------------------- | -------------- | --------- | | `roomId` | The `roomId`. | `VARCHAR(255)` | `PRIMARY` | | `storageData` | The stringified JSON Storage data. | `TEXT` | | Create a database Navigate to PlanetScale’s [new database page](https://app.planetscale.com/new), give your database a name, and click “Create” at the bottom.
Create a PlanetScale database
Create a new branch After the database has been created, click “New branch” at the top right to create a branch—this works like Git.
Create a new branch
Set up your schema Click “Console” at the top, select your branch, and enter the following to set up your table. ```sql CREATE TABLE documents ( roomId VARCHAR(255) PRIMARY KEY, storageData TEXT NOT NULL ); ```
Create your table
Click “Connect” After the database has been created, click “Connect” at the top right to create new credentials.
Click connect
Create credentials Give your new credentials a name, then click “Create Password”.
Create a PlanetScale password
Add the credentials to your project Select “Node.js” in the dropdown and copy the database URL (or add it as an environment variable).
Get the database URL
Database ready! Let’s take a look at the code.
## Add the Storage data to your database And finally, we can add the Storage JSON data to our database! First, we need to install `mysql2`: ```bash npm i mysql2 ``` Then implement the following to synchronize your data to PlanetScale: ```ts highlight="2,11-12,49-67" import { WebhookHandler } from "@liveblocks/node"; import mysql from "mysql2"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; // Your PlanetScale database URL const DATABASE_URL = "YOUR_DATABASE_URL"; export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Storage document data has been updated if (event.type === "storageUpdated") { const { roomId } = event.data; // Get Storage data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/storage`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Storage document data as a string const storageData = await response.text(); // Update database const connection = await mysql.createConnection(DATABASE_URL); const sql = ` INSERT INTO documents (roomId, storageData) VALUES (?, ?) ON DUPLICATE KEY UPDATE storageData = VALUES(storageData); `; try { await connection.query(sql, [roomId, storageData]); } catch (err) { return new Response("Problem inserting data into database", { status: 500, }); } await connection.end(); } return new Response(null, { status: 200 }); } ``` ## Check if it worked To check if your database synchronization is working, you can replay a `storageUpdated` event from the Liveblocks dashboard.
Create your table schema
Then go back to the Console page on PlanetScale and enter the following to see all entries: ```sql SELECT * FROM documents; ``` You should now see your Storage document—we’ve successfully set up data synchronization! When a user edits Storage data in your app, this function will be called, and your database will be updated. You can rely on this to stay up to date, within the 5 second throttle limit. ## Learn more You can learn more about Liveblocks webhooks in our full [webhooks guide](/docs/platform/webhooks). We also have an API reference for the [Get Storage Document API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-storage). --- meta: title: "How to synchronize your Liveblocks Storage document data to a Supabase Postgres database" description: "Learn how to automatically update your Supabase Postgres database with changes from your Storage-based application." --- Liveblocks allows you to build collaborative applications with [Storage](/storage), a persisted conflict-free data store. Using our webhooks and REST API, you can then retrieve the Storage document data as it changes, and store it in your database. ## What we’re building In this guide we’ll be linking a Storage application up to a [Supabase Postgres](https://supabase.com/database) database so that Storage document data is automatically synchronized. This is enabled through the following: - [StorageUpdated](/docs/platform/webhooks#StorageUpdatedEvent) webhook event - [Get Storage Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-storage) REST API This guide assumes you already have a Liveblocks Storage project set up. If you haven’t already got one, you can select _Custom_ on our [getting started page](https://liveblocks.io/docs/get-started/custom), choose a framework, and come back after you’re set up. You could also use an example such as our [collaborative spreadsheet)(/examples/collaborative-spreadsheet-advanced/nextjs-spreadsheet-advanced). ### This specific webhook is throttled Note that the [StorageUpdated](/docs/platform/webhooks#StorageUpdatedEvent) webhook event is throttled at a rate of _once every 5 seconds_. This is because Storage can update up to _60 times per second_, and it would be impractical to run the webhook this frequently. ## Create an endpoint in your project When a webhook event is triggered, it can send a POST request to the back end in your project, and from within there we can update the database. In this guide, we’ll be using a Next.js route handler (API endpoint) as an example, but other frameworks work similarly. In order to use webhooks, we’ll need to retrieve the `headers` and `body` from the request. Here’s the basic endpoint we’ll be starting from: ```ts export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Handle webhooks and database // ... return new Response(null, { status: 200 }); } ``` Create this endpoint in your project, and make it available on `localhost` at the following URL: ``` /api/liveblocks-database-sync ``` ### Testing webhooks locally Running webhooks locally can be difficult, but one way to do this is to use a tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or [`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily put your localhost server online. If your project is running on `localhost:3000`, you can run the following command to generate a temporary URL that’s available while your localhost server is running: ```bash npx localtunnel --port 3000 ``` `localtunnel` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-database-sync # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-database-sync ``` You now have a URL that can be used in the webhooks dashboard. ## Set up webhooks on the Liveblocks dashboard To use webhooks, you need to pass your endpoint URL to the webhooks dashboard inside your Liveblocks project, and tell the webhook to trigger when Storage document data has changed. Select your project From the [Liveblocks dashboard](/dashboard), navigate to the project you’d like to use with webhooks, or create a new project.
Create a Liveblocks project
Go to the webhooks dashboard Click on the **“Webhooks”** tab on the menu at the left.
Click webhooks
Create an endpoint Click the **“Create endpoint…”** button on the webhooks dashboard to start setting up your webhook.
Click add endpoint
Add your endpoint URL Enter the URL of the endpoint. In a production app this will be the real endpoint, but for now enter your `localtunnel` URL from earlier.
Add endpoint URL
Get your secret key Click **“Create endpoint”** at the bottom, then find your **“Webhook secret key”** on the next page, and copy it.
Copy your webhook secret key
Webhooks dashboard is set up! Note that you can filter specifically for `storageUpdated` events, but we’re ignoring this for now so we can test more easily. Let’s go back to the code.
## Verify the webhook request The [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides you with a function that verifies whether the current request is a real webhook request from Liveblocks. You can set this up by setting up a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest). Make sure to add your “Signing Secret” from the Liveblocks dashboard—in a real project we’d recommend using an environment variable for this. ```ts highlight="1,3-5,11-21" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // Update database // ... return new Response(null, { status: 200 }); } ``` We can then check we’re receiving the correct type of event, get the updated `roomId`, and handle updating the database inside there. ```ts highlight="23-29" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Storage document data has been updated if (event.type === "storageUpdated") { const { roomId } = event.data; // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Get the current room’s Storage document data Before updating our database, we need to get the current room’s data. We can do this through the [Get Storage Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-storage) REST API. You use the REST API, you need to add your secret key from your project page. ```ts highlight="7-8,30-43" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Storage document data has been updated if (event.type === "storageUpdated") { const { roomId } = event.data; // Get Storage data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/storage`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Storage document data as a string const storageData = await response.text(); // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Create a Supabase Postgres database We’re ready to set up our Supabase database! We’ll be creating a simple `documents` table that contains the following fields: | Field | Description | Type | Key | | ------------- | ---------------------------------- | -------------- | --------- | | `roomId` | The `roomId`. | `VARCHAR(255)` | `PRIMARY` | | `storageData` | The stringified JSON Storage data. | `TEXT` | | New project Navigate to Supabase’s [dashboard page](https://supabase.com/dashboard/projects), and click “New project” at the top.
Click new project
Create a database Give your database a name and password, then click “Create new project” at the bottom. Make sure to save your password, because you won’t be able to view it again.
Create a new project
Create your table Click the SQL Editor icon in the left bar, enter the following code into the console, and click “Run” at the bottom right to create the table. ```sql CREATE TABLE documents ( roomId VARCHAR(255) PRIMARY KEY, storageData TEXT NOT NULL ); ```
Create your table schema
Get your credentials Click the Settings icon in the left bar, click “API” at the left and find two items: 1. Your “URL”, under “Project URL”. 2. Your `anon``public` key, under “Project API key”. Make a note of these, or add them as environment variables in your project.
Create your table schema
Database ready! Let’s take a look at the code.
## Add the Storage data to your database And finally, we can add the Storage JSON data to our database! First, we need to install the Supabase library: ```bash npm i @supabase/supabase-js ``` Then implement the following to synchronize your data, making sure to add your Project URL: ```ts highlight="2,11-15,52-62" import { WebhookHandler } from "@liveblocks/node"; import { createClient } from "@supabase/supabase-js"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; // Create a Supabase client const supabase = createClient( "YOUR_SUPABASE_PROJECT_URL", "YOUR_SUPABASE_PUBLIC_ANON_KEY" ); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Storage document data has been updated if (event.type === "storageUpdated") { const { roomId } = event.data; // Get Storage data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/storage`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Storage document data as a string const storageData = await response.text(); // Update database const { data, error } = await supabase .from("documents") .insert({ roomid: roomId, storagedata: storageData }) .select(); if (error) { return new Response("Problem inserting data into database", { status: 500, }); } } return new Response(null, { status: 200 }); } ``` ## Check if it worked To check if it worked, you can replay an event from the Liveblocks dashboard, or just edit your document.
Create your table schema
Next, go to Supabase, and click the Table Editor icon on the left bar. Find your `documents` table on the left, and check the entries. You should now see your Storage document—we’ve successfully set up data synchronization! When a user edits Storage data in your app, this function will be called, and your database will be updated. You can rely on this to stay up to date, within the 5 second throttle limit. ## Learn more You can learn more about Liveblocks webhooks in our full [webhooks guide](/docs/platform/webhooks). We also have an API reference for the [Get Storage Document API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-storage). --- meta: title: "How to synchronize your Liveblocks Storage document data to a Vercel Postgres database" description: "Learn how to automatically update your Vercel Postgres database with changes from your Storage-based application." --- Liveblocks allows you to build collaborative applications with [Storage](/storage), a persisted conflict-free data store. Using our webhooks and REST API, you can then retrieve the Storage document data as it changes, and store it in your database. ## What we’re building In this guide we’ll be linking a Storage application up to a [Vercel Postgres](https://vercel.com/docs/storage/vercel-postgres) database so that Storage document data is automatically synchronized. This is enabled through the following: - [StorageUpdated](/docs/platform/webhooks#StorageUpdatedEvent) webhook event - [Get Storage Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-storage) REST API This guide assumes you already have a Liveblocks Storage project set up. If you haven’t already got one, you can select _Custom_ on our [getting started page](https://liveblocks.io/docs/get-started/custom), choose a framework, and come back after you’re set up. You could also use an example such as our [collaborative spreadsheet)(/examples/collaborative-spreadsheet-advanced/nextjs-spreadsheet-advanced). ### This specific webhook is throttled Note that the [StorageUpdated](/docs/platform/webhooks#StorageUpdatedEvent) webhook event is throttled at a rate of _once every 5 seconds_. This is because Storage can update up to _60 times per second_, and it would be impractical to run the webhook this frequently. ## Create an endpoint in your project When a webhook event is triggered, it can send a POST request to the back end in your project, and from within there we can update the database. In this guide, we’ll be using a Next.js route handler (API endpoint) as an example, but other frameworks work similarly. In order to use webhooks, we’ll need to retrieve the `headers` and `body` from the request. Here’s the basic endpoint we’ll be starting from: ```ts export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Handle webhooks and database // ... return new Response(null, { status: 200 }); } ``` Create this endpoint in your project, and make it available on `localhost` at the following URL: ``` /api/liveblocks-database-sync ``` ### Testing webhooks locally Running webhooks locally can be difficult, but one way to do this is to use a tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or [`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily put your localhost server online. If your project is running on `localhost:3000`, you can run the following command to generate a temporary URL that’s available while your localhost server is running: ```bash npx localtunnel --port 3000 ``` `localtunnel` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-database-sync # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-database-sync ``` You now have a URL that can be used in the webhooks dashboard. ## Set up webhooks on the Liveblocks dashboard To use webhooks, you need to pass your endpoint URL to the webhooks dashboard inside your Liveblocks project, and tell the webhook to trigger when Storage document data has changed. Select your project From the [Liveblocks dashboard](/dashboard), navigate to the project you’d like to use with webhooks, or create a new project.
Create a Liveblocks project
Go to the webhooks dashboard Click on the **“Webhooks”** tab on the menu at the left.
Click webhooks
Create an endpoint Click the **“Create endpoint…”** button on the webhooks dashboard to start setting up your webhook.
Click add endpoint
Add your endpoint URL Enter the URL of the endpoint. In a production app this will be the real endpoint, but for now enter your `localtunnel` URL from earlier.
Add endpoint URL
Get your secret key Click **“Create endpoint”** at the bottom, then find your **“Webhook secret key”** on the next page, and copy it.
Copy your webhook secret key
Webhooks dashboard is set up! Note that you can filter specifically for `storageUpdated` events, but we’re ignoring this for now so we can test more easily. Let’s go back to the code.
## Verify the webhook request The [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides you with a function that verifies whether the current request is a real webhook request from Liveblocks. You can set this up by setting up a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest). Make sure to add your “Signing Secret” from the Liveblocks dashboard—in a real project we’d recommend using an environment variable for this. ```ts highlight="1,3-5,11-21" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // Update database // ... return new Response(null, { status: 200 }); } ``` We can then check we’re receiving the correct type of event, get the updated `roomId`, and handle updating the database inside there. ```ts highlight="23-29" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Storage document data has been updated if (event.type === "storageUpdated") { const { roomId } = event.data; // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Get the current room’s Storage document data Before updating our database, we need to get the current room’s data. We can do this through the [Get Storage Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-storage) REST API. You use the REST API, you need to add your secret key from your project page. ```ts highlight="7-8,30-43" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Storage document data has been updated if (event.type === "storageUpdated") { const { roomId } = event.data; // Get Storage data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/storage`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Storage document data as a string const storageData = await response.text(); // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Create a Vercel Postgres database We’re ready to set up our database! This is how to get started with Vercel Postgres: Navigate to the [Vercel Storage](https://vercel.com/dashboard/stores) dashboard. Click "Create Database", then select “Postgres”. Give the database a name and click “Create”. Follow the getting started guide to connect the database to your Vercel project, link your environment variables, and install the packages. ## Add the Storage data to your database And finally, we can add the Storage JSON data to our database. Here we’re creating a simple `documents` table that contains the following fields: | Field | Description | Type | Key | | ------------- | ---------------------------------- | -------------- | --------- | | `roomId` | The `roomId`. | `VARCHAR(255)` | `PRIMARY` | | `storageData` | The stringified JSON Storage data. | `TEXT` | | This is how to implement it in your endpoint: ```ts highlight="2,46-65" import { WebhookHandler } from "@liveblocks/node"; import { sql } from "@vercel/postgres"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Storage document data has been updated if (event.type === "storageUpdated") { const { roomId } = event.data; // Get Storage data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/storage`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Storage document data as a string const storageData = await response.text(); // Update database try { await sql` CREATE TABLE IF NOT EXISTS documents ( roomId VARCHAR(255) PRIMARY KEY, storageData TEXT NOT NULL ); `; await sql` INSERT INTO documents (roomId, storageData) VALUES (${roomId}, ${storageData}) ON CONFLICT (roomId) DO UPDATE SET storageData = EXCLUDED.storageData `; } catch (err) { return new Response("Problem inserting data into database", { status: 500, }); } } return new Response(null, { status: 200 }); } ``` ## Check if it worked To check if your database synchronization is working, you can replay a `storageUpdated` event from the Liveblocks dashboard.
Create your table schema
Then go to the Data page on your Vercel Postgres dashboard, find your table in the dropdown, and check the entries. You should now see your Storage document—we’ve successfully set up data synchronization! When a user edits Storage data in your app, this function will be called, and your database will be updated. You can rely on this to stay up to date, within the 5 second throttle limit. ## Learn more You can learn more about Liveblocks webhooks in our full [webhooks guide](/docs/platform/webhooks). We also have an API reference for the [Get Storage Document API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-storage). --- meta: title: "How to synchronize your Liveblocks Yjs document data to a PlanetScale MySQL database" description: "Learn how to automatically update your PlanetScale MySQL database with changes from your Yjs application." --- Liveblocks allows you to build collaborative applications with [Yjs](https://yjs.dev/), which is tightly integrated into our infrastructure. Using our webhooks and REST API, you can then retrieve the Yjs document data as it changes, and store it in your database. ## What we’re building In this guide we’ll be linking a Yjs application up to a [PlanetScale](https://planetscale.com/) MySQL database so that Yjs document data is automatically synchronized. This is enabled through the following: - [YDocUpdated](/docs/platform/webhooks#YDocUpdatedEvent) webhook event - [Get Yjs Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc) REST API This guide assumes you already have a Liveblocks Yjs project set up. If you haven’t already got one, you can open our getting started page, select a [text editor](/docs/get-started/text-editor) or [code editor](/docs/get-started/code-editor), both of which use Yjs, and come back after you’re set up. ### This specific webhook is throttled Note that the [YDocUpdated](/docs/platform/webhooks#YDocUpdatedEvent) webhook event is throttled at a rate of _once every 5 seconds_. This is because Yjs can update up to _60 times per second_, and it would be impractical to run the webhook this frequently. ## Create an endpoint in your project When a webhook event is triggered, it can send a POST request to the back end in your project, and from within there we can update the database. In this guide, we’ll be using a Next.js route handler (API endpoint) as an example, but other frameworks work similarly. In order to use webhooks, we’ll need to retrieve the `headers` and `body` from the request. Here’s the basic endpoint we’ll be starting from: ```ts export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Handle webhooks and database // ... return new Response(null, { status: 200 }); } ``` Create this endpoint in your project, and make it available on `localhost` at the following URL: ``` /api/liveblocks-database-sync ``` ### Testing webhooks locally Running webhooks locally can be difficult, but one way to do this is to use a tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or [`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily put your localhost server online. If your project is running on `localhost:3000`, you can run the following command to generate a temporary URL that’s available while your localhost server is running: ```bash npx localtunnel --port 3000 ``` `localtunnel` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-database-sync # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-database-sync ``` You now have a URL that can be used in the webhooks dashboard. ## Set up webhooks on the Liveblocks dashboard To use webhooks, you need to pass your endpoint URL to the webhooks dashboard inside your Liveblocks project, and tell the webhook to trigger when Yjs document data has changed. Select your project From the [Liveblocks dashboard](/dashboard), navigate to the project you’d like to use with webhooks, or create a new project.
Create a Liveblocks project
Go to the webhooks dashboard Click on the **“Webhooks”** tab on the menu at the left.
Click webhooks
Create an endpoint Click the **“Create endpoint…”** button on the webhooks dashboard to start setting up your webhook.
Click add endpoint
Add your endpoint URL Enter the URL of the endpoint. In a production app this will be the real endpoint, but for now enter your `localtunnel` URL from earlier.
Add endpoint URL
Get your secret key Click **“Create endpoint”** at the bottom, then find your **“Webhook secret key”** on the next page, and copy it.
Copy your webhook secret key
Webhooks dashboard is set up! Note that you can filter specifically for `ydocUpdated` events, but we’re ignoring this for now so we can test more easily. Let’s go back to the code.
## Verify the webhook request The [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides you with a function that verifies whether the current request is a real webhook request from Liveblocks. You can set this up by setting up a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest). Make sure to add your “Signing Secret” from the Liveblocks dashboard—in a real project we’d recommend using an environment variable for this. ```ts highlight="1,3-5,11-21" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // Update database // ... return new Response(null, { status: 200 }); } ``` We can then check we’re receiving the correct type of event, get the updated `roomId`, and handle updating the database inside there. ```ts highlight="23-29" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Yjs document data has been updated if (event.type === "ydocUpdated") { const { roomId } = event.data; // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Get the current room’s Yjs document data Before updating our database, we need to get the current room’s data. We can do this through the [Get Yjs Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc) REST API. You use the REST API, you need to add your secret key from your project page. ```ts highlight="7-8,30-43" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Yjs document data has been updated if (event.type === "ydocUpdated") { const { roomId } = event.data; // Get Yjs data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/ydoc`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Yjs document data as a string const yDocData = await response.text(); // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Create a PlanetScale MySQL database We’re ready to set up our PlanetScale database! We’ll be creating a simple `documents` table that contains the following fields: | Field | Description | Type | Key | | ---------- | ------------------------------ | -------------- | --------- | | `roomId` | The `roomId`. | `VARCHAR(255)` | `PRIMARY` | | `yDocData` | The stringified JSON Yjs data. | `TEXT` | | Create a database Navigate to PlanetScale’s [new database page](https://app.planetscale.com/new), give your database a name, and click “Create” at the bottom.
Create a PlanetScale database
Create a new branch After the database has been created, click “New branch” at the top right to create a branch—this works like Git.
Create a new branch
Set up your schema Click “Console” at the top, select your branch, and enter the following to set up your table. ```sql CREATE TABLE documents ( roomId VARCHAR(255) PRIMARY KEY, yDocData TEXT NOT NULL ); ```
Create your table
Click “Connect” After the database has been created, click “Connect” at the top right to create new credentials.
Click connect
Create credentials Give your new credentials a name, then click “Create Password”.
Create a PlanetScale password
Add the credentials to your project Select “Node.js” in the dropdown and copy the database URL (or add it as an environment variable).
Get the database URL
Database ready! Let’s take a look at the code.
## Add the Yjs data to your database And finally, we can add the Yjs JSON data to our database! First, we need to install `mysql2`: ```bash npm i mysql2 ``` Then implement the following to synchronize your data to PlanetScale: ```ts highlight="2,11-12,49-67" import { WebhookHandler } from "@liveblocks/node"; import mysql from "mysql2"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; // Your PlanetScale database URL const DATABASE_URL = "YOUR_DATABASE_URL"; export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Yjs document data has been updated if (event.type === "ydocUpdated") { const { roomId } = event.data; // Get Yjs data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/ydoc`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Yjs document data as a string const yDocData = await response.text(); // Update database const connection = await mysql.createConnection(DATABASE_URL); const sql = ` INSERT INTO documents (roomId, yDocData) VALUES (?, ?) ON DUPLICATE KEY UPDATE yDocData = VALUES(yDocData); `; try { await connection.query(sql, [roomId, yDocData]); } catch (err) { return new Response("Problem inserting data into database", { status: 500, }); } await connection.end(); } return new Response(null, { status: 200 }); } ``` ## Check if it worked To check if your database synchronization is working, you can replay a `yDocUpdated` event from the Liveblocks dashboard.
Create your table schema
Then go back to the Console page on PlanetScale and enter the following to see all entries: ```sql SELECT * FROM documents; ``` You should now see your Yjs document—we’ve successfully set up data synchronization! When a user edits Yjs data in your app, this function will be called, and your database will be updated. You can rely on this to stay up to date, within the 5 second throttle limit. ## Learn more You can learn more about Liveblocks webhooks in our full [webhooks guide](/docs/platform/webhooks). We also have an API reference for the [Get Yjs Document API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc). --- meta: title: "How to synchronize your Liveblocks Yjs document data to a Supabase Postgres database" description: "Learn how to automatically update your Supabase Postgres database with changes from your Yjs application." --- Liveblocks allows you to build collaborative applications with [Yjs](https://yjs.dev/), which is tightly integrated into our infrastructure. Using our webhooks and REST API, you can then retrieve the Yjs document data as it changes, and store it in your database. ## What we’re building In this guide we’ll be linking a Yjs application up to a [Supabase Postgres](https://supabase.com/database) database so that Yjs document data is automatically synchronized. This is enabled through the following: - [YDocUpdated](/docs/platform/webhooks#YDocUpdatedEvent) webhook event - [Get Yjs Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc) REST API This guide assumes you already have a Liveblocks Yjs project set up. If you haven’t already got one, you can open our getting started page, select a [text editor](/docs/get-started/text-editor) or [code editor](/docs/get-started/code-editor), both of which use Yjs, and come back after you’re set up. ### This specific webhook is throttled Note that the [YDocUpdated](/docs/platform/webhooks#YDocUpdatedEvent) webhook event is throttled at a rate of _once every 5 seconds_. This is because Yjs can update up to _60 times per second_, and it would be impractical to run the webhook this frequently. ## Create an endpoint in your project When a webhook event is triggered, it can send a POST request to the back end in your project, and from within there we can update the database. In this guide, we’ll be using a Next.js route handler (API endpoint) as an example, but other frameworks work similarly. In order to use webhooks, we’ll need to retrieve the `headers` and `body` from the request. Here’s the basic endpoint we’ll be starting from: ```ts export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Handle webhooks and database // ... return new Response(null, { status: 200 }); } ``` Create this endpoint in your project, and make it available on `localhost` at the following URL: ``` /api/liveblocks-database-sync ``` ### Testing webhooks locally Running webhooks locally can be difficult, but one way to do this is to use a tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or [`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily put your localhost server online. If your project is running on `localhost:3000`, you can run the following command to generate a temporary URL that’s available while your localhost server is running: ```bash npx localtunnel --port 3000 ``` `localtunnel` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-database-sync # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-database-sync ``` You now have a URL that can be used in the webhooks dashboard. ## Set up webhooks on the Liveblocks dashboard To use webhooks, you need to pass your endpoint URL to the webhooks dashboard inside your Liveblocks project, and tell the webhook to trigger when Yjs document data has changed. Select your project From the [Liveblocks dashboard](/dashboard), navigate to the project you’d like to use with webhooks, or create a new project.
Create a Liveblocks project
Go to the webhooks dashboard Click on the **“Webhooks”** tab on the menu at the left.
Click webhooks
Create an endpoint Click the **“Create endpoint…”** button on the webhooks dashboard to start setting up your webhook.
Click add endpoint
Add your endpoint URL Enter the URL of the endpoint. In a production app this will be the real endpoint, but for now enter your `localtunnel` URL from earlier.
Add endpoint URL
Get your webhook secret key Click **“Create endpoint”** at the bottom, then find your **“Webhook secret key”** on the next page, and copy it.
Copy your webhook secret key
Webhooks dashboard is set up! Note that you can filter specifically for `ydocUpdated` events, but we’re ignoring this for now so we can test more easily. Let’s go back to the code.
## Verify the webhook request The [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides you with a function that verifies whether the current request is a real webhook request from Liveblocks. You can set this up by setting up a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest). Make sure to add your “Signing Secret” from the Liveblocks dashboard—in a real project we’d recommend using an environment variable for this. ```ts highlight="1,3-5,11-21" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // Update database // ... return new Response(null, { status: 200 }); } ``` We can then check we’re receiving the correct type of event, get the updated `roomId`, and handle updating the database inside there. ```ts highlight="23-29" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Yjs document data has been updated if (event.type === "ydocUpdated") { const { roomId } = event.data; // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Get the current room’s Yjs document data Before updating our database, we need to get the current room’s data. We can do this through the [Get Yjs Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc) REST API. You use the REST API, you need to add your secret key from your project page. ```ts highlight="7-8,30-43" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Yjs document data has been updated if (event.type === "ydocUpdated") { const { roomId } = event.data; // Get Yjs data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/ydoc`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Yjs document data as a string const yDocData = await response.text(); // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Create a Supabase Postgres database We’re ready to set up our Supabase database! We’ll be creating a simple `documents` table that contains the following fields: | Field | Description | Type | Key | | ---------- | ------------------------------ | -------------- | --------- | | `roomId` | The `roomId`. | `VARCHAR(255)` | `PRIMARY` | | `yDocData` | The stringified JSON Yjs data. | `TEXT` | | New project Navigate to Supabase’s [dashboard page](https://supabase.com/dashboard/projects), and click “New project” at the top.
Click new project
Create a database Give your database a name and password, then click “Create new project” at the bottom. Make sure to save your password, because you won’t be able to view it again.
Create a new project
Create your table Click the SQL Editor icon in the left bar, enter the following code into the console, and click “Run” at the bottom right to create the table. ```sql CREATE TABLE documents ( roomId VARCHAR(255) PRIMARY KEY, yDocData TEXT NOT NULL ); ```
Create your table schema
Get your credentials Click the Settings icon in the left bar, click “API” at the left and find two items: 1. Your “URL”, under “Project URL”. 2. Your `anon``public` key, under “Project API key”. Make a note of these, or add them as environment variables in your project.
Create your table schema
Database ready! Let’s take a look at the code.
## Add the Yjs data to your database And finally, we can add the Yjs JSON data to our database! First, we need to install the Supabase library: ```bash npm i @supabase/supabase-js ``` Then implement the following to synchronize your data, making sure to add your Project URL: ```ts highlight="2,11-15,52-62" import { WebhookHandler } from "@liveblocks/node"; import { createClient } from "@supabase/supabase-js"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; // Create a Supabase client const supabase = createClient( "YOUR_SUPABASE_PROJECT_URL", "YOUR_SUPABASE_PUBLIC_ANON_KEY" ); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Yjs document data has been updated if (event.type === "ydocUpdated") { const { roomId } = event.data; // Get Yjs data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/ydoc`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Yjs document data as a string const yDocData = await response.text(); // Update database const { data, error } = await supabase .from("documents") .insert({ roomid: roomId, ydocdata: yDocData }) .select(); if (error) { return new Response("Problem inserting data into database", { status: 500, }); } } return new Response(null, { status: 200 }); } ``` ## Check if it worked To check if it worked, you can replay an event from the Liveblocks dashboard, or just edit your document.
Create your table schema
Next, go to Supabase, and click the Table Editor icon on the left bar. Find your `documents` table on the left, and check the entries. You should now see your Yjs document—we’ve successfully set up data synchronization! When a user edits Yjs data in your app, this function will be called, and your database will be updated. You can rely on this to stay up to date, within the 5 second throttle limit. ## Learn more You can learn more about Liveblocks webhooks in our full [webhooks guide](/docs/platform/webhooks). We also have an API reference for the [Get Yjs Document API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc). --- meta: title: "How to synchronize your Liveblocks Yjs document data to a Vercel Postgres database" description: "Learn how to automatically update your Vercel Postgres database with changes from your Yjs application." --- Liveblocks allows you to build collaborative applications with [Yjs](https://yjs.dev/), which is tightly integrated into our infrastructure. Using our webhooks and REST API, you can then retrieve the Yjs document data as it changes, and store it in your database. ## What we’re building In this guide we’ll be linking a Yjs application up to a [Vercel Postgres](https://vercel.com/docs/storage/vercel-postgres) database so that Yjs document data is automatically synchronized. This is enabled through the following: - [YDocUpdated](/docs/platform/webhooks#YDocUpdatedEvent) webhook event - [Get Yjs Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc) REST API This guide assumes you already have a Liveblocks Yjs project set up. If you haven’t already got one, you can open our getting started page, select a [text editor](/docs/get-started/text-editor) or [code editor](/docs/get-started/code-editor), both of which use Yjs, and come back after you’re set up. ### This specific webhook is throttled Note that the [YDocUpdated](/docs/platform/webhooks#YDocUpdatedEvent) webhook event is throttled at a rate of _once every 5 seconds_. This is because Yjs can update up to _60 times per second_, and it would be impractical to run the webhook this frequently. ## Create an endpoint in your project When a webhook event is triggered, it can send a POST request to the back end in your project, and from within there we can update the database. In this guide, we’ll be using a Next.js route handler (API endpoint) as an example, but other frameworks work similarly. In order to use webhooks, we’ll need to retrieve the `headers` and `body` from the request. Here’s the basic endpoint we’ll be starting from: ```ts export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Handle webhooks and database // ... return new Response(null, { status: 200 }); } ``` Create this endpoint in your project, and make it available on `localhost` at the following URL: ``` /api/liveblocks-database-sync ``` ### Testing webhooks locally Running webhooks locally can be difficult, but one way to do this is to use a tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or [`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily put your localhost server online. If your project is running on `localhost:3000`, you can run the following command to generate a temporary URL that’s available while your localhost server is running: ```bash npx localtunnel --port 3000 ``` `localtunnel` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-database-sync # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-database-sync ``` You now have a URL that can be used in the webhooks dashboard. ## Set up webhooks on the Liveblocks dashboard To use webhooks, you need to pass your endpoint URL to the webhooks dashboard inside your Liveblocks project, and tell the webhook to trigger when Yjs document data has changed. Select your project From the [Liveblocks dashboard](/dashboard), navigate to the project you’d like to use with webhooks, or create a new project.
Create a Liveblocks project
Go to the webhooks dashboard Click on the **“Webhooks”** tab on the menu at the left.
Click webhooks
Create an endpoint Click the **“Create endpoint…”** button on the webhooks dashboard to start setting up your webhook.
Click add endpoint
Add your endpoint URL Enter the URL of the endpoint. In a production app this will be the real endpoint, but for now enter your `localtunnel` URL from earlier.
Add endpoint URL
Get your webhook secret key Click **“Create endpoint”** at the bottom, then find your **“Webhook secret key”** on the next page, and copy it.
Copy your webhook secret key
Webhooks dashboard is set up! Note that you can filter specifically for `ydocUpdated` events, but we’re ignoring this for now so we can test more easily. Let’s go back to the code.
## Verify the webhook request The [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides you with a function that verifies whether the current request is a real webhook request from Liveblocks. You can set this up by setting up a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest). Make sure to add your “Signing Secret” from the Liveblocks dashboard—in a real project we’d recommend using an environment variable for this. ```ts highlight="1,3-5,11-21" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // Update database // ... return new Response(null, { status: 200 }); } ``` We can then check we’re receiving the correct type of event, get the updated `roomId`, and handle updating the database inside there. ```ts highlight="23-29" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Yjs document data has been updated if (event.type === "ydocUpdated") { const { roomId } = event.data; // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Get the current room’s Yjs document data Before updating our database, we need to get the current room’s data. We can do this through the [Get Yjs Document](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc) REST API. You use the REST API, you need to add your secret key from your project page. ```ts highlight="7-8,30-43" import { WebhookHandler } from "@liveblocks/node"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Yjs document data has been updated if (event.type === "ydocUpdated") { const { roomId } = event.data; // Get Yjs data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/ydoc`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Yjs document data as a string const yDocData = await response.text(); // Update database // ... } return new Response(null, { status: 200 }); } ``` ## Create a Vercel Postgres database We’re ready to set up our database! This is how to get started with Vercel Postgres: Navigate to the [Vercel Storage](https://vercel.com/dashboard/stores) dashboard. Click "Create Database", then select “Postgres”. Give the database a name and click “Create”. Follow the getting started guide to connect the database to your Vercel project, link your environment variables, and install the packages. ## Add the Yjs data to your database And finally, we can add the Yjs JSON data to our database. Here we’re creating a simple `documents` table that contains the following fields: | Field | Description | Type | Key | | ---------- | ------------------------------ | -------------- | --------- | | `roomId` | The `roomId`. | `VARCHAR(255)` | `PRIMARY` | | `yDocData` | The stringified JSON Yjs data. | `TEXT` | | This is how to implement it in your endpoint: ```ts highlight="2,46-65" import { WebhookHandler } from "@liveblocks/node"; import { sql } from "@vercel/postgres"; // Add your signing key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_SIGNING_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When Yjs document data has been updated if (event.type === "ydocUpdated") { const { roomId } = event.data; // Get Yjs data from Liveblocks REST API const url = `https://api.liveblocks.io/v2/rooms/${roomId}/ydoc`; const response = await fetch(url, { headers: { Authorization: `Bearer ${API_SECRET}` }, }); if (!response.ok) { return new Response("Problem accessing Liveblocks REST APIs", { status: 500, }); } // Your JSON Yjs document data as a string const yDocData = await response.text(); // Update database try { await sql` CREATE TABLE IF NOT EXISTS documents ( roomId VARCHAR(255) PRIMARY KEY, yDocData TEXT NOT NULL ); `; await sql` INSERT INTO documents (roomId, yDocData) VALUES (${roomId}, ${yDocData}) ON CONFLICT (roomId) DO UPDATE SET yDocData = EXCLUDED.yDocData `; } catch (err) { return new Response("Problem inserting data into database", { status: 500, }); } } return new Response(null, { status: 200 }); } ``` ## Check if it worked To check if your database synchronization is working, you can replay a `yDocUpdated` event from the Liveblocks dashboard.
Create your table schema
Then go to the Data page on your Vercel Postgres dashboard, find your table in the dropdown, and check the entries. You should now see your Yjs document—we’ve successfully set up data synchronization! When a user edits Yjs data in your app, this function will be called, and your database will be updated. You can rely on this to stay up to date, within the 5 second throttle limit. ## Learn more You can learn more about Liveblocks webhooks in our full [webhooks guide](/docs/platform/webhooks). We also have an API reference for the [Get Yjs Document API](/docs/api-reference/rest-api-endpoints#get-rooms-roomId-ydoc). --- meta: title: "How to test webhooks on localhost" description: "Learn how to test Liveblocks webhooks on your local system using localtunnel or ngrok." --- Testing webhooks on your local system can be difficult, but there are ways to make it possible using tools such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) and [`ngrok`](https://www.npmjs.com/package/ngrok). ## Create an endpoint in your project The first step in testing webhooks is making sure you have an API endpoint set up in your project. This is the endpoint that’ll receive the webhook event from Liveblocks. In order to use webhooks, we’ll need to retrieve the `headers` and `body` from the request. Here’s the basic endpoint we’ll be starting from: ```ts export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Handle webhooks // ... return new Response(null, { status: 200 }); } ``` Create this endpoint in your project, and make it available on `localhost` at the following URL: ``` /api/liveblocks-webhook ``` ## Testing webhooks locally Tools such as `localtunnel` and `ngrok` allow you to temporarily place your localhost server online, by providing you with a temporary URL. Let’s take a look at these two options. ### localtunnel [`localtunnel`](https://www.npmjs.com/package/localtunnel) allows you to get started without signing up. If your project is running on `localhost:3000`, you can run the following `localtunnel` command to generate your URL, which will stay online while your localhost server is running: ```bash npx localtunnel --port 3000 ``` `localtunnel` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-webhook # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-webhook ``` You now have a URL that can be used in the webhooks dashboard. ### ngrok [`ngrok`](https://www.npmjs.com/package/ngrok) requires you to sign up and install, but it has more features and is simpler to use after you’ve created an account. If your project is running on `localhost:3000`, you can run the following `ngrok` command to generate your URL, which will stay online while your localhost server is running: ```bash ngrok http 3000 ``` `ngrok` generates a base URL that can be placed into the Liveblocks webhooks dashboard for quick testing. To use this, take the full address of your webhook endpoint, and replace the domain in your `localhost` address with the generated URL. ```shell # Take your local URL http://localhost:3000/api/liveblocks-webhook # Replace localhost with the generated domain, then copy it https://my-localtunnel-url.loca.lt/api/liveblocks-webhook ``` You now have a URL that can be used in the webhooks dashboard. ## Set up webhooks on the Liveblocks dashboard To use webhooks, you need to pass your endpoint URL to the webhooks dashboard inside your Liveblocks project, and tell the webhook to trigger on any specific [webhook events](/docs/platform/webhooks#Liveblocks-events). Select your project From the [Liveblocks dashboard](/dashboard), navigate to the project you’d like to use with webhooks, or create a new project.
Create a Liveblocks project
Go to the webhooks dashboard Click on the **“Webhooks”** tab on the menu at the left.
Click webhooks
Create an endpoint Click the **“Create endpoint…”** button on the webhooks dashboard to start setting up your webhook.
Click add endpoint
Add your endpoint URL Enter the URL of the endpoint. In a production app this will be the real endpoint, but for now enter your `localtunnel` or `ngrok` URL from earlier. You can filter for any specific [webhook events](/docs/platform/webhooks#Liveblocks-events) here, in case you’d only like to listen to certain types.
Add endpoint URL
Get your secret key Click **“Create endpoint”** at the bottom, then find your **“Webhook secret key”** on the next page, and copy it.
Copy your webhook secret key
Webhooks dashboard is set up! Done! Let’s go back to the code.
## Verify the webhook request It’s recommended to verify that your webhook requests have come from Liveblocks, and the [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package provides you with a function that will verify this for you. You can set this up by creating a [`WebhookHandler`](/docs/api-reference/liveblocks-node#WebhookHandler) and running [`verifyRequest`](/docs/api-reference/liveblocks-node#verifyRequest). Make sure to add your "Webhook secret key" from the Liveblocks dashboard—in a real project we’d recommend using an environment variable for this. ```ts highlight="1,3-5,11-21" import { WebhookHandler } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // Use webhook event // ... return new Response(null, { status: 200 }); } ``` The webhook has now been verified! ## Use your webhook event From this point on, you can use the webhook event as you like. Here’s a [Comments](/docs/ready-made-features/comments) example, showing you how to fetch a new thread after it’s just been created. ```ts highlight="2,8-10,28-37" import { WebhookHandler } from "@liveblocks/node"; import { Liveblocks } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); // Add your secret key from a project's API keys dashboard const API_SECRET = "{{SECRET_KEY}}"; const liveblocks = new Liveblocks({ secret: API_SECRET }); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } // When a new thread has been created if (event.type === "threadCreated") { const { roomId, threadId } = event.data; // Fetch new thread const thread = await liveblocks.getThread({ roomId, threadId }); // Use thread // ... } return new Response(null, { status: 200 }); } ``` Visit the [webhook events](/docs/platform/webhooks#Liveblocks-events) section of our webhooks guide to learn more. --- meta: title: "How to use Liveblocks multiplayer undo/redo with React" description: "Learn how to use Liveblocks multiplayer undo/redo with React" --- In this guide, we’ll be learning how to use Liveblocks multiplayer undo/redo with React using the hooks from the [`@liveblocks/react`][] package. This guide uses [TypeScript](https://www.typescriptlang.org/). Liveblocks can definitely be used without TypeScript. We believe typings are helpful to make collaborative apps more robust, but if you’d prefer to skip the TypeScript syntax, feel free to write your code in JavaScript. This guide assumes you already have Liveblocks set up into your React application. If you don’t make sure to follow [these quick steps to get started](/docs/get-started/react) first. ## Multiplayer undo/redo [#undo-redo] Implementing undo/redo in a multiplayer environment is [notoriously complex](/blog/how-to-build-undo-redo-in-a-multiplayer-environment), but Liveblocks provides functions to handle it for you. `useUndo` and `useRedo` return functions that allow you to undo and redo the last changes made to your app. ```tsx highlight="4,5,9,10" import { useUndo, useRedo } from "@liveblocks/react/suspense"; function App() { const undo = useUndo(); const redo = useRedo(); return ( <> ); } ``` An example of this in use would be a button that updates the current `firstName` of a scientist. Every time a Liveblocks storage change is detected, in this case `.set` being called, it’s stored. Pressing the undo button will change the name back to its previous value. ```tsx highlight="6,9,15,16" import { useState } from "react"; import { useMutation, useUndo } from "@liveblocks/react/suspense"; function YourComponent() { const [text, setText] = useState(""); const undo = useUndo(); const updateName = useMutation(({ storage }, newName) => { storage.get("scientist").set("firstName", newName); }); return ( <> setText(e.target.value)} /> ); } ``` Multiplayer undo/redo is much more complex that it sounds—if you’re interested in the technical details, you can find more information in our interactive article: [How to build undo/redo in a multiplayer environment](/blog/how-to-build-undo-redo-in-a-multiplayer-environment). ### Pause and resume history [#pause-resume] Sometimes it can be helpful to pause undo/redo history, so that multiple updates are reverted with a single call. For example, let’s consider a design tool; when a user drags a rectangle, the intermediate rectangle positions should not be part of the undo/redo history, otherwise pressing `undo` may only move the rectangle one pixel backwards. However, these small pixel updates should still be transmitted to others, so that the transition is smooth. `useHistory` is a hook that allows us to pause and resume history states as we please. ```tsx highlight="4,8,9" import { useHistory } from "@liveblocks/react/suspense"; function App() { const { resume, pause } = useHistory(); return pause()} onDragEnd={() => resume()} />; } ``` ### Presence history By default, undo/redo only impacts the room storage—there’s generally no need to use it with presence, for example there’s no reason to undo the position of a user’s cursor. However, occasionally it can be useful. If we explore the design tool scenario, the currently selected rectangle may be stored in a user’s presence. If `undo` is pressed, and the rectangle is moved back, it would make sense to remove the user’s selection on that rectangle. To enable this, we can use the `addToHistory` option when updating the user’s presence. ```tsx highlight="4,9" import { useUpdateMyPresence } from "@liveblocks/react/suspense"; function App() { const updateMyPresence = useUpdateMyPresence(); return ( updateMyPresence({ selected: rectangleId }, { addToHistory: true }) } /> ); } ``` This also works in mutations with `setMyPresence`. ```tsx highlight="4" import { useMutation } from "@liveblocks/react/suspense"; const updateSelected = useMutation(({ setMyPresence }, rectangleId) => { setMyPresence({ selected: rectangleId }, { addToHistory: true }); }); ``` [`@liveblocks/client`]: /docs/api-reference/liveblocks-client [`@liveblocks/react`]: /docs/api-reference/liveblocks-react [`createroomcontext`]: /docs/api-reference/liveblocks-react#createRoomContext [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`livelist.push`]: /docs/api-reference/liveblocks-client#LiveList.push [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`liveobject.get`]: /docs/api-reference/liveblocks-client#LiveObject.get [`liveobject.set`]: /docs/api-reference/liveblocks-client#LiveObject.set [`room.history.pause`]: /docs/api-reference/liveblocks-client#Room.history.pause [`room.history.resume`]: /docs/api-reference/liveblocks-client#Room.history.resume [`room.subscribe`]: /docs/api-reference/liveblocks-client#Room.subscribe(storageItem) [`roomprovider`]: /docs/api-reference/liveblocks-react#RoomProvider [`usehistory`]: /docs/api-reference/liveblocks-react#useHistory [`useothers`]: /docs/api-reference/liveblocks-react#useOthers [`useredo`]: /docs/api-reference/liveblocks-react#useRedo [`useundo`]: /docs/api-reference/liveblocks-react#useUndo [`useupdatemypresence`]: /docs/api-reference/liveblocks-react#useUpdateMyPresence [`useconnectionids`]: /docs/api-reference/liveblocks-react#useConnectionIds [`usestorage`]: /docs/api-reference/liveblocks-react#useStorage [`usemutation`]: /docs/api-reference/liveblocks-react#useMutation --- meta: title: "How to use Liveblocks multiplayer undo/redo with Redux" description: "Learn how to use Liveblocks multiplayer undo/redo with Redux" --- In this guide, we’ll be learning how to use Liveblocks multiplayer undo/redo with Redux using the APIs from the [`@liveblocks/redux`][] package. This guide assumes you already have Liveblocks set up into your Redux store. If you don’t make sure to follow [these quick steps to get started](/docs/get-started/redux) first. ## Multiplayer undo/redo [#undo-redo] Implementing undo/redo when multiple users can edit the app state simultaneously is quite complex! **When only one user can edit the app state, undo/redo acts like a "time machine"**; undo/redo replaces the current app state with an app state that was already be seen by the user. When multiple users are involved, undo or redo can lead to an app state that no one has seen before. For example, let's imagine a design tool with two users editing the same circle. - Initial state => `{ radius: "10px", color: "yellow" }` - User A sets the `color` to `blue` => `{ radius: "10px", color: "blue" }` - User B sets the `radius` to `20px` => `{ radius: "20px", color: "blue" }` - User A realizes that it preferred the circle in yellow and undoes **its last modification** => `{ radius: "20px", color: "yellow" }` A yellow circle with a radius of 20px in a completely new state. **Undo/redo in a multiplayer app does not act like a "time machine"; it only undoes local operation**. The good news is that [`room.history.undo`][] and [`room.history.redo`][] takes that complexity out of your hands so you can focus on the core features of your app. Access these two functions from the client like below so you can easily bind them to keyboard events (⌘+Z/⌘+⇧+Z on Mac and Ctrl+Z/Ctrl+Y on Windows) or undo and redo buttons in your application.. ```js const { undo, redo } = client.getRoom("room-id").history; ``` ### Pause and resume history [#pause-resume] Some applications require skipping intermediate states from the undo/redo history. Let's consider a design tool; when a user drags a rectangle, the intermediate rectangle positions should not be part of the undo/redo history. But they should be shared with the rest of the room to create a great experience. [`room.history.pause`][] and [`room.history.resume`][] lets you skip these intermediate states. To go back to our design tool example, the sequence of calls would look like that: - User presses the rectangle - Call `room.history.pause` to skip future operations from the history - User drags the rectangle - User release the rectangle - Call `room.history.resume` At this point, if the user calls `room.history.undo`, the rectangle will go back to its initial position. ```js const { pause, resume } = client.getRoom("room-id").history; ``` [`room.history.undo`]: /docs/api-reference/liveblocks-client#Room.history.undo [`room.history.redo`]: /docs/api-reference/liveblocks-client#Room.history.redo [`room.history.pause`]: /docs/api-reference/liveblocks-client#Room.history.pause [`room.history.resume`]: /docs/api-reference/liveblocks-client#Room.history.resume [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`createclient`]: /docs/api-reference/liveblocks-client#createClient [api reference]: /docs/api-reference/liveblocks-redux [authentication]: /docs/authentication --- meta: title: "How to use Liveblocks multiplayer undo/redo with Zustand" description: "Learn how to use Liveblocks multiplayer undo/redo with Zustand" --- In this guide, we’ll be learning how to use Liveblocks multiplayer undo/redo with Zustand using the APIs from the [`@liveblocks/zustand`][] package. This guide assumes you already have Liveblocks set up into your Zustand store. If you don’t make sure to follow [these quick steps to get started](/docs/get-started/zustand) first. ## Multiplayer undo/redo [#undo-redo] Implementing undo/redo when multiple users can edit the app state simultaneously is quite complex! **When only one user can edit the app state, undo/redo acts like a "time machine"**; undo/redo replaces the current app state with an app state that was already be seen by the user. When multiple users are involved, undo or redo can lead to an app state that no one has seen before. For example, let's imagine a design tool with two users editing the same circle. - Initial state => `{ radius: "10px", color: "yellow" }` - User A sets the `color` to `blue` => `{ radius: "10px", color: "blue" }` - User B sets the `radius` to `20px` => `{ radius: "20px", color: "blue" }` - User A realizes that it preferred the circle in yellow and undoes **its last modification** => `{ radius: "20px", color: "yellow" }` A yellow circle with a radius of 20px in a completely new state. **Undo/redo in a multiplayer app does not act like a "time machine"; it only undoes local operation**. The good news is that [`room.history.undo`][] and [`room.history.redo`][] takes that complexity out of your hands so you can focus on the core features of your app. Access these two functions from your store like below so you can easily bind them to keyboard events (⌘+Z/⌘+⇧+Z on Mac and Ctrl+Z/Ctrl+Y on Windows) or undo and redo buttons in your application.. ```tsx import useStore from "../store"; function YourComponent() { const undo = useStore((state) => state.liveblocks.room?.history.undo); const redo = useStore((state) => state.liveblocks.room?.history.redo); return ( <> ); } ``` ### Pause and resume history [#pause-resume] Some applications require skipping intermediate states from the undo/redo history. Let's consider a design tool; when a user drags a rectangle, the intermediate rectangle positions should not be part of the undo/redo history. But they should be shared with the rest of the room to create a great experience. [`room.history.pause`][] and [`room.history.resume`][] lets you skip these intermediate states. To go back to our design tool example, the sequence of calls would look like that: - User presses the rectangle - Call `room.history.pause` to skip future operations from the history - User drags the rectangle - User release the rectangle - Call `room.history.resume` At this point, if the user calls `room.history.undo`, the rectangle will go back to its initial position. ```tsx import useStore from "../store"; const pause = useStore((state) => state.liveblocks.room?.history.pause); const resume = useStore((state) => state.liveblocks.room?.history.resume); ``` [`room.history.undo`]: /docs/api-reference/liveblocks-client#Room.history.undo [`room.history.redo`]: /docs/api-reference/liveblocks-client#Room.history.redo [`room.history.pause`]: /docs/api-reference/liveblocks-client#Room.history.pause [`room.history.resume`]: /docs/api-reference/liveblocks-client#Room.history.resume [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`createclient`]: /docs/api-reference/liveblocks-client#createClient [api reference]: /docs/api-reference/liveblocks-zustand [authentication]: /docs/authentication --- meta: title: "How to use Liveblocks Presence with React" description: "Learn how to use Liveblocks Presence with React" --- In this guide, we’ll be learning how to use Liveblocks Presence with React using the hooks from the [`@liveblocks/react`][] package. This guide uses [TypeScript](https://www.typescriptlang.org/). Liveblocks can definitely be used without TypeScript. We believe typings are helpful to make collaborative apps more robust, but if you’d prefer to skip the TypeScript syntax, feel free to write your code in JavaScript. This guide assumes you already have Liveblocks set up into your React application. If you don’t make sure to follow [these quick steps to get started](/docs/get-started/react) first. ## Get other users in the room [#get-others] Now that the provider is set up, we can start using the Liveblocks hooks. The first we’ll add is [`useOthers`][], a hook that provides us information about which _other_ users are connected to the room. To show how many other users are in the room, import `useOthers` into a component and use it as below. ```tsx highlight="1,4" import { RoomProvider, useOthers } from "@liveblocks/react/suspense"; function App() { const others = useOthers(); return
There are {others.length} other users with you in the room.
; } function Index() { return ( ); } ``` Great! We’re connected, and we already have information about the other users currently online. ## Define initial presence [#define-presence] Most collaborative features rely on each user having their own temporary state, which is then shared with others. For example, in an app using multiplayer cursors, the location of each user’s cursor will be their state. In Liveblocks, we call this _presence_. We can use _presence_ to hold any object that we wish to share with others. An example would be the pixel coordinates of a user’s cursor: ```js cursor: { x: 256, y: 367 } ``` To start using presence, let’s define a type named `Presence` in `liveblocks.config.ts`. ```tsx file="liveblocks.config.ts" highlight="8-10,12" declare global { interface Liveblocks { Presence: { cursor: { x: number; y: number } | null; }; } } ``` Then, define an `initialPresence` value on our `RoomProvider`. We’ll set the initial cursor to `null` to represent a user whose cursor is currently off-screen. ```tsx file="index.ts" highlight="7" import { RoomProvider, useOthers } from "@liveblocks/react/suspense"; // App function Index() { return ( ); } ``` ## Update user presence [#update-presence] We can add the [`useUpdateMyPresence`][] hook to share this information in realtime, and in this case, update the current user cursor position when `onPointerMove` is called. Next, import `updateMyPresence` and call it with the updated cursor coordinates whenever a pointer move event is detected. ```tsx import { useUpdateMyPresence } from "@liveblocks/react/suspense"; function App() { const updateMyPresence = useUpdateMyPresence(); return (
updateMyPresence({ cursor: { x: e.clientX, y: e.clientY } }) } onPointerLeave={() => updateMyPresence({ cursor: null })} /> ); } ``` We’re setting `cursor` to `null` when the user’s pointer leaves the element. ## Get other users’ presence [#get-others-presence] To retrieve each user’s presence, and cursor locations, we can once again add [`useOthers`][]. This time we’ll use a selector function to map through each user’s presence, and grab their cursor property. If a cursor is set to `null`, a user is off-screen, so we’ll skip rendering it. ```tsx highlight="20-26" import { useOthers, useUpdateMyPresence, RoomProvider, } from "@liveblocks/react/suspense"; function App() { const others = useOthers(); const updateMyPresence = useUpdateMyPresence(); return (
updateMyPresence({ cursor: { x: e.clientX, y: e.clientY } }) } onPointerLeave={() => updateMyPresence({ cursor: null })} > {others.map(({ connectionId, presence }) => presence.cursor ? ( ) : null )}
); } // Basic cursor component function Cursor({ x, y }) { return ( ); } ``` Presence isn’t only for [multiplayer cursors](/examples/browse/cursors), and can be helpful for a number of other use cases such as [live avatar stacks](/examples/browse/avatar-stack) and [realtime form presence](/examples/browse/forms). [`@liveblocks/client`]: /docs/api-reference/liveblocks-client [`@liveblocks/react`]: /docs/api-reference/liveblocks-react [`createroomcontext`]: /docs/api-reference/liveblocks-react#createRoomContext [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`livelist.push`]: /docs/api-reference/liveblocks-client#LiveList.push [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`liveobject.get`]: /docs/api-reference/liveblocks-client#LiveObject.get [`liveobject.set`]: /docs/api-reference/liveblocks-client#LiveObject.set [`room.history.pause`]: /docs/api-reference/liveblocks-client#Room.history.pause [`room.history.resume`]: /docs/api-reference/liveblocks-client#Room.history.resume [`room.subscribe`]: /docs/api-reference/liveblocks-client#Room.subscribe(storageItem) [`roomprovider`]: /docs/api-reference/liveblocks-react#RoomProvider [`usehistory`]: /docs/api-reference/liveblocks-react#useHistory [`useothers`]: /docs/api-reference/liveblocks-react#useOthers [`useredo`]: /docs/api-reference/liveblocks-react#useRedo [`useundo`]: /docs/api-reference/liveblocks-react#useUndo [`useupdatemypresence`]: /docs/api-reference/liveblocks-react#useUpdateMyPresence [`useconnectionids`]: /docs/api-reference/liveblocks-react#useConnectionIds [`usestorage`]: /docs/api-reference/liveblocks-react#useStorage [`usemutation`]: /docs/api-reference/liveblocks-react#useMutation --- meta: title: "How to use Liveblocks Presence with Redux" description: "Learn how to use Liveblocks Presence with Redux" --- In this guide, we’ll be learning how to use Liveblocks Presence with Redux using the APIs from the [`@liveblocks/redux`][] package. This guide assumes you already have Liveblocks set up into your Redux store. If you don’t make sure to follow [these quick steps to get started](/docs/get-started/redux) first. ## Get other users in the room [#get-others] Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. If you want to list all the people connected to the room, you can use `state.liveblocks.others` to get an array of the other users in the room. ```jsx import { useSelector } from "react-redux"; function App() { const others = useSelector((state) => state.liveblocks.others); } ``` ## Update user presence [#update-presence] To create immersive multiplayer experiences, it’s helpful for each person in the room to share their real‑time state with other connected users. That real‑time state often corresponds to a cursor position or even the item a user has currently selected. We call this concept "Presence". For instance, to share the cursor’s position in real‑time with others, we’re going to add a new `presenceMapping` option to our `enhancer` to specify which part of the state maps to the current user’s `presence`. ```jsx file="src/store.js" /* ... imports and client setup ... */ const initialState = { cursor: { x: 0, y: 0 }, }; const slice = createSlice({ name: "state", initialState, reducers: { setCursor: (state, action) => { state.cursor = action.payload; }, }, }); export const { setCursor } = slice.actions; function makeStore() { return configureStore({ reducer: slice.reducer, enhancers: [ enhancer({ client, presenceMapping: { cursor: true }, }), ], }); } const store = makeStore(); export default store; ``` Then you can dispatch an action like in any redux app and we will broadcast this cursor to everyone in the room. ```jsx import { useDispatch } from "react-redux"; import { setCursor } from "./store.js"; function YourComponent() { const dispatch = useDispatch(); return (
dispatch(setCursor({ x: e.clientX, y: e.clientY }))} /> ); } ``` ## Get other users’ presence [#get-others-presence] Get people’s cursor positions with `liveblocks.others.map(user => user.presence?.cursor)`. It’s worth noting that a user presence can be `undefined`. ```jsx import { useSelector } from "react-redux"; function OthersCursors() { const others = useSelector((state) => state.liveblocks.others); const othersCursors = others.map((user) => user.presence?.cursor); // Render cursors with custom SVGs based on x and y } ``` [`room.history.undo`]: /docs/api-reference/liveblocks-client#Room.history.undo [`room.history.redo`]: /docs/api-reference/liveblocks-client#Room.history.redo [`room.history.pause`]: /docs/api-reference/liveblocks-client#Room.history.pause [`room.history.resume`]: /docs/api-reference/liveblocks-client#Room.history.resume [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`createclient`]: /docs/api-reference/liveblocks-client#createClient [api reference]: /docs/api-reference/liveblocks-redux [authentication]: /docs/authentication --- meta: title: "How to use Liveblocks Presence with Zustand" description: "Learn how to use Liveblocks Presence with Zustand" --- In this guide, we’ll be learning how to use Liveblocks Presence with Zustand using the APIs from the [`@liveblocks/zustand`][] package. This guide assumes you already have Liveblocks set up into your Zustand store. If you don’t make sure to follow [these quick steps to get started](/docs/get-started/zustand) first. ## Get other users in the room [#get-others] Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. If you want to list all the people connected to the room, you can use `liveblocks.others` to get an array of the other users in the room. ```tsx import useStore from "./store"; function YourComponent() { useStore((state) => state.liveblocks.others); } ``` ## Update user presence [#update-presence] To create immersive multiplayer experiences, it’s helpful for each person in the room to share their real‑time state with other connected users. That real‑time state often corresponds to a cursor position or even the item a user has currently selected. We call this concept “Presence”. For instance, to share the cursor’s position in real‑time with others, we’re going to add a new `presenceMapping` option to our `liveblocks` middleware configuration to specify which part of the state maps to the current user’s presence. In this case, we’re updating the `cursor` position in our store in the `onPointerMove` event listener in our React component. ```ts file="src/store.ts" highlight="6,9-10,20-21,25-27" import create from "zustand"; import { createClient } from "@liveblocks/client"; import { liveblocks } from "@liveblocks/zustand"; import type { WithLiveblocks } from "@liveblocks/zustand"; type Cursor = { x: number; y: number }; type State = { cursor: Cursor; setCursor: (cursor: Cursor) => void; }; const client = createClient({ publicApiKey: "{{PUBLIC_KEY}}", }); const useStore = create>()( liveblocks( (set) => ({ cursor: { x: 0, y: 0 }, setCursor: (cursor) => set({ cursor }), }), { client, presenceMapping: { cursor: true, }, } ) ); export default useStore; ``` ```tsx file="src/App.tsx" highlight="18-24" import React, { useEffect } from "react"; import useStore from "./store"; import "./App.css"; const App = () => { const { liveblocks: { enterRoom, leaveRoom }, } = useStore(); useEffect(() => { enterRoom("room-id"); return () => { leaveRoom("room-id"); }; }, [enterRoom, leaveRoom]); const setCursor = useStore((state) => state.setCursor); return (
setCursor({ x: e.clientX, y: e.clientY })} /> ); }; export default App; ``` ## Get other users’ presence [#get-others-presence] Get people’s cursor positions with `liveblocks.others.map(user => user.presence.cursor)`. ```tsx file="src/App.tsx" function App() { /* ... */ const others = useStore((state) => state.liveblocks.others); const othersCursors = others.map((user) => user.presence.cursor); // Render cursors with custom SVGs based on x and y } ``` [`room.history.undo`]: /docs/api-reference/liveblocks-client#Room.history.undo [`room.history.redo`]: /docs/api-reference/liveblocks-client#Room.history.redo [`room.history.pause`]: /docs/api-reference/liveblocks-client#Room.history.pause [`room.history.resume`]: /docs/api-reference/liveblocks-client#Room.history.resume [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`createclient`]: /docs/api-reference/liveblocks-client#createClient [api reference]: /docs/api-reference/liveblocks-zustand [authentication]: /docs/authentication --- meta: title: "How to use Liveblocks Storage with React" description: "Learn how to use Liveblocks Storage with React" --- In this guide, we’ll be learning how to use Liveblocks Storage with React using the hooks from the [`@liveblocks/react`][] package. This guide uses [TypeScript](https://www.typescriptlang.org/). Liveblocks can definitely be used without TypeScript. We believe typings are helpful to make collaborative apps more robust, but if you’d prefer to skip the TypeScript syntax, feel free to write your code in JavaScript This guide assumes you already have Liveblocks set up into your React application. If you don’t make sure to follow [these quick steps to get started](/docs/get-started/react) first. ## Sync and persist the state across client [#storage-intro] Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. Some collaborative features require a single shared state between all users—an example of this would be a [collaborative design tool](/examples/browse/whiteboard), with each shape having its own state, or a form with shared inputs. In Liveblocks, this is where `storage` comes in. Room storage automatically updates for every user on changes, and unlike presence, persists after users disconnect. ### Storage types Our storage uses special data structures (inspired by [CRDTs](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type)) to resolve all conflicts, meaning that state is always accurate. There are [multiple storage types](https://liveblocks.io/docs/api-reference/liveblocks-client#Storage) available: - [`LiveObject`][] - Similar to a JavaScript object. - [`LiveList`][] - An array-like ordered collection of items. - [`LiveMap`][] - Similar to a JavaScript Map. ### Defining initial storage [#initial-storage] To use storage, first define a type named `Storage` in `liveblocks.config.ts`. In this example we’ll define a [`LiveObject`][] called `scientist`, containing first and last name properties. ```tsx file="liveblocks.config.ts" highlight="1,4-6,11" import { LiveObject } from "@liveblocks/client"; declare global { interface Liveblocks { Storage: { scientist: LiveObject<{ firstName: string; lastName: string }> }; } } ``` Then, define the initial structure within [`RoomProvider`][]. ```tsx file="index.ts" import { LiveObject } from "@liveblocks/client"; import { RoomProvider } from "@liveblocks/react/suspense"; /* App */ function Index() { return ( ); } ``` ### Using storage Once the default structure is defined, we can then make use of our storage. The [`useStorage`][] hook allows us to access an immutable version of our storage using a selector function. ```tsx highlight="4,12,13" import { useStorage } from "@liveblocks/react"; function App() { const scientist = useStorage((root) => root.scientist); if (scientist == null) { return
Loading...
; } return ( <> ); } ``` The two input values will now automatically update in a realtime as `firstName` and `lastName` are modified by other users. `useStorage` returns `null` during the initial loading because the storage is loaded from the server. It can quickly become cumbersome to handle `null` whenever we use `useStorage`, but we have some good new for you; `@liveblocks/react` contains a [`Suspense`](https://beta.reactjs.org/reference/react/Suspense) version of all of our hooks. ### Updating storage The best way to update storage is through mutations. The [`useMutation`][] hook allows you to create reusable callback functions that modify Liveblocks state. For example, let’s create a mutation that can modify the scientist’s name. Inside this mutation we’re accessing the storage root, a [`LiveObject`][] like `scientist`, and retrieving a mutable copy of `scientist` with [`LiveObject.get`]. From there, we can set the updated name using [`LiveObject.set`]. ```tsx // Define mutation const updateName = useMutation(({ storage }, nameType, newName) => { const mutableScientist = storage.get("scientist"); mutableScientist.set(nameType, newName); }, []); ``` We can then call this mutation, and pass `nameType` and `newName` arguments. ```tsx updateName("firstName", "Albert"); ``` If we take a look at this in the context of a component, we can see how to combine [`useStorage`][] to display the names, and [`useMutation`][] to modify them. Note that `useMutation` takes a dependency array, and works similarly to `useCallback`. ```tsx file="index.ts" import { useStorage, useMutation } from "@liveblocks/react"; function YourComponent() { const scientist = useStorage((root) => root.scientist); if (scientist == null) { return
Loading...
; } const updateName = useMutation(({ storage }, nameType, newName) => { const mutableScientist = storage.get("scientist"); mutableScientist.set(nameType, newName); }, []); return ( <> updateName("firstName", e.target.value)} /> updateName("lastName", e.target.value)} /> ); } ``` All changes made within `useMutation` are automatically batched and sent to the Liveblocks together. `useMutation` can also be used to retrieve and modify presence too, giving you access to multiple parameters, not just `storage`. ```tsx useMutation({ storage, self, others, setMyPresence }); ``` Find more information in the [Mutations](/docs/api-reference/liveblocks-react#useMutation) section of our documentation. ### Nested data structures With Liveblocks storage, it’s possible to nest data structures inside each other, for example `scientist` could hold a [`LiveList`][] containing a list of pets. ```tsx highlight="3" initialStorage={{ scientist: new LiveObject({ pets: new LiveList(["🐶", "🐱", "🐷"]), firstName: "Marie", lastName: "Curie", }) }} ``` Because the `useStorage` selector converts your data structure into a normal immutable JavaScript structure (made from objects, arrays, maps), `pets` can be accessed directly with `useStorage`. ```tsx // ["🐶", "🐱", "🐷"] const pets = useStorage((root) => root.scientist.pets); ``` You can even reach into a `LiveObject` or `LiveList` and extract a property. ```tsx // "Marie" const firstName = useStorage((root) => root.scientist.firstName); // "🐶" const firstPet = useStorage((root) => root.scientist.pets[0]); ``` ### Improving storage performance `useStorage` is highly efficient and only triggers a rerender when the value returned from the selector changes. For example, the following selectors will only trigger rerenders when their respective values change, and are unaffected by any other storage updates. ```tsx // ✅ Rerenders only when root.scientist.firstName changes const firstName = useStorage((root) => root.scientist.firstName); // ✅ Rerenders only when root.scientist changes const scientist = useStorage((root) => root.scientist); ``` However, selector functions must return a stable result to be efficient—if a new object is created within the selector function, it will rerender on every storage change. ```tsx // ❌ Rerenders on every change because `map` returns a new array every time const pets = useStorage((root) => root.scientist.pets.map((pet) => pet + pet)); ``` To account for this, we can pass a `shallow` equality check function, provided by `@liveblocks/react`: ```tsx highlight="1,6" import { shallow } from "@liveblocks/react"; // ✅ Rerenders only when root.scientist.pets shallowly changes const pets = useStorage( (root) => root.scientist.pets.map((pet) => pet + pet), shallow ); ``` Find more information in the [How selectors work](/docs/api-reference/liveblocks-react#selectors) section of our documentation. ### Using Suspense If you’d like to use `Suspense` in your application, make sure to re-export our hooks from `"@liveblocks/react/suspense"`. And then put a `Suspense` component right below the `RoomProvider`. This version of `useStorage` never returns `null`, the loading fallback will be handled by `Suspense` `fallback`. ```tsx file="index.ts" highlight="2,28-30" import { LiveObject } from "@liveblocks/client"; import { Suspense } from "react"; import { RoomProvider, useStorage } from "@liveblocks/react/suspense"; function App() { const scientist = useStorage((root) => root.scientist); return ( <> ); } function Index() { return ( Loading
}> ); } ``` If you’re using a framework that supports Server Side Rendering like [Next.js](https://nextjs.org/), you cannot use `Suspense` directly like this. Liveblocks does not load the storage on the server by default, so the components using `useStorage` will never be able to render. To keep the benefits from `Suspense`, you should use `ClientSideSuspense` from ` @liveblocks/react` instead of the normal `Suspense` from React like this: ```tsx file="index.ts" highlight="1,12-14" import { ClientSideSuspense } from "@liveblocks/react/suspense"; // ... function Index() { return ( Loading...
}> {() => } ); } ``` [`@liveblocks/client`]: /docs/api-reference/liveblocks-client [`@liveblocks/react`]: /docs/api-reference/liveblocks-react [`createroomcontext`]: /docs/api-reference/liveblocks-react#createRoomContext [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`livelist.push`]: /docs/api-reference/liveblocks-client#LiveList.push [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`liveobject.get`]: /docs/api-reference/liveblocks-client#LiveObject.get [`liveobject.set`]: /docs/api-reference/liveblocks-client#LiveObject.set [`room.history.pause`]: /docs/api-reference/liveblocks-client#Room.history.pause [`room.history.resume`]: /docs/api-reference/liveblocks-client#Room.history.resume [`room.subscribe`]: /docs/api-reference/liveblocks-client#Room.subscribe(storageItem) [`roomprovider`]: /docs/api-reference/liveblocks-react#RoomProvider [`usehistory`]: /docs/api-reference/liveblocks-react#useHistory [`useothers`]: /docs/api-reference/liveblocks-react#useOthers [`useredo`]: /docs/api-reference/liveblocks-react#useRedo [`useundo`]: /docs/api-reference/liveblocks-react#useUndo [`useupdatemypresence`]: /docs/api-reference/liveblocks-react#useUpdateMyPresence [`useconnectionids`]: /docs/api-reference/liveblocks-react#useConnectionIds [`usestorage`]: /docs/api-reference/liveblocks-react#useStorage [`usemutation`]: /docs/api-reference/liveblocks-react#useMutation --- meta: title: "How to use Liveblocks Storage with Redux" description: "Learn how to use Liveblocks Storage with Redux" --- In this guide, we’ll be learning how to use Liveblocks Storage with Redux using the APIs from the [`@liveblocks/redux`][] package. This guide assumes you already have Liveblocks set up into your Redux store. If you don’t make sure to follow [these quick steps to get started](/docs/get-started/redux) first. ## Sync and persist the state across client [#storage-intro] As opposed to `presence`, some collaborative features require that every user interacts with the same piece of state. For example, in Google Docs, it is the paragraphs, headings, images in the document. In Figma, it’s all the shapes that make your design. That’s what we call the room’s `storage`. Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. The room’s storage is a conflicts-free state that multiple users can edit at the same time. It is persisted to our backend even after everyone leaves the room. Liveblocks provides custom data structures inspired by [CRDTs](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type) that can be nested to create the state that you want. - [`LiveObject`][] - Similar to JavaScript object. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. - [`LiveList`][] - An ordered collection of items synchronized across clients. Even if multiple users add/remove/move elements simultaneously, LiveList will solve the conflicts to ensure everyone sees the same collection of items. - [`LiveMap`][] - Similar to a JavaScript Map. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. When using our Redux integration you cannot interact directly with these data structures. Our enhancer synchronizes your store with our data structures based on the `storageMapping` configuration. Here is an example to explain how it works under the hood. Imagine you have the following store: ```js /* ...client setup... */ const initialState = { firstName: "Marie", lastName: "Curie", discoveries: ["Polonium", "Radium"], }; const slice = createSlice({ name: "state", initialState, reducers: { setFirstName: (state, action) => { state.firstName = action.payload; }, setLastName: (state, action) => { state.lastName = action.payload; }, addDiscovery: (state, action) => { state.discoveries.push(action.payload); }, }, }); export const { setScientist } = slice.actions; function makeStore() { return configureStore({ reducer: slice.reducer, enhancers: [ enhancer({ client, storageMapping: { firstName: true, lastName: true, discoveries: true }, }), ], }); } const store = makeStore(); ``` With this setup, the room's `storage` root is : ```js const root = new LiveObject({ firstName: "Marie", lastName: "Curie", discoveries: new LiveList(["Polonium", "Radium"]), }); ``` If you update your store by dispatching `setFirstName("Pierre")`, the enhancer will do `root.set("firstName", "Pierre")` for you and update the store of all the users currently connected to the room. The enhancer compares the previous state and the new state to detect changes and patch our data structures accordingly. The reverse process happens when receiving updates from other clients; the enhancer patches your immutable state. When entering a room with `enterRoom`, the enhancer fetches the room's storage from our server and patches your store. If this is the first time you're entering a room, the storage will be empty. `enterRoom` takes an additional argument to initialize the room's storage. ```js enterRoom("room-id", { firstName: "Lise", lastName: "Meitner", discoveries: ["Nuclear fission", "Protactinium"], }); ``` [`room.history.undo`]: /docs/api-reference/liveblocks-client#Room.history.undo [`room.history.redo`]: /docs/api-reference/liveblocks-client#Room.history.redo [`room.history.pause`]: /docs/api-reference/liveblocks-client#Room.history.pause [`room.history.resume`]: /docs/api-reference/liveblocks-client#Room.history.resume [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`createclient`]: /docs/api-reference/liveblocks-client#createClient [api reference]: /docs/api-reference/liveblocks-redux [authentication]: /docs/authentication --- meta: title: "How to use Liveblocks Storage with Zustand" description: "Learn how to use Liveblocks Storage with Zustand" --- In this guide, we’ll be learning how to use Liveblocks Storage with Zustand using the APIs from the [`@liveblocks/zustand`][] package. This guide assumes you already have Liveblocks set up into your Zustand store. If you don’t make sure to follow [these quick steps to get started](/docs/get-started/zustand) first. ## Sync and persist the state across client [#storage-intro] As opposed to `presence`, some collaborative features require that every user interacts with the same piece of state. For example, in Google Docs, it is the paragraphs, headings, images in the document. In Figma, it’s all the shapes that make your design. That’s what we call the room’s “storage”. Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your collaborative experiences as you build them, in realtime. The room’s storage is a conflicts-free state that multiple users can edit at the same time. It is persisted to our backend even after everyone leaves the room. Liveblocks provides custom data structures inspired by [CRDTs](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type) that can be nested to create the state that you want. - [`LiveObject`][] - Similar to JavaScript object. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. - [`LiveList`][] - An ordered collection of items synchronized across clients. Even if multiple users add/remove/move elements simultaneously, LiveList will solve the conflicts to ensure everyone sees the same collection of items. - [`LiveMap`][] - Similar to a JavaScript Map. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner. When using our Zustand integration you cannot interact directly with these data structures. Our middleware synchronizes your store with our data structures based on the `storageMapping` configuration. Here is an example to explain how it works under the hood. Imagine you have the following store: ```ts file="src/store.ts" highlight="6-8,20-22" /* ...client setup */ const useStore = create>()( liveblocks( (set) => ({ firstName: "Marie", lastName: "Curie", discoveries: ["Polonium", "Radium"], setFirstName: (firstName) => set({ firstName }), setLastName: (lastName) => set({ lastName }), addDiscovery: (discovery) => set((state) => ({ discoveries: state.discoveries.concat([discovery]), })), }), { client, storageMapping: { firstName: true, lastName: true, discoveries: true, }, } ) ); ``` With this setup, the room's `storage` root is: ```ts const root = new LiveObject({ firstName: "Marie", lastName: "Curie", discoveries: new LiveList(["Polonium", "Radium"]), }); ``` If you update your store by calling `setFirstName("Pierre")`, the middleware will do `root.set("firstName", "Pierre")` for you and update the store of all the users currently connected to the room. The middleware compares the previous state and the new state to detect changes and patch our data structures accordingly. The reverse process happens when receiving updates from other clients; the middleware patches your immutable state. When entering a room with `liveblocks.enterRoom`, the middleware fetches the room's storage from our server and patches your store. If this is the first time you're entering a room, the storage will be initialized with the current value in your Zustand state, typically your initial state. [`room.history.undo`]: /docs/api-reference/liveblocks-client#Room.history.undo [`room.history.redo`]: /docs/api-reference/liveblocks-client#Room.history.redo [`room.history.pause`]: /docs/api-reference/liveblocks-client#Room.history.pause [`room.history.resume`]: /docs/api-reference/liveblocks-client#Room.history.resume [`liveobject`]: /docs/api-reference/liveblocks-client#LiveObject [`livemap`]: /docs/api-reference/liveblocks-client#LiveMap [`livelist`]: /docs/api-reference/liveblocks-client#LiveList [`createclient`]: /docs/api-reference/liveblocks-client#createClient [api reference]: /docs/api-reference/liveblocks-zustand [authentication]: /docs/authentication --- meta: title: "How to use Liveblocks with Astro" description: "Learn how to add Liveblocks to your Astro app" --- When adding Liveblocks to [Astro](https://astro.build) apps, it's recommended to use [`client:only`](https://docs.astro.build/en/reference/directives-reference/#clientonly) with your components. This is recommended because Liveblocks only needs on the client, and in Astro, you may run into bundling issues if the app is rendered on both the server and client. ## Example Here's an example of `client:only` used with various frameworks: ```astro --- import SvelteInput from "../components/SvelteInput.svelte"; import ReactInput from "../components/ReactInput.tsx"; import VueInput from "../components/VueInput.vue"; ---
``` ## Not required with .astro components Note that when using `.astro` components and the ` ``` ## Using @liveblocks/react with Astro Note that when using our React package with Astro, `RoomProvider` does not work in nested `.astro` files, as each component is a separate "island", and cannot see the context in another island. Each React root in `.astro` files is like a new React app, and will need its own `RoomProvider`. --- meta: title: "How to use Liveblocks with Next.js /app directory" description: "Learn how to add Liveblocks to your Next.js app directory" --- A pattern we’d recommend when using [Next.js](https://nextjs.org) /app directory is creating a providers client component for [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider) and importing it into `layout.tsx`. ```tsx file="app/Providers.tsx" import { ReactNode } from "react"; import { LiveblocksProvider } from "@liveblocks/react/suspense"; export function Providers({ children }: { children: ReactNode }) { return ( {children} ); } ``` ```tsx file="app/layout.tsx" import { ReactNode } from "react"; import { Providers } from "./Providers"; export default function Layout({ children }: { children: ReactNode }) { return ( {children} ); } ``` To join rooms, create a `Room.tsx` client component in the current route, using `RoomProvider` within here. ```tsx file="app/Room.tsx" "use client"; import { ReactNode } from "react"; import { RoomProvider } from "../liveblocks.config"; import { LiveObject } from "@liveblocks/client"; export default function Room({ children }: { children: ReactNode }) { return ( {children} ); } ``` Doing this avoids an issue when importing `LiveObject/LiveMap/ListList` into server components. You can then use `Room.tsx` in your page component, and everything will work as expected. ```tsx file="app/page.tsx" import { Room } from "./Room"; export default function Page() { return {/* Your Liveblocks app */}; } ``` ## Structuring your app To take this one step further, you can then server-render your layout within `Room`’s `children`, whilst using other client components for realtime parts of your app: ```tsx file="room.tsx" import { ReactNode } from "react"; import { Room } from "./Room"; import { LiveCanvas } from "./LiveCanvas"; export default function Page({ children }: { children: ReactNode }) { return ( // Room.tsx is a client component that contains RoomProvider {/* This layout is server rendered */}
My drawing app
{/* LiveCanvas is a client component using Liveblocks features */}
); } ``` --- meta: title: "How to use Yjs subdocuments" description: "Learn how to Yjs subdocuments on client and server" --- Liveblocks Yjs supports [subdocuments](https://docs.yjs.dev/api/subdocuments), which allow you to nest Yjs documents inside each other. This guide takes you through how to use them on client and server. ## When to use subdocuments Subdocuments are helpful when you have multiple _large_ Yjs documents in the same room, and you wish to lazy-load them individually. Each subdocument works similarly to a normal Yjs document, allowing you to use [shared types](https://docs.yjs.dev/getting-started/working-with-shared-types), [awareness](https://docs.yjs.dev/getting-started/adding-awareness), and more. ### Not necessary for multiple text editors Please note that **subdocuments are not necessary for displaying multiple text editors** on one page. For this use case, it’s often best to create a [`Y.Map`](https://docs.yjs.dev/api/shared-types/y.map) in your Yjs document, and place the contents of each editor inside. For example, if your text editor uses [`Y.XmlFragment`](https://docs.yjs.dev/api/shared-types/y.xmlfragment), here’s how to create this. ```tsx // Create Yjs document with an `editors` map const yDoc = new Y.Doc(); const yMap = yDoc.getMap("editors"); // Create shared types and add to map const editorOne = new Y.XMLFragment(); const editorTwo = new Y.XMLFragment(); yMap.set("editor-1", editorOne); yMap.set("editor-2", editorTwo); // Pass `editorOne` and `editorTwo` to your text editors // ... ``` This is much simpler than using subdocuments. However, if the content of these editors is very large, or if your text editor only accepts a `Y.Doc`, subdocuments may be for you. ## On the client Subdocuments can be stored in your Yjs tree like any other shared type. In this example we’ll create a [`Y.Map`](https://docs.yjs.dev/api/shared-types/y.map) to store them in, making sure to lazy load any subdocuments. ```ts import { LiveblocksYjsProvider } from "@liveblocks/yjs"; import * as Y from "yjs"; // Create main document and connect, disabling auto-loading of subdocuments const yDoc = new Y.Doc(); const yProvider = new LiveblocksYjsProvider(room, doc, { autoloadSubdocs: false, }); // Create a Y.Map to hold subdocuments const subdocMap = yDoc.getMap("subdocs"); ``` ### Create a subdocument [#create-a-subdocument] To create a new subdocument, create a `new Y.Doc()`, and use this it any other. ```ts highlight="8-9,11-14,16-17" import { LiveblocksYjsProvider } from "@liveblocks/yjs"; import * as Y from "yjs"; // Create main document and connect, disabling auto-loading of subdocuments const yDoc = new Y.Doc(); const yProvider = new LiveblocksYjsProvider(room, doc, { autoloadSubdocs: false, }); // Create a Y.Map to hold subdocuments const subdocMap = yDoc.getMap("subdocs"); // Create subdocument const subdoc = new Y.Doc(); yDoc.getMap().set("my-document", subdoc); subdoc.getText("default").insert(0, "This is a subdocument"); // Make note of its `guid`, which is used for retrieving it later const guid = subdoc.guid; // e.g. "c4a755..." ``` Make sure to keep track of its `guid`. ### Load the subdocument To load the subdocument on _another client_, use its `guid`. ```ts highlight="8-9" import { LiveblocksYjsProvider } from "@liveblocks/yjs"; import * as Y from "yjs"; // Create main document and connect, disabling auto-loading of subdocuments const yDoc = new Y.Doc(); const yProvider = new LiveblocksYjsProvider(room, doc, { autoloadSubdocs: false, }); // From another client, load the subdoc using the GUID from `subdoc.guid` or `doc.getSubdocGuids` yProvider.loadSubdoc("c4a755..."); // Alternatively, get a reference to a subdocument from `doc.getSubdocs()` and then load // subdoc.load(); ``` ### Listening for changes To keep track of subdocument changes, you can use `Y.Doc.on("subdocs")`. ```ts highlight="10-13" import { LiveblocksYjsProvider } from "@liveblocks/yjs"; import * as Y from "yjs"; // Create main document and connect, disabling auto-loading of subdocuments const yDoc = new Y.Doc(); const yProvider = new LiveblocksYjsProvider(room, yDoc, { autoloadSubdocs: false, }); yDoc.on("subdocs", ({ added, removed, loaded }) => { // Subdocument change // ... }); ``` ## On the server It’s possible to use your subdocument on the server, without connecting with a provider. ### Fetching a subdocument When fetching a Yjs subdocument on the server, it’s recommended to use [`liveblocks.getYjsDocumentAsBinaryUpdate`](/docs/api-reference/liveblocks-node#get-rooms-roomId-ydoc-binary) with the `guid` of your subdocument. We stored the `guid` when we [created the subdocument](#create-a-subdocument). ```ts highlight="9-13,15-17" import { Liveblocks } from "@liveblocks/node"; import * as Y from "yjs"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export function POST() { // Get your Yjs subdocument as a binary update const update = liveblocks.getYjsDocumentAsBinaryUpdate("my-room-id", { // The `guid` of your subdocument, as noted earlier guid: "c4a755...", }); // Create a Yjs document for your subdoc and apply the update const subdoc = new Y.Doc(); Y.applyUpdate(subdoc, new Uint8Array(update)); // `subdoc` can now be read // ... } ``` We’ve now retrieved the subdocument, and it can be read, but any changes you make won’t be applied to other clients, and are only temporary. ### Updating a subdocument To permanently apply changes to your subdocument, sending them to Liveblocks and other clients, you can use [`liveblocks.sendYjsBinaryUpdate`](/docs/api-reference/liveblocks-node#put-rooms-roomId-ydoc). ```ts highlight="19-20,22-23,25-29" import { Liveblocks } from "@liveblocks/node"; import * as Y from "yjs"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export function POST() { // Get your Yjs subdocument as a binary update const update = liveblocks.getYjsDocumentAsBinaryUpdate("my-room-id", { // The `guid` of your subdocument, as noted earlier guid: "c4a755...", }); // Create a Yjs document for your subdoc and apply the update const subdoc = new Y.Doc(); Y.applyUpdate(subdoc, new Uint8Array(update)); // Make changes to your `subdoc`, for example subdoc.getText("my-text").insert(0, "Hello world"); // Convert `subdoc` into a binary update const subdocChanges = Y.encodeStateAsUpdate(subdoc); // Send the changes to Liveblocks, and other clients liveblocks.sendYjsBinaryUpdate("my-room-id", subdocChanges, { // The `guid` of your subdocument, as noted earlier guid: "c4a755...", }); } ``` After running this code, all connected users will see the update. --- meta: title: "How to use your Y.Doc on the server" description: "Learn how to retrieve your Yjs document’s Y.Doc on the server" --- Using [`@liveblocks/node`](/docs/api-reference/liveblocks-node), it’s possible to retrieve your Yjs document and use it as a [`Y.Doc`](https://docs.yjs.dev/api/y.doc) on the server. This is often helpful for retrieving text editor state, and we have some specific guides for this: - [Getting Tiptap state on the server](/docs/guides/getting-tiptap-state-on-the-server). - [Getting ProseMirror state on the server](/docs/guides/getting-prosemirror-state-on-the-server). ## Getting your Y.Doc Using [`Liveblocks.getYjsDocumentAsBinaryUpdate`](/docs/api-reference/liveblocks-node#get-rooms-roomId-ydoc-binary) you can fetch your Yjs data, and place it inside a `Y.Doc`. ```ts import * as Y from "yjs"; import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST() { // Get your Yjs data as a binary update const update = await liveblocks.getYjsDocumentAsBinaryUpdate("my-room-name"); // Create a Yjs document const yDoc = new Y.Doc(); // Apply the binary update to `yDoc` Y.applyUpdate(yDoc, new Uint8Array(update)); // `yDoc` can now be used as you like // ... } ``` Note that any changes you make will not be applied to other users, as the `Y.Doc` is not connected to any providers. ## Applying changes Should you wish to send any changes to your document to other users, you can encode `yDoc` as a binary update, and use [`Liveblocks.sendYjsBinaryUpdate`](/docs/api-reference/liveblocks-node#put-rooms-roomId-ydoc) to apply the change. ```ts highlight="18-20,22-23,25-26" import * as Y from "yjs"; import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); export async function POST() { // Get your Yjs data as a binary update const update = await liveblocks.getYjsDocumentAsBinaryUpdate("my-room-name"); // Create a Yjs document const yDoc = new Y.Doc(); // Apply the binary update to `yDoc` Y.applyUpdate(yDoc, new Uint8Array(update)); // An example of a `yDoc` modification const yText = yDoc.getText("text"); yText.insert(0, "Hello world"); // Encode the document state as an update const yUpdate = Y.encodeStateAsUpdate(yDoc); // Send the update to Liveblocks await liveblocks.sendYjsBinaryUpdate(roomId, yUpdate); } ``` These changes will be immediately applied to all connected users. --- meta: title: "Modifying Yjs document data with the REST API" description: "Learn how to update your Yjs document using the Liveblocks REST API" --- Liveblocks allows you to update your Yjs document data, or `yDoc`, from the REST API, helpful for sending updates from the server. This is made possible through [`Liveblocks.sendYjsBinaryUpdate`](/docs/api-reference/liveblocks-node#put-rooms-roomId-ydoc) in [`@liveblocks/node`](/docs/api-reference/liveblocks-node). ## Updating a Yjs document Updating a Yjs document requires you to create a [binary update](https://docs.yjs.dev/api/document-updates), before sending it to Liveblocks using [`Liveblocks.sendYjsBinaryUpdate`](/docs/api-reference/liveblocks-node#put-rooms-roomId-ydoc). Here’s an example in a serverless endpoint. ```ts import * as Y from "yjs"; import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const roomId = "my-room-name"; export async function POST() { // Create a Yjs document const yDoc = new Y.Doc(); // Create your data structures and make your update // Each editor is different, you probably need to change these two lines const yText = yDoc.getText("text"); yText.insert(0, "Hello world"); // Encode the document state as an update const yUpdate = Y.encodeStateAsUpdate(yDoc); // Insert the update await liveblocks.sendYjsBinaryUpdate(roomId, yUpdate); } ``` Note that if you’re using a text editor, [each one works differently](#each-editor-works-differently), so you’ll most likely need to modify these two lines to use a format your editor defines. ```ts const yText = yDoc.getText("text"); yText.insert(0, "Hello world"); ``` ## Initializing a Yjs document It’s also possible to create a new room with an initial Yjs document. To do this, call [`Liveblocks.createRoom`](/docs/api-reference/liveblocks-node#post-rooms), then send the update as before. ```ts highlight="21-24" import * as Y from "yjs"; import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const roomId = "my-room-name"; export async function POST() { // Create a Yjs document const yDoc = new Y.Doc(); // Create your data structures and make your update // Each editor is different, you probably need to change these two lines const yText = yDoc.getText("text"); yText.insert(0, "Hello world"); // Encode the document state as an update const yUpdate = Y.encodeStateAsUpdate(yDoc); // Create the new room const room = await liveblocks.createRoom(roomId, { defaultAccesses: ["room:write"], }); // Initialize the Yjs document with the update await liveblocks.sendYjsBinaryUpdate(roomId, yUpdate); } ``` ## Each editor works differently [#each-editor-works-differently] Note that each text and code editor may work differently, and may include specific functions for creating binary updates, or use different shared types. Slate and Tiptap use [`Y.XmlFragment`](https://docs.yjs.dev/api/shared-types/y.xmlfragment) instead of [`Y.Text`](https://docs.yjs.dev/api/shared-types/y.text). ### Slate This is how to initialize a [Slate](/docs/get-started/yjs-slate-react) document. ```ts highlight="3,14-18,20-22" import * as Y from "yjs"; import { Liveblocks } from "@liveblocks/node"; import { slateNodesToInsertDelta } from "@slate-yjs/core"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const roomId = "my-room-name"; export async function POST() { // Create a Yjs document const yDoc = new Y.Doc(); // The Slate document we're creating const slateDoc = { type: "paragraph", children: [{ text: "Hello world" }], }; // Create your data structures and make your update const insertDelta = slateNodesToInsertDelta(slateDoc); (yDoc.get("content", Y.XmlText) as Y.XmlText).applyDelta(insertDelta); // Encode the document state as an update const yUpdate = Y.encodeStateAsUpdate(yDoc); // Create the new room const room = await liveblocks.createRoom(roomId, { defaultAccesses: ["room:write"], }); // Initialize the Yjs document with the update await liveblocks.sendYjsBinaryUpdate(roomId, yUpdate); } ``` ### Tiptap This is how to initialize a [Tiptap](https://tiptap.dev/docs/editor/api/extensions/collaboration) document. ```ts highlight="13-15,17-19" import * as Y from "yjs"; import { Liveblocks } from "@liveblocks/node"; const liveblocks = new Liveblocks({ secret: "{{SECRET_KEY}}", }); const roomId = "my-room-name"; export async function POST() { // Create a Yjs document const yDoc = new Y.Doc(); // The Tiptap Yjs state we're creating const yXmlElement = new Y.XmlElement("paragraph"); yXmlElement.insert(0, [new Y.XmlText("Hello world")]); // Create your data structures and make your update const yXmlFragment = yDoc.getXmlFragment("default"); yXmlFragment.insert(0, [yXmlElement]); // Encode the document state as an update message const yUpdate = Y.encodeStateAsUpdate(yDoc); // Create the new room const room = await liveblocks.createRoom(roomId, { defaultAccesses: ["room:write"], }); // Initialize the Yjs document with the update await liveblocks.sendYjsBinaryUpdate(roomId, yUpdate); } ``` --- meta: title: "Reauthenticate without reloading the page or losing state" description: "Learn how to reauthenticate the current room without refreshing the page" --- Sometimes it’s helpful to [reauthenticate](/docs/authentication) users, for example after a logged out user has signed into your application, and you wish to display their details. ## How to reauthenticate By calling [`room.reconnect`](/docs/api-reference/liveblocks-client#Room.reconnect) you can reconnect and reauthenticate your collaborative application without refreshing the page or unmounting the [`RoomProvider`](/docs/api-reference/liveblocks-react#RoomProvider) component. Using this method will preserve the current client’s state, such as the undo/redo history. ```ts highlight="5" const { room, leave } = client.enterRoom("my-room", { // ... }); room.reconnect(); ``` ### In React In our React package, you can retrieve the current room with the [`useRoom`](/docs/api-reference/liveblocks-react#useRoom) hook, before calling `room.reconnect` from there. ```tsx highlight="4,6" import { useRoom } from "../liveblocks.config"; export function App() { const room = useRoom(); return ; } ``` --- meta: title: "Revalidate API data in realtime with SWR" description: "Learn how use Liveblocks and SWR to revalidate API data in realtime" --- [SWR](https://swr.vercel.app) is a library that provides [React](https://react.dev) hooks for data fetching. It’s possible to revalidate your data in realtime by broadcasting events using Liveblocks. An example usage of this may be a share dialog containing a list of users—when a new user is added to the dialog, we can broadcast an event telling other online users to refresh their user list. ## Broadcasting events A simple SWR hook that fetches a list of users may look similar to this: ```tsx function Component() { const { data: users, mutate } = useSWR("/api/users", fetcher); return (
{users.map((user) => /* ... */)}:
); } ``` To create a function that allows us to update this data in realtime, we can broadcast an event telling other clients to revalidate their data with [useBroadcastEvent](https://liveblocks.io/docs/api-reference/liveblocks-react#useBroadcastEvent): ```tsx const broadcast = useBroadcastEvent(); // Sending a custom REVALIDATE event broadcast({ type: "REVALIDATE" }); ``` We can then listen for the event with [useEventListener](https://liveblocks.io/docs/api-reference/liveblocks-react#useEventListener), and call the mutate function from SWR to update the data: ```tsx const { data: users, mutate } = useSWR("/api/users", fetcher); useEventListener(({ event }) => { if (event.type === "REVALIDATE") { mutate(); } }); ``` ## Putting it together If we put everything together, we can display a list of users, broadcasting a revalidate event when a new user is added to the list. ```tsx import { useBroadcastevent, useEventListener } from "../liveblocks.config"; function Component() { // Data updates on every button click const { data, mutate } = useSWR("/api/user", fetcher); // Listen for custom event useEventListener(({ event }) => { if (event.type === "REVALIDATE") { mutate(); } }); // Create broadcast hook const broadcast = useBroadcastEvent(); function addUser() { // Code to add a new user to your list // ... // Broadcast the custom event broadcast({ type: "REVALIDATE" }); } return (
{users.map((user) => /* ... */)}:
); } ``` Great, data that revalidates in realtime at the click of a button! You can find an example of this technique being used in the [Next.js Starter Kit](https://github.com/liveblocks/liveblocks/blob/main/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialog.tsx#L123-L131). --- meta: title: "Setting an initial or default value in BlockNote" description: "Learn how to set an initial value to display when the document is empty" --- Yjs doesn’t allow you to set an initial value for a document, as documents are stored as a list of changes, rather than as the current state. If you were to try add a default value, this would instead be sent as an append command, meaning that it would be added to any existing data in the document, instead of working as a default value. ## Setting content in BlockNote BlockNote allows you to set a default value by setting `initialContent` in `useCreateBlockNote`, however when connected to Yjs this will trigger the duplication problem. ```tsx function Editor({ doc, provider }: EditorProps) { const editor: BlockNoteEditor = useCreateBlockNote({ // +++ initialContent: [{ type: "paragraph", content: "Hello world" }], // +++ // Other options // ... }); return ; } ``` ### Default value with Yjs To avoid this problem, you can instead wait for Liveblocks Yjs to connect, check if the editor’s content is empty, and _then_ set a default value. ```tsx function Editor({ doc, provider }: EditorProps) { const editor = useEditor({ // Options // ... }); // +++ // Set default state useEffect(() => { function setDefault() { if (!editor) { return; } if (editor.document.length === 1) { editor.insertBlocks( [{ type: "paragraph", content: "Hello world" }], editor.document[0] ); } } if (provider.isReady) { setDefault(); } provider.on("sync", setDefault); return () => provider.off("sync", setDefault); }, [provider, editor]); // +++ return ; } ``` Note that we’re assuming your BlockNote application is set up as recommended in our [getting started guide](/docs/get-started/yjs-blocknote-react). --- meta: title: "Setting an initial or default value in Tiptap" description: "Learn how to set an initial value to display when the document is empty" --- Yjs doesn’t allow you to set an initial value for a document, as documents are stored as a list of changes, rather than as the current state. If you were to try add a default value, this would instead be sent as an append command, meaning that it would be added to any existing data in the document, instead of working as a default value. If you’re using our [Text Editor](/docs/ready-made-features/text-editor) Tiptap product, there’s an [option to set the initial value](/docs/api-reference/liveblocks-react-tiptap#Setting-initial-content), and you should use this method. This guide is only [Sync Datastore and Yjs](/docs/platform/sync-datastore). ## Setting content in Tiptap Tiptap allows you to set a default value by setting `content` in `useEditor`, however when connected to Yjs this will trigger the duplication problem. ```tsx highlight="3" function Editor({ doc, provider }: EditorProps) { const editor = useEditor({ content: "

This will duplicate on load

", // Options // ... }); return ; } ``` ### Default value with Yjs To avoid this problem, you can instead wait for Liveblocks Yjs to connect, check if the editor’s content is empty, and _then_ set a default value. ```tsx highlight="7-27" function Editor({ doc, provider }: EditorProps) { const editor = useEditor({ // Options // ... }); // Set default state useEffect(() => { function setDefault() { if (!editor) { return; } if (editor.getText() === "") { editor.commands.setContent(`

Default content

My paragraph

`); } } setDefault(); provider.on("sync", setDefault); return () => provider.off("sync", setDefault); }, [provider, editor]); return ; } ``` Note that we’re assuming your Tiptap application is set up as recommended in our [getting started guide](/docs/get-started/yjs-tiptap-react). --- meta: title: "What happens when a user joins a room at maximum capacity?" description: "Learn what happens when a room reaches the maximum simultaneous connections per room" --- Liveblocks allows you to gracefully handle maximum user limits in rooms. But first, when is a room at maximum capacity? - A room is full when it’s hit your maximum simultaneous connections per room limit. - Your maximum simultaneous connections limit is defined by your current [plan](/pricing). - Any users above that count will not be able to join the room. - Any users already in the room will not be affected by another user trying to join. - If a user can’t join a room, they will not be counted towards your MAUs. ## Example For example, let’s say your plan allows for 50 simultaneous connections. If there’s a room that currently has 50 users inside, Marie (the 51st user) will not be able to join the room. If Marie tries to join, the first 50 users in the room will be unaffected, and the room will function as normal. However, Marie’s client will receive an error, which can be handled. ## Handling users that are over the count No JavaScript `Error` is thrown when a user tries to join a room that’s full, instead you can listen for error events, which are helpful for displaying a warning or redirecting the user elsewhere. ### In React With our [`@liveblocks/react`](https://liveblocks.io/docs/api-reference/liveblocks-react) package, you can listen for error events with [`useErrorListener`](/docs/api-reference/liveblocks-react#useErrorListener). When `error.code === 4005`, that means the room was full when the user tried to join. ```tsx highlight="15-17" import { useErrorListener } from "../liveblocks.config"; function App() { // Listen for errors useErrorListener((error) => { switch (error.code) { case -1: // Authentication error break; case 4001: // Could not connect because you don't have access to this room break; case 4005: // Could not connect because room was full break; default: // Unexpected error break; } }); // ... } ``` ### In JavaScript With our [`@liveblocks/client`](/docs/api-reference/liveblocks-client) package, you can listen for error events with [`room.subscribe("error")`](/docs/api-reference/liveblocks-client#Room.subscribe.error). When `error.code === 4005`, that means the room was full when the user tried to join. ```ts highlight="17-19" // No error is thrown when the room is full const { room, leave } = client.enterRoom("my-room", { /* ... */ }); // Listen for errors const unsubscribe = room.subscribe("error", (error) => { switch (error.code) { case -1: // Authentication error break; case 4001: // Could not connect because you don't have access to this room break; case 4005: // Could not connect because room was full break; default: // Unexpected error break; } }); ``` --- meta: title: "What to check before enabling a new notification kind" description: "Checklist for changing notification kinds in the dashboard" --- When publishing changes to your notification settings in the dashboard, you should make sure your app is ready to handle any webhooks changes. This is not a problem when _disabling_ a notification kind, but when you _enable_ a new notification kind you should check that your app is ready to receive these new notifications. ## Enabling in the dashboard When in the notifications settings dashboard, you can enable and disable various webhook events for different kinds. For example, below we’ve toggled a custom notification `kind`.
Toggle a custom notification kind
Before publishing this change, it’s important to understand what will occur, and to modify your app. ## What happens when you enable a notification kind After enabling and publishing a notification kind, a new webhook event will be sent for that `kind` on the channel you selected. Below is an example of an API endpoint set up for a Liveblocks webhook—you can see the new event that will be received if you were to enable a custom notification sent on the email channel. If you don’t recognise this code, you should read one of our guides on setting up notification kinds with webhooks, where everything is explained: [thread guide](/docs/guides/how-to-send-email-notifications-of-unread-comments) and [textMention guide](/docs/guides/how-to-send-email-notifications-for-unread-text-editor-mentions). ```ts import { WebhookHandler } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } if (event.type !== "notification") { return new Response("This is not a notification webhook", { status: 400 }); } // { // type: "notification", // data: { // +++ // channel: "email", // kind: "$myCustomNotification", // +++ // projectId: "my-project-id", // roomId: "my-room-id", // userId: "my-user-id", // inboxNotificationId: "in_xt3p7ak...", // createdAt: "2021-10-06T01:45:56.558Z", // }, // } console.log(event); return new Response(null, { status: 200 }); } ``` As you can see above, the two highlighted lines are the fields for the new event, and you’ll need to handle them. ## Before publishing the change Before publishing your notification settings change, you’ll most likely wish to check for `channel` and `kind` in your webhook endpoint, and handle it accordingly. ```ts import { WebhookHandler } from "@liveblocks/node"; // Add your webhook secret key from a project's webhooks dashboard const WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_KEY"; const webhookHandler = new WebhookHandler(WEBHOOK_SECRET); export async function POST(request: Request) { const body = await request.json(); const headers = request.headers; // Verify if this is a real webhook request let event; try { event = webhookHandler.verifyRequest({ headers: headers, rawBody: JSON.stringify(body), }); } catch (err) { console.error(err); return new Response("Could not verify webhook call", { status: 400 }); } if (event.type !== "notification") { return new Response("This is not a notification webhook", { status: 400 }); } // +++ if ( event.data.channel === "email" && event.data.kind === "$myCustomNotification" ) { // Send an email to your user for this custom notification // ... return new Response(null, { status: 200 }); } // +++ return new Response(null, { status: 200 }); } ``` If you don’t handle this, you may find yourself running into problems, depending on the way you’ve written the logic in your app. ## Safe to publish After changing your webhook endpoint, it’s safe for you to go back to the notification settings page, and publish your changes.
Enable a notification kind
Events for the new notification kind will now be called in your app. ## Users can change their preferences Each user in your app can set their own preferences for notifications, and after enabling a notification kind, each user’s will be set to the default value.
Default notification settings
You can use [`useUpdateNotificationSettings`](/docs/api-reference/liveblocks-react#useUpdateNotificationSettings) to view and set each user’s individual values, making it easy to create notification setting panels. Below, we’re allowing users to toggle `thread` notifications on the `email` channel. ```tsx import { useNotificationSettings } from "@liveblocks/react"; function NotificationSettings() { // +++ const [{ isLoading, error, settings }, updateSettings] = useNotificationSettings(); // +++ if (isLoading || error) { return null; } return (
updateSettings({ email: { thread: e.target.checked } }) } // +++ id="setting-email-thread" />
); } ``` You can use this hook to create a full notification panel for each user. If you’re not on React, you can use [JavaScript](/docs/api-reference/liveblocks-client#Client.getNotificationSettings) or [Node.js](/docs/api-reference/liveblocks-node#get-users-userId-notification-settings) functions instead.