# ✅ After
https://api.liveblocks.io/v2/rooms/:roomId/users/:userId/subscription-settings
```

That’s it for 2.24!

[`@liveblocks/client`]: /docs/api-reference/liveblocks-client
[`@liveblocks/react`]: /docs/api-reference/liveblocks-react
[`@liveblocks/node`]: /docs/api-reference/liveblocks-node

---
meta:
  title: "Upgrading to 2.8"
  parentTitle: "Upgrading"
  description: "Guide to upgrade to Liveblocks version 2.8"
---

We are introducing attachments to allow users to add files to their comments.

## How to upgrade? [#how]

You can upgrade to 2.8 by downloading the latest version of each Liveblocks
package you’re using. The easiest way to do that is to run the following
command:

```bash
npx create-liveblocks-app@latest --upgrade
```

## All changes are for Comments

If you are not using Comments or are not using
[the default Comments components](/docs/ready-made-features/comments/default-components),
there are no breaking changes for you! However, if you are, or intend to use
them later, keep reading.

## Attachments

Liveblocks 2.8 adds attachments to Comments, and while not a breaking change, it
is mostly enabled by default in the default components. We recommend you either
[enable attachments](#enable-attachments) or
[disable attachments](#disable-attachments) when you upgrade.

### Enable attachments [#enable-attachments]

Attachments are enabled by default, but if you’re using
[`useCreateThread`](/docs/api-reference/liveblocks-react#useCreateThread),
[`useCreateComment`](/docs/api-reference/liveblocks-react#useCreateComment), or
[`useEditComment`](/docs/api-reference/liveblocks-react#useEditComment), you can
now pass these an attachments array.

```tsx
const createThread = useCreateThread();

// ❌ Before - Liveblocks 2.7
createThread({ body: {}, metadata: {} });

// ✅ After - Liveblocks 2.8
createThread({ body: {}, attachments: [], metadata: {} });
```

These hooks are most commonly used to enable
[custom Composer behavior](/docs/api-reference/liveblocks-react-ui#Custom-behavior)
with `onComposerSubmit`, which now provides the `attachments` array for you. You
must pass this to your mutations for attachments to work correctly.

```tsx
const createThread = useCreateThread();

// ❌ Before - Liveblocks 2.7
<Composer
  onComposerSubmit={({ body }, event) => {
    event.preventDefault();
    createThread({ body, metadata: {} });
  }}
/>

// ✅ After - Liveblocks 2.8
<Composer
// +++
  onComposerSubmit={({ body, attachments }, event) => {
    // +++
    event.preventDefault();
    // +++
    createThread({ body, attachments, metadata: {} });
    // +++
  }}
/>
```

Remember that this applies to
[`useCreateComment`](/docs/api-reference/liveblocks-react#useCreateComment) and
[`useEditComment`](/docs/api-reference/liveblocks-react#useEditComment) too, not
just [`useCreateThread`](/docs/api-reference/liveblocks-react#useCreateThread).
No further changes are necessary to enable attachments in the default
components.

{/* TODO If you’re using primitives you must ... */}

### Disable attachments [#disable-attachments]

If you’d prefer to disable attachments, you can do so by setting the
`showAttachments` prop to `false` on each of the following components:
[`Composer`](/docs/api-reference/liveblocks-react-ui#Composer),
[`Comment`](/docs/api-reference/liveblocks-react-ui#Comment),
[`Thread`](/docs/api-reference/liveblocks-react-ui#Thread), and
[`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification.Thread).

```tsx
// Disable attachments
<Composer showAttachments={false} ... />
<Comment showAttachments={false} ... />
<Thread showAttachments={false} ... />
<InboxNotification showAttachments={false} ... />
```

## Default Composer component structure

The default [`Composer`](/docs/api-reference/liveblocks-react-ui#Composer)
component’s structure has slightly changed, so if you customized its styles:
make sure to check if and how the new structure affects your changes.

```html
<!-- ❌ Before - Liveblocks 2.7 -->
<form class="lb-root lb-composer lb-composer-form">
  <!-- The editor and its controls -->
</form>

<!-- ✅ After - Liveblocks 2.8 -->
<form class="lb-root lb-composer lb-composer-form">
  <div class="lb-composer-editor-container">
    <!-- The editor and its controls -->
  </div>
</form>
```

---
meta:
  title: "Upgrading to 2.9"
  parentTitle: "Upgrading"
  description: "Guide to upgrade to Liveblocks version 2.9"
---

We are introducing pagination to Comments and Notifications as default. You need
to upgrade your app to handle this.

## How to upgrade? [#how]

You can upgrade to 2.9 by downloading the latest version of each Liveblocks
package you’re using. The easiest way to do that is to run the following
command:

```bash
npx create-liveblocks-app@latest --upgrade
```

## All changes are for Comments & Notifications

If you’re not using
[`useThreads`](/docs/api-reference/liveblocks-react#useThreads) or
[`useInboxNotifications`](/docs/api-reference/liveblocks-react#useInboxNotifications)
there are no breaking changes for you! However, if you are using them, keep
reading.

## Pagination

[`useThreads`](/docs/api-reference/liveblocks-react#useThreads) and
[`useInboxNotifications`](/docs/api-reference/liveblocks-react#useInboxNotifications)
now only fetch the **latest 50 threads/notifications**, and you must use
paginate to retrieve more. Previously, these two functions would fetch every
single thread/notification, but this is no longer possible.

<Banner type="warning">
  If your rooms have fewer than 50 threads, and your users fewer than 50
  notifications, there will be no visible difference when upgrading. Pagination
  is only used after 50.
</Banner>

### Threads

Here’s a before and after example with
[`useThreads`](/docs/api-reference/liveblocks-react#useThreads), adding
pagination.

```tsx title="Before"
import { useThreads } from "@liveblocks/react/suspense";

function Threads() {
  const { threads } = useThreads();

  return (
    <div>
      {threads.map((thread) => (
        <Thread key={thread.id} thread={thread} />
      ))}
    </div>
  );
}
```

```tsx title="After"
import { useThreads } from "@liveblocks/react/suspense";

function Threads() {
  const {
    threads,
    // +++
    fetchMore,
    isFetchingMore,
    hasFetchedAll,
    fetchMoreError,
    // +++
  } = useThreads();

  // +++
  const loadMore = fetchMoreError ? (
    <>
      <p>Error loading more threads: {fetchMoreError.message}</p>
      <button onClick={fetchMore} disabled={isFetchingMore}>
        Retry
      </button>
    </>
  ) : (
    <button onClick={fetchMore} disabled={isFetchingMore}>
      Load more
    </button>
  );
  // +++

  return (
    <div>
      {threads.map((thread) => (
        <Thread key={thread.id} thread={thread} />
      ))}
      // +++
      {hasFetchedAll ? <div>🎉 You're all caught up!</div> : loadMore}
      // +++
    </div>
  );
}
```

### Notifications

Here’s a before and after example with
[`useInboxNotifications`](/docs/api-reference/liveblocks-react#useInboxNotifications),
adding pagination.

```tsx title="Before"
import { useInboxNotifications } from "@liveblocks/react/suspense";

function Notifications() {
  const { inboxNotifications } = useInboxNotifications();

  return (
    <div>
      {inboxNotifications.map((notification) => (
        <InboxNotification key={notification.id} inboxNotification={no†ification} />
      ))}
    </div>
  );
}
```

```tsx title="After"
import { useInboxNotifications } from "@liveblocks/react/suspense";

function Notifications() {
  const {
    inboxNotifications,
    // +++
    fetchMore,
    isFetchingMore,
    hasFetchedAll,
    fetchMoreError,
    // +++
  } = useInboxNotifications();

  // +++
  const loadMore = fetchMoreError ? (
    <>
      <p>Error loading more notifications: {fetchMoreError.message}</p>
      <button onClick={fetchMore} disabled={isFetchingMore}>
        Retry
      </button>
    </>
  ) : (
    <button onClick={fetchMore} disabled={isFetchingMore}>
      Load more
    </button>
  );
  // +++

  return (
    <div>
      {inboxNotifications.map((notification) => (
        <InboxNotification key={notification.id} inboxNotification={no†ification} />
      ))}
      // +++
      {hasFetchedAll ? <div>🎉 You're all caught up!</div> : loadMore}
      // +++
    </div>
  );
}
```

### Learn more

Learn more about pagination under
[`useThreads`](/docs/api-reference/liveblocks-react#useThreads) and
[`useInboxNotifications`](/docs/api-reference/liveblocks-react#useInboxNotifications).

---
meta:
  title: "Upgrading to 3.0"
  parentTitle: "Upgrading"
  description: "Guide to upgrade to Liveblocks version 3.0"
---

Liveblocks 3.0 is our third major release, focusing on our newest product,
[AI Copilots](https://liveblocks.io/blog/meet-liveblocks-3-0-the-fastest-way-to-let-your-users-collaborate-with-ai-in-your-product).
We’ve used this as an opportunity to tidy up some of our existing APIs, ensuring
consistency throughout our offering.

## How to upgrade [#how-to-upgrade]

First of all, let’s upgrade all Liveblocks dependencies to their latest
versions. The easiest way to do that is to run the following command:

```bash
npx create-liveblocks-app@latest --upgrade
```

There are some **breaking changes** in this update.

## Does this affect you? [#does-this-affect-you]

**If you’re using TypeScript 4.9 or lower**, TypeScript 5.0 is now the minimum
supported version.

**If you’re using [Comments](/docs/ready-made-features/comments) and/or
[Text Editor](/docs/ready-made-features/text-editor)**, please read about
[changes to mentions](#mentions).

**If you’re using [Notifications](/docs/ready-made-features/notifications)**,
please read about
[a change related to notification settings](#notification-settings).

**If you’re using
[`@liveblocks/emails`](/docs/api-reference/liveblocks-emails)**, please read
about [changes to its returned values](#liveblocks-emails).

**If you’re using
[`LiveblocksUIConfig`](/docs/api-reference/liveblocks-react-ui#LiveblocksUiConfig)**,
please read about [its renaming](#liveblocks-ui-config).

**If you’re using any deprecated APIs** (e.g. `useBatch`, `useStorageStatus`,
`useEditorStatus`…), please read about [the ones being removed](#deprecated).

Otherwise, you can simply upgrade your packages and no changes will affect you.

## Mentions [#mentions]

We’re making some changes to mentions in Comments and Text Editor to support
different mention kinds in future releases: user mentions, group mentions, etc.

### Components [#mentions-components]

The `onMentionClick` prop on [`Thread`][] and [`Comment`][] now receives a
`MentionData` object as its first argument instead of a `userId` string.

```tsx
// ❌ Before
onMentionClick(userId: string, event: MouseEvent<HTMLElement>) => void;
// userId: "user-0"

// ✅ After
onMentionClick(mention: MentionData, event: MouseEvent<HTMLElement>) => void;
// mention: { kind: "user", id: "user-0" }
```

### Primitives [#mentions-primitives]

When customizing the
[`Mention`](/docs/api-reference/liveblocks-react-ui#primitives-Comment.Body-Mention)
component on the [`Comment.Body`][] and [`Composer.Editor`][] primitives, the
`userId` prop has been replaced by a `mention` one which is a `MentionData`
object.

```tsx
import { Comment, Composer } from "@liveblocks/react-ui/primitives";

// ❌ Before
<Comment.Body
  components={{
    // +++
    Mention: ({ userId }) => <Comment.Mention>@{userId}</Comment.Mention>,
    // +++
  }}
/>;
<Composer.Editor
  components={{
    // +++
    Mention: ({ userId, isSelected }) => (
      <Composer.Mention>@{userId}</Composer.Mention>
    ),
    // +++
  }}
/>;

// ✅ After
<Comment.Body
  components={{
    // +++
    Mention: ({ mention }) => <Comment.Mention>@{mention.id}</Comment.Mention>,
    // +++
  }}
/>;
<Composer.Editor
  components={{
    // +++
    Mention: ({ mention, isSelected }) => (
      <Composer.Mention>@{mention.id}</Composer.Mention>
    ),
    // +++
  }}
/>;
```

When customizing the
[`MentionSuggestions`](/docs/api-reference/liveblocks-react-ui#primitives-Composer.Editor-MentionSuggestions)
component on the [`Composer.Editor`][] primitive, the `userIds` prop has been
replaced by a `mentions` one which is an array of `MentionData` objects, and the
`selectedUserId` prop has been renamed to `selectedMentionId`.

```tsx
import { Composer } from "@liveblocks/react-ui/primitives";

// ❌ Before
<Composer.Editor
  components={{
    // +++
    MentionSuggestions: ({ userIds, selectedUserId }) => (
      // +++
      <Composer.Suggestions>
        <Composer.SuggestionsList>
          // +++
          {userIds.map((userId) => (
            <Composer.SuggestionsListItem key={userId} value={userId}>
              {userId}
            </Composer.SuggestionsListItem>
          ))}
          // +++
        </Composer.SuggestionsList>
      </Composer.Suggestions>
    ),
  }}
/>;

// ✅ After
<Composer.Editor
  components={{
    // +++
    MentionSuggestions: ({ mentions, selectedMentionId }) => (
      // +++
      <Composer.Suggestions>
        <Composer.SuggestionsList>
          // +++
          {mentions.map((mention) => (
            <Composer.SuggestionsListItem key={mention.id} value={mention.id}>
              {mention.id}
            </Composer.SuggestionsListItem>
          ))}
          // +++
        </Composer.SuggestionsList>
      </Composer.Suggestions>
    ),
  }}
/>;
```

### `@liveblocks/emails` [#mentions-liveblocks-emails]

The [`prepareTextMentionNotificationEmailAsReact`][] and
[`prepareTextMentionNotificationEmailAsHtml`][] functions’ returned data changed
slightly:

- The `id` property is now named `textMentionId`, it refers to the mention’s
  Text Mention ID, not the user ID used for the mention
- The `id` property now refers to the mention’s ID, as in the user ID used for
  the mention
- The `kind` property now indicates the mention’s kind (e.g. `"user"`,
  `"group"`, etc.)

```tsx
await prepareTextMentionNotificationEmailAsReact(liveblocks, event);

// ❌ Before
// {
//   mention: {
// +++
//     id: "in_xxx",
// +++
//     roomId: "123",
//     createdAt: new Date(),
//     author: {
//       id: "user-who-created-the-mention",
//       info: {
//         name: "Aurélien",
//       },
//     },
//     content: /* The rendered content */,
//   },
//   ...
// }

// ✅ After
// {
//   mention: {
// +++
//     textMentionId: "in_xxx",
//     id: "user-who-is-mentioned",
//     kind: "user",
// +++
//     roomId: "123",
//     createdAt: new Date(),
//     author: {
//       id: "user-who-created-the-mention",
//       info: {
//         name: "Aurélien",
//       },
//     },
//     content: /* The rendered content */,
//   },
//   ...
// }
```

When customizing the
[`Mention`](/docs/api-reference/liveblocks-emails#prepare-text-mention-notification-email-as-react-customizing-components)
component in the [`prepareTextMentionNotificationEmailAsReact`][] function, the
`element` prop’s `userId` property has been renamed to `id`, and `element.kind`
now indicates the mention’s kind.

```tsx
await prepareThreadNotificationEmailAsReact(liveblocks, webhookEvent, {
  components: {
    // ❌ Before
    Mention: ({ element, user }) => (
      // +++
      <span style={{ color: "red" }}>@{element.userId}</span>
      // +++
    ),

    // ✅ After
    Mention: ({ element, user }) => (
      // +++
      <span style={{ color: "red" }}>@{element.id}</span>
      // +++
    ),
  },
});
```

### Utilities [#utilities]

The `getMentionedIdsFromCommentBody` utility (from
[`@liveblocks/client`](/docs/api-reference/liveblocks-client) and
[`@liveblocks/node`](/docs/api-reference/liveblocks-node)) has been replaced by
[`getMentionsFromCommentBody`](/docs/api-reference/liveblocks-client#get-mentions-from-comment-body).

```tsx
// ❌ Before
getMentionedIdsFromCommentBody(commentBody);
// ["user-1", "user-2"]

// ✅ After
getMentionsFromCommentBody(commentBody, (mention) => mention.kind === "user");
// [{ type: "mention", kind: "user", id: "user-1" }, { type: "mention", kind: "user", id: "user-2" }]
```

By default, if the optional second argument is not provided, all mentions are
returned, including future mention kinds (e.g. group mentions in the future).

```tsx
// All mentions
getMentionsFromCommentBody(commentBody);

// Only user mentions
getMentionsFromCommentBody(commentBody, (mention) => mention.kind === "user");
```

## Notification settings [#notification-settings]

2.24 [introduced a few naming changes](/docs/platform/upgrading/2.24) around the
concepts of “notification settings” and “subscription settings” to improve
clarity. 3.0 [removes the aliases for the previous names](#deprecated) but it
also introduces one change that we couldn’t make in 2.24:

The `UPDATE_USER_NOTIFICATION_SETTINGS_ERROR` error has been renamed to
`UPDATE_NOTIFICATION_SETTINGS_ERROR`.

```tsx
// ❌ Before
useErrorListener((error) =>
  // +++
  if (error.context.type === "UPDATE_USER_NOTIFICATION_SETTINGS_ERROR") {
    // +++
    /* ... */
  }
);

// ✅ After
useErrorListener((error) =>
  // +++
  if (error.context.type === "UPDATE_NOTIFICATION_SETTINGS_ERROR") {
    // +++
    /* ... */
  }
);
```

## `@liveblocks/emails` [#liveblocks-emails]

The functions to prepare HTML/React emails are now more consistent, using the
same `body` and `content` properties instead of `reactBody`/`htmlBody` and
`reactContent`/`htmlContent`.

```tsx
// ❌ Before
prepareThreadNotificationEmailAsReact(liveblocks, event); // { comment: { reactBody: ReactNode, ... }, ... }
prepareThreadNotificationEmailAsHtml(liveblocks, event); // { comment: { htmlBody: string, ... }, ... }
prepareTextMentionNotificationEmailAsReact(liveblocks, event); // { mention: { reactContent: ReactNode, ... }, ... }
prepareTextMentionNotificationEmailAsHtml(liveblocks, event); // { mention: { htmlContent: string, ... }, ... }

// ✅ After
prepareThreadNotificationEmailAsReact(liveblocks, event); // { comment: { body: ReactNode, ... }, ... }
prepareThreadNotificationEmailAsHtml(liveblocks, event); // { comment: { body: string, ... }, ... }
prepareTextMentionNotificationEmailAsReact(liveblocks, event); // { mention: { content: ReactNode, ... }, ... }
prepareTextMentionNotificationEmailAsHtml(liveblocks, event); // { mention: { content: string, ... }, ... }
```

## `LiveblocksUiConfig` [#liveblocks-ui-config]

The
[`LiveblocksUIConfig`](/docs/api-reference/liveblocks-react-ui#LiveblocksUiConfig)
utility has been renamed to `LiveblocksUiConfig` (`UI` → `Ui`) for consistency
with other Liveblocks APIs.

Run the following **codemod** or manually make the changes:

```bash
npx @liveblocks/codemod@latest liveblocks-ui-config
```

## Removed deprecated APIs [#deprecated]

All of the following APIs have been removed in 3.0, as they were deprecated
multiple versions ago.

**Affecting all packages:**

- The `code` property has been removed from `LiveblocksError`, use
  `LiveblocksError.context.code` instead for better TypeScript support.
- The deprecated aliases from the
  [2.24 naming changes](/docs/platform/upgrading/2.24) have been removed, use
  their new names instead. There’s a codemod available to automatically use the
  new names: `npx @liveblocks/codemod@latest rename-notification-settings`.

**Affecting `@liveblocks/client`:**

- The `unstable_fallbackToHTTP` client option has been removed, set
  `largeMessageStrategy` to `"experimental-fallback-to-http"` instead.

**Affecting `@liveblocks/node`:**

- The `metadata` option has been removed from the [`getRooms`][] method, use
  `query.metadata` instead.
- The `nextPage` property has been removed from the [`getRooms`][] method’s
  response, use `query.nextPage` instead.
- The `RoomData` type has been removed, use `RoomInfo` instead. There’s a
  codemod available to automatically use the new type:
  `npx @liveblocks/codemod@latest room-info-to-room-data`.

**Affecting `@liveblocks/react`:**

- The `useBatch` hook has been removed, use [`useMutation`][] instead for
  writing to Storage, which will automatically batch all mutations.
- The `useStorageStatus` hook has been removed, use [`useSyncStatus`][] instead
  for tracking sync status, it reflects the sync status of all parts of
  Liveblocks.

**Affecting `@liveblocks/react-lexical`:**

- The `useEditorStatus` hook has been removed, use [`useSyncStatus`][] instead
  for tracking sync status, it reflects the sync status of all parts of
  Liveblocks.

[`Thread`]: /docs/api-reference/liveblocks-react-ui#Thread
[`Comment`]: /docs/api-reference/liveblocks-react-ui#Comment
[`Comment.Body`]:
  /docs/api-reference/liveblocks-react-ui#primitives-Comment.Body
[`Composer.Editor`]:
  /docs/api-reference/liveblocks-react-ui#primitives-Composer.Editor
[`prepareTextMentionNotificationEmailAsReact`]:
  /docs/api-reference/liveblocks-emails#prepare-text-mention-notification-email-as-react
[`prepareTextMentionNotificationEmailAsHtml`]:
  /docs/api-reference/liveblocks-emails#prepare-text-mention-notification-email-as-html
[`getRooms`]: /docs/api-reference/liveblocks-node#get-rooms
[`useSyncStatus`]: /docs/api-reference/liveblocks-react#useSyncStatus
[`useMutation`]: /docs/api-reference/liveblocks-react#useMutation

---
meta:
  title: "Upgrading to 3.10"
  parentTitle: "Upgrading"
  description: "Guide to upgrade to Liveblocks version 3.10"
---

Liveblocks 3.10 releases breaking changes for [Tiptap](#Upgrade-Tiptap) and
[BlockNote](#Upgrade-BlockNote) users, as it requires at least Tiptap 3.0 or
BlockNote 0.39. No other parts of Liveblocks are affected by breaking changes.

## Not using Tiptap or BlockNote?

**If you’re not using Tiptap or BlockNote, there are no breaking changes**,
simply upgrade all Liveblocks dependencies to their latest versions. The easiest
way to upgrade is to run the following command:

```bash
npx create-liveblocks-app@latest --upgrade
```

## Upgrade Tiptap

Tiptap v3 is a major update with breaking changes, but the migration process is
quite simple. First, you _must_ uninstall all Tiptap and Liveblocks dependencies
to avoid future version conflicts as we upgrade. This includes any extra Tiptap
extensions you may have installed or anything else with a dependency on Tiptap.

### AI-assisted upgrade

The easiest way to upgrade is to use our AI assistant to generate the uninstall
and install commands for you. Make sure you’re signed in, paste your
`package.json` into the following input, and generate the commands.

<AskAiInput 
  placeholder="Paste package.json here" 
  prompt={`Write two **multi-line** npm commands for me:
1. Uninstall all Tiptap and Liveblocks packages. 
2. Install all the same packages with @latest on Liveblocks and @3 on Tiptap.

Here is my package.json:

\`\`\`json

{{INPUT}}

\`\`\`

Offer to rewrite the multi-line commands if I'm on Windows.

`}>

Generate commands

</AskAiInput>

Run the two commands then skip to
[Critical changes for Liveblocks users](#Critical-changes-for-Liveblocks-Tiptap-users).

### Manual upgrade

If you’d like to upgrade manually, you need to run the npm command to uninstall
all Tiptap and Liveblocks packages. Note that you are likely using more package
than are listed here, such as Tiptap extensions.

```bash
npm uninstall @tiptap/react @tiptap/starter-kit @liveblocks/client @liveblocks/react @liveblocks/react-ui @liveblocks/react-tiptap @liveblocks/node
```

Next re-install all Tiptap and Liveblocks packages. If you see a peer dependency
issue during this install, you may have missed something during the previous
uninstall step.

```bash
npm install @tiptap/react@3 @tiptap/starter-kit@3 @liveblocks/client@latest @liveblocks/react@latest @liveblocks/react-ui@latest @liveblocks/react-tiptap@latest @liveblocks/node@latest
```

This will ensure that all packages are compatible with each other and prevent
version conflicts.

### Critical changes for Liveblocks Tiptap users

While Tiptap 3 includes many changes, there are two particularly critical
changes that affect Liveblocks integration:

#### StarterKit configuration change

Liveblocks provides its own undo/redo functionality for collaborative editing.
In Tiptap 2, you disabled this by setting `history: false` in the StarterKit
extension. In Tiptap 3, this option has been renamed to `undoRedo`:

```diff
const editor = useEditor({
  extensions: [
    StarterKit.configure({
-      history: false,
+      undoRedo: false,
    }),
    // Other extensions...
  ],
});
```

#### Set `immediatelyRender: false` for SSR

If you're using server-side rendering (such as with Next.js), it's now more
critical to set `immediatelyRender: false` in your editor configuration to
prevent hydration issues:

```tsx highlight="3"
const editor = useEditor(
  {
    immediatelyRender: false,
    extensions: [
      StarterKit.configure({
        undoRedo: false,
      }),
      // Other extensions...
    ],
  },
  []
);
```

This ensures the editor doesn't render on the server, which can cause mismatches
between server and client rendering.

#### Style Changes

If you have your own styles for collaboration cursors, the default CSS classes
prefix changed from `.collaboration-cursor` to `.collaboration-carets`.

```diff
- .collaboration-cursor__caret {
+ .collaboration-carets__caret {
  /* Your caret styles */
}

- .collaboration-cursor__label {
+ .collaboration-carets__label {
  /* Your label styles */
}
```

### Full migration guide

For a complete list of breaking changes and new features in Tiptap 3, refer to
the
[official Tiptap upgrade guide](https://tiptap.dev/docs/guides/upgrade-tiptap-v2).

## Upgrade BlockNote

BlockNote 0.39 has upgraded to use Tiptap 3.0 under the hood, which requires a
simple upgrade. First, you _must_ uninstall all BlockNote, Tiptap, and
Liveblocks dependencies to avoid future version conflicts as we upgrade. This
includes any extra Tiptap extensions you may have installed or anything else
with a dependency on Tiptap.

### AI-assisted upgrade

The easiest way to upgrade is to use our AI assistant to generate the uninstall
and install commands for you. Make sure you’re signed in, paste your
`package.json` into the following input, and generate the commands.

<AskAiInput 
  placeholder="Paste package.json here" 
  prompt={`Write two **multi-line** npm commands for me:
1. Uninstall all BlockNote, Tiptap, and Liveblocks packages. 
2. Install all the same packages with @latest on Liveblocks and BlockNote, and @3 on Tiptap.

Here is my package.json:

\`\`\`json

{{INPUT}}

\`\`\`

Offer to rewrite the multi-line commands if I'm on Windows.

`}>

Generate commands

</AskAiInput>

Run the two commands then skip to
[Critical changes for Liveblocks BlockNote users](#Critical-changes-for-Liveblocks-BlockNote-users).

### Manual upgrade

If you’d like to upgrade manually, you need to run the npm command to uninstall
all BlockNote, Tiptap, and Liveblocks packages. Note that you are likely using
more package than are listed here, such as text editor extensions.

```bash
npm uninstall @blocknote/core @blocknote/mantine @liveblocks/client @liveblocks/react @liveblocks/react-ui @liveblocks/react-blocknote @liveblocks/node
```

Next re-install all BlockNote, Tiptap, and Liveblocks packages. If you see a
peer dependency issue during this install, you may have missed something during
the previous uninstall step.

```bash
npm install @blocknote/core@latest @blocknote/mantine@latest @liveblocks/client@latest @liveblocks/react@latest @liveblocks/react-ui@latest @liveblocks/react-blocknote@latest @liveblocks/node@latest
```

This will ensure that all packages are compatible with each other and prevent
version conflicts.

### Critical changes for Liveblocks BlockNote users

#### Style Changes

If you have your own styles for collaboration cursors, the default CSS classes
prefix changed from `.collaboration-cursor` to `.collaboration-carets`.

```diff
- .collaboration-cursor__caret {
+ .collaboration-carets__caret {
  /* Your caret styles */
}

- .collaboration-cursor__label {
+ .collaboration-carets__label {
  /* Your label styles */
}
```

### BlockNote release notes

To learn more about the changes in BlockNote 0.39, please refer to the
[BlockNote 0.39 release notes](https://github.com/TypeCellOS/BlockNote/releases/tag/v0.39.0).

---
meta:
  title: "Adding Liveblocks to existing useState hooks"
  description: "Learn how to add Liveblocks to your existing useState hooks"
---

<Banner title="Not recommended" type="warning">
  Note that this is not the recommended way to build your app. We recommend
  using conflict-free data types and the `useStorage` and `useMutation` hooks,
  to take full advantage of our features.
</Banner>

You can easily add Liveblocks to an existing `useState` hook by broadcasting and
listening to events.

```ts
import { useState } from "react";
import { useBroadcastEvent, useEventListener } from "./liveblocks.config";

function useCustomState() {
  const [state, setState] = useState();
  const broadcast = useBroadcastEvent();

  // Update useState and broadcast an event
  const setStateAndBroadcast = (newValue) => {
    setState(newValue);
    broadcast({ type: "STATE_UPDATE", data: newValue });
  };

  // Listen for the broadcast event
  useEventListener(({ event }) => {
    if (event.type === "STATE_UPDATE") {
      setState(event.data);
    }
  });

  return [state, setStateAndBroadcast];
}
```

---
meta:
  title: "Can I use my own database with Yjs?"
  description:
    "Learn how to use webhooks to duplicate Yjs document data to your own
    database"
---

When building collaborative applications with Yjs and Liveblocks, you may wish
to store document data in your own database alongside Liveblocks. While
Liveblocks needs to store Yjs document data to handle realtime collaboration,
you can use webhooks to duplicate this data to your own database whenever
changes occur.

## Why Liveblocks stores Yjs data

Liveblocks must store Yjs document data to provide realtime collaboration
features. This storage is essential for:

- **Realtime synchronization**: Distributing changes to all connected users
  instantly.
- **Conflict resolution**: Merging changes from multiple users working
  simultaneously.
- **Offline support**: Syncing changes when users reconnect after being offline.
- **Document loading**: Loading the current document state when a user
  enters the room.

Without storing this data, Liveblocks wouldn't be able to provide the realtime
collaboration features that make Yjs powerful. However, you can still maintain
your own copy of the data for additional purposes like backups, analytics, or
creating full-text search.

## Duplicating data with webhooks

The best way to keep your own database in sync with Liveblocks is to use the
[`YDocUpdated`](/docs/platform/webhooks#YDocUpdatedEvent) webhook event. This
event fires whenever a Yjs document changes, allowing you to copy the updated
data to your database. Your server can then:

1. Receive the webhook event.
2. Fetch the latest document data from Liveblocks.
3. Store it in your database.

This approach ensures your database stays synchronized with Liveblocks without
interfering with realtime collaboration.

### Webhook throttling

Yjs documents can update up to 60 times per second during active collaboration,
so the `YDocUpdated` webhook is throttled to prevent overwhelming your server.
The webhook event will run no more than once every 60 seconds by default, but on
Enterprise plans, you can configure this throttle to be as low as every 5
seconds, giving you near realtime synchronization to your database.

<Banner title="Need a lower throttle speed?">

If you’re interested in lowering the throttle value,
[reach out to our team](/contact/sales) to discuss your needs.

</Banner>

## Setting up database synchronization

We have detailed guides that walk you through how to set up your webhooks and
store the Yjs data in your database.

- [How to synchronize your Liveblocks Yjs document data to a Vercel Postgres database](/docs/guides/how-to-synchronize-your-liveblocks-yjs-document-data-to-a-vercel-postgres-database)
- [How to synchronize your Liveblocks Yjs document data to a Supabase Postgres database](/docs/guides/how-to-synchronize-your-liveblocks-yjs-document-data-to-a-supabase-postgres-database)
- [How to synchronize your Liveblocks Yjs document data to a PlanetScale MySQL database](/docs/guides/how-to-synchronize-your-liveblocks-yjs-document-data-to-a-planetscale-mysql-database)

If your database isn’t mentioned in these guides, the process is still similar,
so we recommend following the general steps outlined in the guides. The guides
above detail fetching your Yjs data, however if you’re using one of our text
editor extensions, you can use our server packages to fetch the Tiptap or
Lexical data instead.

### Tiptap

If you’re using Tiptap,
[`withProsemirrorDocument`](/docs/api-reference/liveblocks-node-prosemirror#withProsemirrorDocument)
can be used to fetch the Tiptap data, as text, markdown, JSON, and more, when
receiving the webhook event.

```ts
const textContent = await withProsemirrorDocument(
  { roomId: "my-room-id", client: liveblocks },
  async (api) => {
    return api.toMarkdown();
  }
);

// "# Hello world"
console.log(textContent);
```

Additionally you can modify the live document with
[`setContent`](/docs/api-reference/liveblocks-node-prosemirror#api.setContent).

### Lexical

If you’re using Lexical,
[`withLexicalDocument`](/docs/api-reference/liveblocks-node-lexical#withLexicalDocument)
can be used to fetch the Lexical data, as text, markdown, JSON, and more, when
receiving the webhook event.

```ts
const textContent = await withLexicalDocument(
  { roomId: "my-room-id", client: liveblocks },
  async (doc) => {
    return doc.toMarkdown();
  }
);

// "# Hello world"
console.log(textContent);
```

Additionally you can modify the live document with
[`update`](/docs/api-reference/liveblocks-node-lexical#doc.update).

## Learn more

For more information about data storage and webhooks, check out these resources:

### API documentation

Learn more about the APIs for working with Yjs data:

- [YDocUpdated webhook event](/docs/platform/webhooks#YDocUpdatedEvent)
- [@liveblocks/node-prosemirror](/docs/api-reference/liveblocks-node-prosemirror)
- [@liveblocks/node-lexical](/docs/api-reference/liveblocks-node-lexical)
- [Data storage](/docs/platform/data-storage)

---
meta:
  title: "Fixing Next.js server component errors"
  description: "Learn how to fix Next.js server component problems"
---

## Fixing the warning: "Only plain objects can be passed to Client Components from Server Components"

This error occurs when you create a Liveblocks data structure (`LiveObject`,
`LiveList`, or `LiveMap`) within a server component. This is often occurs when
creating a room with `RoomProvider`, for example:

```tsx file="layout.tsx"
import { ReactNode } from "react";
import { RoomProvider } from "../liveblocks.config";
import { LiveObject } from "@liveblocks/client";

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <RoomProvider
          id="my-room-name"
          initialStorage={{
            // ❌ This line causes the error
            session: new LiveObject(),
          }}
        >
          {children}
        </RoomProvider>
      </body>
    </html>
  );
}
```

This can be fixed by turning the file into a client file with `"use client";` at
the top of the file. However, if your need a server component, you can extract
the provider to a different file.

## Structuring your app

To take this further, and to learn how to structure your Next.js app in the best
possible way, make sure to read our
[How to use Liveblocks with Next.js /app directory](/docs/guides/how-to-use-liveblocks-with-nextjs-app-directory)
guide.

---
meta:
  title: "Getting ProseMirror state on the server"
  description:
    "Learn how to retrieve your document’s ProseMirror state on the server"
---

Using
[`@liveblocks/node-prosemirror`](/docs/api-reference/liveblocks-node-prosemirror),
it’s possible to retrieve the state of your ProseMirror document on the server.

## Getting document state

To get your document state, you can use
[`withProsemirrorDocument`](/docs/api-reference/liveblocks-node-prosemirror#withProsemirrorDocument)
and
[`api.getText`](/docs/api-reference/liveblocks-node-prosemirror#api.getText).

```ts
import { Liveblocks } from "@liveblocks/node";
import { withProsemirrorDocument } from "@liveblocks/node-prosemirror";

const liveblocks = new Liveblocks({
  secret: "{{SECRET_KEY}}",
});

// +++
const textContent = await withProsemirrorDocument(
  { roomId: "your-room-id", client: liveblocks, field: "prosemirror" },
  async (api) => {
    return api.getText();
  }
);
// +++

// "My content"
console.log(textContent);
```

## Modifying document state

To modify document state with transactions, use
[`api.update`](/docs/api-reference/liveblocks-node-prosemirror#api.update). 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).

```ts
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, field: "prosemirror" },
  // +++
  async (api) => {
    await api.update((_, tr) => {
      // Transaction example
      return tr.insertText("Hello world");
    });
  }
  // +++
);
```

## Using Yjs APIs instead

We don’t generally recommend it, but it’s also possible to use
[`@liveblocks/node`](/docs/api-reference/liveblocks-node) to retrieve the state
of your ProseMirror document, and its [`Y.Doc`](https://docs.yjs.dev/api/y.doc),
on the server. This may give you more control in some cases.

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`. We can then call
`yDocToProseMirror` from [`y-prosemirror`](https://github.com/yjs/y-prosemirror)
to retrieve the ProseMirror editor’s state.

```ts
import * as Y from "yjs";
import { Liveblocks } from "@liveblocks/node";
import { yDocToProsemirrorJSON } from "y-prosemirror";

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));

  // Get ProseMirror state from the default Yjs property it uses, "prosemirror"
  const prosemirrorState = yDocToProsemirrorJSON(yDoc, "prosemirror");

  // { type: "doc", content: [{ type: "paragraph", content: [...] }] }
  console.log(prosemirrorState);
}
```

If you’d like to edit your `Y.Doc`, make sure to read
[how to use your `Y.Doc` on the server](/docs/guides/how-to-use-your-ydoc-on-the-server).

---
meta:
  title: "Getting Tiptap state on the server"
  description:
    "Learn how to retrieve your document’s Tiptap state on the server"
---

Using
[`@liveblocks/node-prosemirror`](/docs/api-reference/liveblocks-node-prosemirror),
it’s possible to retrieve the state of your Tiptap document on the server.
Tiptap is not easy to edit on the server directly, which is why it’s necessary
to use the ProseMirror package. Tiptap is an extension of ProseMirror.

## Getting document state

To get your document state, you can use
[`withProsemirrorDocument`](/docs/api-reference/liveblocks-node-prosemirror#withProsemirrorDocument)
and
[`api.getText`](/docs/api-reference/liveblocks-node-prosemirror#api.getText).

```ts
import { Liveblocks } from "@liveblocks/node";
import { withProsemirrorDocument } from "@liveblocks/node-prosemirror";

const liveblocks = new Liveblocks({
  secret: "{{SECRET_KEY}}",
});

// +++
const textContent = await withProsemirrorDocument(
  { roomId: "your-room-id", client: liveblocks },
  async (api) => {
    return api.getText();
  }
);
// +++

// "My content"
console.log(textContent);
```

## Modifying document state

To modify document state with transactions, use
[`api.update`](/docs/api-reference/liveblocks-node-prosemirror#api.update). 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).

```ts
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 },
  // +++
  async (api) => {
    await api.update((_, tr) => {
      // Transaction example
      return tr.insertText("Hello world");
    });
  }
  // +++
);
```

You’ve now learned to fetch and modify document state!

## Using Yjs APIs instead

We don’t generally recommend it, but it’s also possible to use
[`@liveblocks/node`](/docs/api-reference/liveblocks-node) to retrieve the state
of your Tiptap document, and its [`Y.Doc`](https://docs.yjs.dev/api/y.doc), on
the server. This may give you more control in some cases.

With
[`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`. Because Tiptap is a
wrapper around ProseMirror, we can then call `yDocToProseMirror` from
[`y-prosemirror`](https://github.com/yjs/y-prosemirror) to retrieve the Tiptap
editor’s state.

```ts
import * as Y from "yjs";
import { Liveblocks } from "@liveblocks/node";
import { yDocToProsemirrorJSON } from "y-prosemirror";

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));

  // Get Tiptap state from the Yjs property it uses, "default"
  const tiptapState = yDocToProsemirrorJSON(yDoc, "default");

  // { type: "doc", content: [{ type: "paragraph", content: [...] }] }
  console.log(tiptapState);
}
```

If you’d like to edit your `Y.Doc`, make sure to read
[how to use your `Y.Doc` on the server](/docs/guides/how-to-use-your-ydoc-on-the-server).

---
meta:
  title: "How to add users to Liveblocks Comments"
  description:
    "Learn how to add your user’s avatars and names to Liveblocks Comments using
    the resolver functions."
---

After following the get started guide for Comments, you’ll notice that each user
is currently “Anonymous”, and that there’s no way to mention or tag other users.
To enable these features, we need to tell Comments where to find your users’
information.

<Figure>
  <Image
    src="/assets/tutorials/comments/thread.png"
    alt="Thread with resolved users"
    width={1291}
    height={892}
  />
</Figure>

## What we’re learning

In this guide we’ll be modifying
[`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider),
learning how to:

- Add names and avatars to threads and comments with
  [`resolveUsers`](/docs/api-reference/liveblocks-react#resolveUsers).
- Create user mention suggestions in the composer using
  [`resolveMentionSuggestions`](/docs/api-reference/liveblocks-react#resolveMentionSuggestions).

## Authenticate your application

The first step is to find an
[authentication guide for your framework](/docs/authentication) and authenticate
your app, as this is necessary for Comments.

Make sure to follow the metadata step in the guide, and attach the name of your
user, along with the URL of their avatar, as these properties will both be used
in the [default components](/docs/ready-made-features/comments#Components).
Here’s an example using ID token authentication, our recommended method, with an
email address as a user’s ID.

```ts title="Metadata in ID tokens"
const { status, body } = await liveblocks.identifyUser(
  {
    userId: "marc@example.com",

    // Optional
    // tenantId: "org-id",
    // groupIds: ["group-id-1", "group-id-2"],
  },
  {
    userInfo: {
      name: "Marc",
      avatar: "https://example.com/marc.png",

      // Your custom metadata
      // ...
    },
  }
);
```

Modify your `UserMeta` type in `liveblocks.config.ts` to match the metadata
format, adding type hints to your editor.

```ts file="liveblocks.config.ts"
declare global {
  interface Liveblocks {
    UserMeta: {
      id: string;

      info: {
        name: string;
        avatar: string;

        // Your custom metadata
        // ...
      };
    };
  }
}
```

## Resolving users

To show each user’s name and avatar in threads and comments, we need to use
[`resolveUsers`](https://liveblocks.io/docs/api-reference/liveblocks-client#resolveUsers).

<Figure>
  <Image
    src="/assets/tutorials/comments/thread.png"
    alt="Thread with resolved users"
    width={1291}
    height={892}
  />
</Figure>

<Steps>
  <Step>
    <StepTitle>Add the function to your LiveblocksProvider</StepTitle>
    <StepContent>
      The [`resolveUsers`](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers) function is passed as an option to
      [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider)—let’s add it. This function
      provides you with `userIds`, an array of user IDs that have interacted with Comments.
      These `userIds` match the IDs set when authenticating users in your app.

      ```tsx highlight="2-8"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // ["marc@example.com", ...]
          console.log(userIds);

          // Return a list of users
          // ...
        }}

        // ...
      />;
      ```

    </StepContent>

  </Step>
  <Step>
    <StepTitle>Return your users</StepTitle>
    <StepContent>
      `resolveUsers` requires you to return a list of users in
      the `UserMeta["info"]` format we set earlier. Remember
      that _name_ and _avatar_ are required for the default components,
      but you can also use any other metadata in your app.

      ```tsx highlight="7-16"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // ["marc@example.com", ...]
          console.log(userIds);

          // Return a list of users
          return [
            {
              name: "Marc",
              avatar: "https://example.com/marc.png",

              // Your custom metadata
              // ...
            },
            // ...
          ];
        }}

        // ...
      />;
      ```

      We’re only returning one user here, but make sure to return an
      array containing each user, in the same order you received the IDs.

    </StepContent>

  </Step>
  <Step>
    <StepTitle>Real-world example</StepTitle>
    <StepContent>
      In your real application you’ll probably be getting users from your
      API endpoint and database via `fetch`. This is how we’d recommend
      building out this function.

      ```tsx highlight="3-7"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // Get users from your back end
          const users = await __fetchUsers__(userIds);

          // Return a list of users
          return users;
        }}

        // ...
      />;
      ```

    </StepContent>

  </Step>
  <Step lastStep>
    <StepTitle>Users are now visible</StepTitle>
    <StepContent>
      After adding this, you should now be able to see your users in threads!

      <Figure>
        <Image
          src="/assets/tutorials/comments/thread.png"
          alt="Thread with resolved users"
          width={1291}
          height={892}
        />
      </Figure>

    </StepContent>

  </Step>
</Steps>

## Resolving mention suggestions

We can see the users that have commented, but we don’t have a way to search for
users to mention, for example after typing the `@` character. We can create a
simple search that resolves this data with
[`resolveMentionSuggestions`](/docs/api-reference/liveblocks-react#resolveMentionSuggestions).

<Figure>
  <Image
    src="/assets/tutorials/comments/mentions.png"
    alt="Working Comments mentions"
    width={1297}
    height={781}
  />
</Figure>

<Steps>
  <Step>
    <StepTitle>Add the function to your config file</StepTitle>
    <StepContent>
      `resolveMentionSuggestions` is placed alongside `resolveUsers`, and
      provides you with `text`, which is the string that the user is searching for.
      You can use this string to return a list of matching user IDs.

      ```tsx highlight="5-11"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // ...
        }}
        resolveMentionSuggestions={async ({ text, roomId }) => {
          // The text the user is searching for, e.g. "mar"
          console.log(text);

          // Return a list of user IDs that match the query
          return ["marc@example.com", "marissa@example.com"];
        }}

        // ...
      />;
      ```

    </StepContent>

  </Step>
  <Step>
    <StepTitle>Real-world example</StepTitle>
    <StepContent>

      In a real application, you’ll most likely be getting a list of each user,
      before filtering the list by the user’s names or IDs. If `text` is an empty
      string, then you need to return a list of every user, instead of a filtered list.

      ```tsx highlight="6-16"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // ...
        }}
        resolveMentionSuggestions={async ({ text, roomId }) => {
          // Fetch all users from your back end
          let users = await __fetchAllUsers__();

          // If there's a query, filter for the relevant users
          if (text) {
            // Filter any way you'd like, e.g. checking if the name matches
            users = users.filter((user) => user.name.includes(text));
          }

          // Return the filtered `userIds`
          return users.map((user) => user.id);
        }}

        // ...
      />;
      ```

    </StepContent>

  </Step>
  <Step lastStep>
    <StepTitle>Mention suggestions now appear</StepTitle>
    <StepContent>

      Now we’ve found and returned the correct users, Comments can display
      a list of mention suggestions!

      <Figure>
        <Image
          src="/assets/tutorials/comments/mentions.png"
          alt="Working Comments mentions"
          width={1297}
          height={781}
        />
      </Figure>

    </StepContent>

  </Step>
</Steps>

## Next steps

You’re now ready to start building your Comments application! Here’s where you
can learn more:

- [API reference](/docs/api-reference/liveblocks-react#Comments)
- [Component reference](/docs/api-reference/liveblocks-react-ui#Comments)
- [Examples](/examples/browse/comments)

---
meta:
  title: "How to add users to Liveblocks Notifications"
  description:
    "Learn how to add your user’s avatars and names to Liveblocks Notifications
    using the resolver functions."
---

After following the get started guide for Notifications, and sending
notifications with [Comments](/docs/ready-made-features/comments) or
[Text editor](/docs/ready-made-features/text-editor), you’ll notice that each
user is currently “Anonymous”, and that there’s no way to mention or tag other
users. To enable these features, we need to tell Notifications where to find
your users’ information.

<Figure>
  <Image
    src="/assets/notifications/notifications-inbox.jpg"
    alt="Thread"
    width={1344}
    height={896}
  />
</Figure>

## What we’re learning

In this guide we’ll be modifying
[`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider),
learning how to:

- Add names and avatars to notifications with
  [`resolveUsers`](/docs/api-reference/liveblocks-react#resolveUsers).

## Authenticate your application

The first step is to find an
[authentication guide for your framework](/docs/authentication) and authenticate
your app, as this is necessary for Notifications.

Make sure to follow the metadata step in the guide, and attach the name of your
user, along with the URL of their avatar, as these properties will both be used
in the [default components](/docs/ready-made-features/notifications#Components).
Here’s an example using ID token authentication, our recommended method, with an
email address as a user’s ID.

```ts title="Metadata in ID tokens"
const { status, body } = await liveblocks.identifyUser(
  {
    userId: "marc@example.com",

    // Optional
    // tenantId: "org-id",
    // groupIds: ["group-id-1", "group-id-2"],
  },
  {
    userInfo: {
      name: "Marc",
      avatar: "https://example.com/marc.png",

      // Your custom metadata
      // ...
    },
  }
);
```

Modify your `UserMeta` type in `liveblocks.config.ts` to match the metadata
format, adding type hints to your editor.

```ts file="liveblocks.config.ts"
declare global {
  interface Liveblocks {
    UserMeta: {
      id: string;

      info: {
        name: string;
        avatar: string;

        // Your custom metadata
        // ...
      };
    };
  }
}
```

## Resolving users

To show each user’s name and avatar in threads and comments, we need to use
[`resolveUsers`](https://liveblocks.io/docs/api-reference/liveblocks-client#resolveUsers).

<Figure>
  <Image
    src="/assets/notifications/notifications-inbox.jpg"
    alt="Thread"
    width={1344}
    height={896}
  />
</Figure>

<Steps>
  <Step>
    <StepTitle>Add the function to your LiveblocksProvider</StepTitle>
    <StepContent>
      The [`resolveUsers`](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers) function is passed as an option to
      [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider)—let’s add it. This function
      provides you with `userIds`, an array of user IDs that have interacted with Notifications.
      These `userIds` match the IDs set when authenticating users in your app.

      ```tsx highlight="2-8"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // ["marc@example.com", ...]
          console.log(userIds);

          // Return a list of users
          // ...
        }}

        // ...
      />;
      ```

    </StepContent>

  </Step>
  <Step>
    <StepTitle>Return your users</StepTitle>
    <StepContent>
      `resolveUsers` requires you to return a list of users in
      the `UserMeta["info"]` format we set earlier. Remember
      that _name_ and _avatar_ are required for the default components,
      but you can also use any other metadata in your app.

      ```tsx highlight="7-16"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // ["marc@example.com", ...]
          console.log(userIds);

          // Return a list of users
          return [
            {
              name: "Marc",
              avatar: "https://example.com/marc.png",

              // Your custom metadata
              // ...
            },
            // ...
          ];
        }}

        // ...
      />;
      ```

      We’re only returning one user here, but make sure to return an
      array containing each user, in the same order you received the IDs.

    </StepContent>

  </Step>
  <Step>
    <StepTitle>Real-world example</StepTitle>
    <StepContent>
      In your real application you’ll probably be getting users from your
      API endpoint and database via `fetch`. This is how we’d recommend
      building out this function.

      ```tsx highlight="3-7"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // Get users from your back end
          const users = await __fetchUsers__(userIds);

          // Return a list of users
          return users;
        }}

        // ...
      />;
      ```

    </StepContent>

  </Step>
  <Step lastStep>
    <StepTitle>Users are now visible</StepTitle>
    <StepContent>
      After adding this, you should now be able to see your users in notifications!

      <Figure>
        <Image
          src="/assets/notifications/notifications-inbox.jpg"
          alt="Thread"
          width={1344}
          height={896}
        />
      </Figure>

    </StepContent>

  </Step>
</Steps>

## Next steps

You’re now ready to start building your Notifications application! Here’s where
you can learn more:

- [API reference](/docs/api-reference/liveblocks-react#Notifications)
- [Component reference](/docs/api-reference/liveblocks-react-ui#Notifications)
- [Examples](/examples/browse/notifications)

---
meta:
  title: "How to add users to Liveblocks Text Editor"
  description:
    "Learn how to add your user’s avatars and names to Liveblocks Text Editor
    using the resolver functions."
---

After following the get started guide for Text Editor, you’ll notice that each
user is currently “Anonymous”, and that there’s no way to mention or tag other
users. To enable these features, we need to tell Text Editor where to find your
users’ information.

<Figure>
  <Image
    src="/assets/text-editor/mentions.png"
    alt="User mentions"
    width={1312}
    height={874}
  />
</Figure>

## What we’re learning

In this guide we’ll be modifying
[`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider),
learning how to:

- Add names and avatars to cursors and suggestions with
  [`resolveUsers`](/docs/api-reference/liveblocks-react#resolveUsers).
- Create user mention suggestions in the editor using
  [`resolveMentionSuggestions`](/docs/api-reference/liveblocks-react#resolveMentionSuggestions).

## Authenticate your application

The first step is to find an
[authentication guide for your framework](/docs/authentication) and authenticate
your app, as this is necessary for Text Editor.

Make sure to follow the metadata step in the guide, and attach the name of your
user, the color of their cursor, and their avatar URL, as these properties will
both be used in the [Text editor](/docs/ready-made-features/text-editor). Here’s
an example using ID token authentication, our recommended method, with an email
address as a user’s ID.

```ts title="Metadata in ID tokens"
const { status, body } = await liveblocks.identifyUser(
  {
    userId: "marc@example.com",

    // Optional
    // tenantId: "org-id",
    // groupIds: ["group-id-1", "group-id-2"],
  },
  {
    userInfo: {
      name: "Marc",
      avatar: "https://example.com/marc.png",

      // Your custom metadata
      // ...
    },
  }
);
```

Modify your `UserMeta` type in `liveblocks.config.ts` to match the metadata
format, adding type hints to your editor.

```ts file="liveblocks.config.ts"
declare global {
  interface Liveblocks {
    UserMeta: {
      id: string;

      info: {
        name: string;
        avatar: string;

        // Your custom metadata
        // ...
      };
    };
  }
}
```

## Resolving users

To show each user’s name and color in their cursors, we need to use
[`resolveUsers`](https://liveblocks.io/docs/api-reference/liveblocks-client#resolveUsers).

<Figure>
  <Image
    src="/assets/text-editor/cursors.png"
    alt="Real-time text cursors"
    width={1312}
    height={874}
  />
</Figure>

<Steps>
  <Step>
    <StepTitle>Add the function to your LiveblocksProvider</StepTitle>
    <StepContent>
      The [`resolveUsers`](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers) function is passed as an option to
      [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider)—let’s add it. This function
      provides you with `userIds`, an array of user IDs that have interacted with Text Editor.
      These `userIds` match the IDs set when authenticating users in your app.

      ```tsx highlight="2-8"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // ["marc@example.com", ...]
          console.log(userIds);

          // Return a list of users
          // ...
        }}

        // ...
      />;
      ```

    </StepContent>

  </Step>
  <Step>
    <StepTitle>Return your users</StepTitle>
    <StepContent>
      `resolveUsers` requires you to return a list of users in
      the `UserMeta["info"]` format we set earlier. Remember
      that _name_, _color_, and _avatar_, are required for the editor component,
      but you can also use any other metadata in your app.

      ```tsx highlight="7-17"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // ["marc@example.com", ...]
          console.log(userIds);

          // Return a list of users
          return [
            {
              name: "Marc",
              color: "#00ff00",
              avatar: "https://example.com/marc.png",

              // Your custom metadata
              // ...
            },
            // ...
          ];
        }}

        // ...
      />;
      ```

      We’re only returning one user here, but make sure to return an
      array containing each user, in the same order you received the IDs.

    </StepContent>

  </Step>
  <Step>
    <StepTitle>Real-world example</StepTitle>
    <StepContent>
      In your real application you’ll probably be getting users from your
      API endpoint and database via `fetch`. This is how we’d recommend
      building out this function.

      ```tsx highlight="3-7"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // Get users from your back end
          const users = await __fetchUsers__(userIds);

          // Return a list of users
          return users;
        }}

        // ...
      />;
      ```

    </StepContent>

  </Step>
  <Step lastStep>
    <StepTitle>Users are now visible</StepTitle>
    <StepContent>
      After adding this, you should now be able to see your user names in cursors!

      <Figure>
        <Image
          src="/assets/text-editor/cursors.png"
          alt="Real-time text cursors"
          width={1312}
          height={874}
        />
      </Figure>

    </StepContent>

  </Step>
</Steps>

## Resolving mention suggestions

We can see the users that are connected, but we don’t have a way to search for
users to mention inline, for example after typing the `@` character. We can
create a simple search that resolves this data with
[`resolveMentionSuggestions`](/docs/api-reference/liveblocks-react#resolveMentionSuggestions).

<Figure>
  <Image
    src="/assets/text-editor/mentions.png"
    alt="User mentions"
    width={1312}
    height={874}
  />
</Figure>

<Steps>
  <Step>
    <StepTitle>Add the function to your config file</StepTitle>
    <StepContent>
      `resolveMentionSuggestions` is placed alongside `resolveUsers`, and
      provides you with `text`, which is the string that the user is searching for.
      You can use this string to return a list of matching user IDs.

      ```tsx highlight="5-11"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // ...
        }}
        resolveMentionSuggestions={async ({ text, roomId }) => {
          // The text the user is searching for, e.g. "mar"
          console.log(text);

          // Return a list of user IDs that match the query
          return ["marc@example.com", "marissa@example.com"];
        }}

        // ...
      />;
      ```

    </StepContent>

  </Step>
  <Step>
    <StepTitle>Real-world example</StepTitle>
    <StepContent>

      In a real application, you’ll most likely be getting a list of each user,
      before filtering the list by the user’s names or IDs. If `text` is an empty
      string, then you need to return a list of every user, instead of a filtered list.

      ```tsx highlight="6-16"
      <LiveblocksProvider
        resolveUsers={async ({ userIds }) => {
          // ...
        }}
        resolveMentionSuggestions={async ({ text, roomId }) => {
          // Fetch all users from your back end
          let users = await __fetchAllUsers__();

          // If there's a query, filter for the relevant users
          if (text) {
            // Filter any way you'd like, e.g. checking if the name matches
            users = users.filter((user) => user.name.includes(text));
          }

          // Return the filtered `userIds`
          return users.map((user) => user.id);
        }}

        // ...
      />;
      ```

    </StepContent>

  </Step>
  <Step lastStep>
    <StepTitle>Mention suggestions now appear</StepTitle>
    <StepContent>

      Now we’ve found and returned the correct users, Text Editor can display
      a list of mention suggestions!

      <Figure>
        <Image
          src="/assets/text-editor/mentions.png"
          alt="User mentions"
          width={1312}
          height={874}
        />
      </Figure>

    </StepContent>

  </Step>
</Steps>

## Next steps

You’re now ready to start building your Text Editor application! Here’s where
you can learn more:

- [API reference for Lexical React](/docs/api-reference/liveblocks-react-lexical)
- [API reference for Lexical Node](/docs/api-reference/liveblocks-node-lexical)
- [Examples](/examples/browse/text-editor)
- [Overview](/docs/ready-made-features/text-editor)

---
meta:
  title:
    "How to create a collaborative code editor with CodeMirror, Yjs, Next.js,
    and Liveblocks"
  description:
    "Build a collaborative code editor with CodeMirror, Yjs, Next.js, and
    Liveblocks"
---

In this tutorial, we’ll be building a collaborative code editor using
CodeMirror, Yjs, Next.js, and Liveblocks.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/code-editors/code-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

This guide assumes that you’re already familiar with
[React](https://react.dev/), [Next.js](https://nextjs.org/),
[TypeScript](https://www.typescriptlang.org/), and
[CodeMirror](https://codemirror.net/).

## Install CodeMirror, Yjs, and Liveblocks into your Next.js application

<Banner title="Download the example">

On GitHub we have a working example of this
[collaborative CodeMirror editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-codemirror)
for you to download and run.

</Banner>

Run the following command to install the CodeMirror, Yjs, and Liveblocks
packages:

```bash
npm install @liveblocks/client @liveblocks/react @liveblocks/node @liveblocks/yjs yjs codemirror @codemirror/lang-javascript y-codemirror.next
```

## Set up access token authentication

The first step in connecting to Liveblocks is to set up an authentication
endpoint in `/app/api/liveblocks-auth/route.ts`.

```ts
import { Liveblocks } from "@liveblocks/node";
import { NextRequest } from "next/server";

const API_KEY = "{{SECRET_KEY}}";

const liveblocks = new Liveblocks({
  secret: API_KEY!,
});

export async function POST(request: NextRequest) {
  // Get the current user's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = await request.json();
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { body, status } = await session.authorize();
  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's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = request.body;
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { status, body } = await session.authorize();
  response.status(status).send(body);
}
```

## Initialize your Liveblocks config file

Let’s initialize the `liveblocks.config.ts` file in which you’ll set up the
Liveblocks client.

```bash
npx create-liveblocks-app@latest --init --framework react
```

We’ll also need another type for this tutorial. After creating the config file,
open it up and insert the following:

```tsx file="liveblocks.config.ts"
import { LiveblocksYjsProvider } from "@liveblocks/yjs";

// ...

export type TypedLiveblocksProvider = LiveblocksYjsProvider<
  Presence,
  Storage,
  UserMeta,
  RoomEvent
>;
```

## 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="/app/Room.tsx"
"use client";

import { ReactNode } from "react";
import {
  LiveblocksProvider,
  RoomProvider,
  ClientSideSuspense,
} from "@liveblocks/react/suspense";

export function Room({ children }: { children: ReactNode }) {
  return (
    <LiveblocksProvider authEndpoint="/api/liveblocks-auth">
      <RoomProvider id="my-room">
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          {children}
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}
```

## Set up the CodeMirror editor

Now that we’ve set up Liveblocks, we can start integrating Monaco and Yjs in the
`Editor.tsx` file.

```tsx file="Editor.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 { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { TypedLiveblocksProvider, useRoom } from "@/liveblocks.config";
import styles from "./CollaborativeEditor.module.css";

// Collaborative code editor with undo/redo, live cursors, and live avatars
export function CollaborativeEditor() {
  const room = useRoom();
  const [element, setElement] = useState<HTMLElement>();
  const [yUndoManager, setYUndoManager] = useState<Y.UndoManager>();

  const ref = useCallback((node: HTMLElement | null) => {
    if (!node) return;
    setElement(node);
  }, []);

  // Set up Liveblocks Yjs provider and attach CodeMirror editor
  useEffect(() => {
    let provider: TypedLiveblocksProvider;
    let ydoc: Y.Doc;
    let view: EditorView;

    if (!element || !room || !userInfo) {
      return;
    }

    // Create Yjs provider and document
    ydoc = new Y.Doc();
    provider = new LiveblocksYjsProvider(room as any, ydoc);
    const ytext = ydoc.getText("codemirror");
    const undoManager = new Y.UndoManager(ytext);
    setYUndoManager(undoManager);

    // Set up CodeMirror and extensions
    const state = EditorState.create({
      doc: ytext.toString(),
      extensions: [
        basicSetup,
        javascript(),
        yCollab(ytext, provider.awareness, { undoManager }),
      ],
    });

    // Attach CodeMirror to element
    view = new EditorView({
      state,
      parent: element,
    });

    return () => {
      ydoc?.destroy();
      provider?.destroy();
      view?.destroy();
    };
  }, [element, room, userInfo]);

  return (
    <div className={styles.container}>
      <div className={styles.editorContainer} ref={ref}></div>
    </div>
  );
}
```

And here is the `Editor.module.css` file to make sure your multiplayer text
editor looks nice and tidy.

```css file="Editor.module.css" isCollapsed isCollapsable
.container {
  display: flex;
  flex-direction: column;
  position: relative;
  border-radius: 12px;
  background: #fff;
  width: 100%;
  height: 100%;
  color: #111827;
  overflow: hidden;
}

.editorHeader {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.editorContainer {
  position: relative;
  flex-grow: 1;
  overflow: auto;
}
```

## Add your editor to the current page

Next, add the `CollaborativeEditor` into the page file, and place it inside the
`Room` component we created earlier. We should now be seeing a basic
collaborative editor!

```tsx file="/app/page.tsx"
import { Room } from "./Room";
import CollaborativeEditor from "@/components/Editor";

export default function Page() {
  return (
    <Room>
      <CollaborativeEditor />
    </Room>
  );
}
```

## Add live cursors

To add live cursors to the code editor, we can get the `userInfo` for the
current user with [`useSelf`](/docs/api-reference/liveblocks-react#useSelf), and
attach it Yjs awareness. After adding the following, you should see live
cursors:

```tsx file="Cursors.tsx" highlight="1,10-11,35-40"
import { useRoom, useSelf } from "@/liveblocks.config";
// ...

// Collaborative code editor with undo/redo, live cursors, and live avatars
export function CollaborativeEditor() {
  const room = useRoom();
  const [element, setElement] = useState<HTMLElement>();
  const [yUndoManager, setYUndoManager] = useState<Y.UndoManager>();

  // Get user info from Liveblocks authentication endpoint
  const userInfo = useSelf((me) => me.info);

  const ref = useCallback((node: HTMLElement | null) => {
    if (!node) return;
    setElement(node);
  }, []);

  // Set up Liveblocks Yjs provider and attach CodeMirror editor
  useEffect(() => {
    let provider: TypedLiveblocksProvider;
    let ydoc: Y.Doc;
    let view: EditorView;

    if (!element || !room || !userInfo) {
      return;
    }

    // Create Yjs provider and document
    ydoc = new Y.Doc();
    provider = new LiveblocksYjsProvider(room as any, ydoc);
    const ytext = ydoc.getText("codemirror");
    const undoManager = new Y.UndoManager(ytext);
    setYUndoManager(undoManager);

    // Attach user info to Yjs
    provider.awareness.setLocalStateField("user", {
      name: userInfo.name,
      color: userInfo.color,
      colorLight: userInfo.color + "80", // 6-digit hex code at 50% opacity
    });

    // Set up CodeMirror and extensions
    const state = EditorState.create({
      doc: ytext.toString(),
      extensions: [
        basicSetup,
        javascript(),
        yCollab(ytext, provider.awareness, { undoManager }),
      ],
    });

    // Attach CodeMirror to element
    view = new EditorView({
      state,
      parent: element,
    });

    return () => {
      ydoc?.destroy();
      provider?.destroy();
      view?.destroy();
    };
  }, [element, room, userInfo]);

  return (
    <div className={styles.container}>
      <div className={styles.editorContainer} ref={ref}></div>
    </div>
  );
}
```

We can style these cursors by placing CSS in a global CSS file.

```css file="globals.css" isCollapsed isCollapsable
/* Cursor name */
.cm-editor .cm-ySelectionInfo {
  position: absolute;
  top: -1.6em;
  left: -1px;
  padding: 2px 6px;
  opacity: 1;
  color: #fff;
  border: 0;
  border-radius: 6px;
  border-bottom-left-radius: 0;
  line-height: normal;
  white-space: nowrap;
  font-size: 14px;
  font-family: sans-serif;
  font-style: normal;
  font-weight: 600;
  pointer-events: none;
  user-select: none;
  z-index: 1000;
}

.cm-editor .cm-ySelectionCaretDot {
  display: none;
}

/* Other CodeMirror styles */
.cm-editor {
  height: 100%;
  font-size: 14px;
}

.cm-editor.cm-focused {
  outline: none;
}

.cm-editor .cm-scroller {
  padding-top: 1rem;
}

.cm-editor .cm-gutters {
  background: none;
  border: 0;
}

.cm-editor .cm-lineNumbers .cm-gutterElement {
  padding-left: 1rem;
  padding-right: 0.5rem;
}
```

## Add a toolbar

From this point onwards, you can build your CodeMirror app as normal! For
example, should you wish to add a basic undo/redo toolbar to your app:

```tsx file="Toolbar.tsx"
import * as Y from "yjs";
import styles from "./Toolbar.module.css";

type Props = {
  yUndoManager: Y.UndoManager;
};

export function Toolbar({ yUndoManager }: Props) {
  return (
    <div className={styles.toolbar}>
      <button className={styles.button} onClick={() => yUndoManager.undo()}>
        Undo
      </button>
      <button className={styles.button} onClick={() => yUndoManager.redo()}>
        Redo
      </button>
    </div>
  );
}
```

Add some matching styles:

```css file="Toolbar.module.css" isCollapsed isCollapsable
.toolbar {
  display: flex;
  padding: 1em;
  gap: 6px;
}

.button {
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  border-radius: 6px;
  height: 32px;
  width: 32px;
  background: #fff;
  color: #1f2937;
  border: none;
  box-shadow:
    rgba(0, 0, 0, 0.12) 0 4px 8px 0,
    rgba(0, 0, 0, 0.02) 0 0 0 1px;
}

.button:hover {
  color: #111827;
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 5px 8px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}

.button:focus-visible {
  outline-offset: 2px;
}
```

You can then import this into your editor to enable basic CodeMirror features:

```tsx file="Editor.tsx" highlight="1,9-13"
import { Toolbar } from "@/components/Toolbar";
// ...

export function CollaborativeEditor() {
  // ...

  return (
    <div className={styles.container}>
      <div className={styles.editorHeader}>
        <div>
          {yUndoManager ? <Toolbar yUndoManager={yUndoManager} /> : null}
        </div>
      </div>
      <div className={styles.editorContainer} ref={ref}></div>
    </div>
  );
}
```

## Create live avatars with Liveblocks hooks

Along with building out your code editor, you can now use other Liveblocks
features, such as [Presence](/docs/ready-made-features/presence). The
[`useOthers`](/docs/api-reference/liveblocks-react#useOthers) hook allows us to
view information about each user currently online, and we can turn this into a
live avatars component.

```tsx file="Avatars.tsx"
import { useOthers, useSelf } from "@/liveblocks.config";
import styles from "./Avatars.module.css";

export function Avatars() {
  const users = useOthers();
  const currentUser = useSelf();

  return (
    <div className={styles.avatars}>
      {users.map(({ connectionId, info }) => {
        return (
          <Avatar key={connectionId} picture={info.picture} name={info.name} />
        );
      })}

      {currentUser && (
        <div className="relative ml-8 first:ml-0">
          <Avatar
            picture={currentUser.info.picture}
            name={currentUser.info.name}
          />
        </div>
      )}
    </div>
  );
}

export function Avatar({ picture, name }: { picture: string; name: string }) {
  return (
    <div className={styles.avatar} data-tooltip={name}>
      <img
        src={picture}
        className={styles.avatar_picture}
        data-tooltip={name}
      />
    </div>
  );
}
```

And here’s the styles:

```css file="Avatars.module.css" isCollapsed isCollapsable
.avatars {
  display: flex;
  padding: 0 0.75rem;
}

.avatar {
  display: flex;
  place-content: center;
  position: relative;
  border: 4px solid #fff;
  border-radius: 9999px;
  width: 42px;
  height: 42px;
  background-color: #9ca3af;
  margin-left: -0.75rem;
}

.avatar:before {
  content: attr(data-tooltip);
  position: absolute;
  top: 100%;
  opacity: 0;
  transition: opacity 0.15s ease;
  padding: 5px 10px;
  color: white;
  font-size: 0.75rem;
  border-radius: 8px;
  margin-top: 10px;
  z-index: 1;
  background: black;
  white-space: nowrap;
}

.avatar:hover:before {
  opacity: 1;
}

.avatar_picture {
  width: 100%;
  height: 100%;
  border-radius: 9999px;
}
```

You can then import this to your editor to see it in action:

```tsx file="Editor.tsx" highlight="1,13"
import { Avatars } from "@/components/Avatars";
// ...

export function CollaborativeEditor() {
  // ...

  return (
    <div className={styles.container}>
      <div className={styles.editorHeader}>
        <div>
          {yUndoManager ? <Toolbar yUndoManager={yUndoManager} /> : null}
        </div>
        <Avatars />
      </div>
      <div className={styles.editorContainer} ref={ref}></div>
    </div>
  );
}
```

Note that the cursors and avatars match in color and name, as the info for both
is sourced from the Liveblocks authentication endpoint.

## Try it out

You should now see the complete editor, along with live cursors, live avatars,
and some basic features! On GitHub we have a working example of this
[multiplayer code editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-codemirror).

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/code-editors/code-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

---
meta:
  title:
    "How to create a collaborative code editor with Monaco, Yjs, Next.js, and
    Liveblocks"
  description:
    "Build a collaborative code editor with Monaco, Yjs, Next.js, and Liveblocks"
---

In this tutorial, we’ll be building a collaborative code editor using Monaco,
Yjs, Next.js, and Liveblocks.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/code-editors/code-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

This guide assumes that you’re already familiar with
[React](https://react.dev/), [Next.js](https://nextjs.org/),
[TypeScript](https://www.typescriptlang.org/), and
[Monaco](https://microsoft.github.io/monaco-editor/).

## Install Monaco, Yjs, and Liveblocks into your Next.js application

<Banner title="Download the example">

On GitHub we have a working example of this
[collaborative Monaco editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-monaco)
for you to download and run.

</Banner>

Run the following command to install the Monaco, Yjs, and Liveblocks packages:

```bash
npm install @liveblocks/client @liveblocks/react @liveblocks/yjs yjs @monaco-editor/react y-monaco y-protocols
```

## Set up access token authentication

The first step in connecting to Liveblocks is to set up an authentication
endpoint in `/app/api/liveblocks-auth/route.ts`.

```ts
import { Liveblocks } from "@liveblocks/node";
import { NextRequest } from "next/server";

const API_KEY = "{{SECRET_KEY}}";

const liveblocks = new Liveblocks({
  secret: API_KEY!,
});

export async function POST(request: NextRequest) {
  // Get the current user's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = await request.json();
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { body, status } = await session.authorize();
  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's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = request.body;
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { status, body } = await session.authorize();
  response.status(status).send(body);
}
```

## Initialize your Liveblocks config file

Let’s initialize the `liveblocks.config.ts` file in which you’ll set up the
Liveblocks client.

```bash
npx create-liveblocks-app@latest --init --framework react
```

We’ll also need another type for this tutorial. After creating the config file,
open it up and insert the following:

```tsx file="liveblocks.config.ts"
import { LiveblocksYjsProvider } from "@liveblocks/yjs";

// ...

export type TypedLiveblocksProvider = LiveblocksYjsProvider<
  Presence,
  Storage,
  UserMeta,
  RoomEvent
>;
```

## 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="/app/Room.tsx"
"use client";

import { ReactNode } from "react";
import {
  LiveblocksProvider,
  RoomProvider,
  ClientSideSuspense,
} from "@liveblocks/react/suspense";

export function Room({ children }: { children: ReactNode }) {
  return (
    <LiveblocksProvider authEndpoint="/api/liveblocks-auth">
      <RoomProvider id="my-room">
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          {children}
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}
```

## Set up the Monaco editor

Now that we’ve set up Liveblocks, we can start integrating Monaco and Yjs in the
`Editor.tsx` file.

```tsx file="Editor.tsx"
"use client";

import * as Y from "yjs";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { TypedLiveblocksProvider, useRoom } from "@/liveblocks.config";
import { useCallback, useEffect, useState } from "react";
import styles from "./CollaborativeEditor.module.css";
import { Editor } from "@monaco-editor/react";
import { editor } from "monaco-editor";
import { MonacoBinding } from "y-monaco";
import { Awareness } from "y-protocols/awareness";

// Collaborative code editor with undo/redo, live cursors, and live avatars
export function CollaborativeEditor() {
  const room = useRoom();
  const [provider, setProvider] = useState<TypedLiveblocksProvider>();
  const [editorRef, setEditorRef] = useState<editor.IStandaloneCodeEditor>();

  // Set up Liveblocks Yjs provider and attach Monaco editor
  useEffect(() => {
    let yProvider: TypedLiveblocksProvider;
    let yDoc: Y.Doc;
    let binding: MonacoBinding;

    if (editorRef) {
      yDoc = new Y.Doc();
      const yText = yDoc.getText("monaco");
      yProvider = new LiveblocksYjsProvider(room, yDoc);
      setProvider(yProvider);

      // Attach Yjs to Monaco
      binding = new MonacoBinding(
        yText,
        editorRef.getModel() as editor.ITextModel,
        new Set([editorRef]),
        yProvider.awareness as Awareness
      );
    }

    return () => {
      yDoc?.destroy();
      yProvider?.destroy();
      binding?.destroy();
    };
  }, [editorRef, room]);

  const handleOnMount = useCallback((e: editor.IStandaloneCodeEditor) => {
    setEditorRef(e);
  }, []);

  return (
    <div className={styles.container}>
      <div className={styles.editorContainer}>
        <Editor
          onMount={handleOnMount}
          height="100%"
          width="100%"
          theme="vs-light"
          defaultLanguage="typescript"
          defaultValue=""
          options={{
            tabSize: 2,
            padding: { top: 20 },
          }}
        />
      </div>
    </div>
  );
}
```

And here is the `Editor.module.css` file to make sure your multiplayer text
editor looks nice and tidy.

```css file="Editor.module.css" isCollapsed isCollapsable
.container {
  display: flex;
  flex-direction: column;
  position: relative;
  border-radius: 12px;
  background: #fff;
  width: 100%;
  height: 100%;
  color: #111827;
  overflow: hidden;
}

.editorHeader {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.editorContainer {
  position: relative;
  flex-grow: 1;
}
```

## Add your editor to the current page

Next, add the `CollaborativeEditor` into the page file, and place it inside the
`Room` component we created earlier. We should now be seeing a basic
collaborative editor!

```tsx file="/app/page.tsx"
import { Room } from "./Room";
import CollaborativeEditor from "@/components/Editor";

export default function Page() {
  return (
    <Room>
      <CollaborativeEditor />
    </Room>
  );
}
```

## Add live cursors

To add live cursors to the code editor, we can get the `userInfo` for the
current user with [`useSelf`](/docs/api-reference/liveblocks-react#useSelf), and
attach it Yjs awareness. Currently, the only way to style this is to loop
through each Yjs user, and dynamically insert CSS styles into the page, using
`::after` to display users’ names. We’ll place this in a new file:

```tsx file="Cursors.tsx"
import { useEffect, useMemo, useState } from "react";
import {
  AwarenessList,
  TypedLiveblocksProvider,
  UserAwareness,
  useSelf,
} from "@/liveblocks.config";

type Props = {
  yProvider: TypedLiveblocksProvider;
};

export function Cursors({ yProvider }: Props) {
  // Get user info from Liveblocks authentication endpoint
  const userInfo = useSelf((me) => me.info);

  const [awarenessUsers, setAwarenessUsers] = useState<AwarenessList>([]);

  useEffect(() => {
    // Add user info to Yjs awareness
    const localUser: UserAwareness["user"] = userInfo;
    yProvider.awareness.setLocalStateField("user", localUser);

    // On changes, update `awarenessUsers`
    function setUsers() {
      setAwarenessUsers([...yProvider.awareness.getStates()] as AwarenessList);
    }
    yProvider.awareness.on("change", setUsers);
    setUsers();

    return () => {
      yProvider.awareness.off("change", setUsers);
    };
  }, [yProvider]);

  // Insert awareness info into cursors with styles
  const styleSheet = useMemo(() => {
    let cursorStyles = "";

    for (const [clientId, client] of awarenessUsers) {
      if (client?.user) {
        cursorStyles += `
          .yRemoteSelection-${clientId},
          .yRemoteSelectionHead-${clientId}  {
            --user-color: ${client.user.color};
          }

          .yRemoteSelectionHead-${clientId}::after {
            content: "${client.user.name}";
          }
        `;
      }
    }

    return { __html: cursorStyles };
  }, [awarenessUsers]);

  return <style dangerouslySetInnerHTML={styleSheet} />;
}
```

This CSS will work in combination with some other styles, which we can place in
a global CSS file:

```css file="globals.css" isCollapsed isCollapsable
/* Cursor selection background */
.yRemoteSelection {
  opacity: 0.5;
  background-color: var(--user-color);
  margin-right: -1px;
}

/* Cursor caret */
.yRemoteSelectionHead {
  position: absolute;
  box-sizing: border-box;
  height: 100%;
  border-left: 2px solid var(--user-color);
}

/* Cursor name */
.yRemoteSelectionHead::after {
  position: absolute;
  top: -1.4em;
  left: -2px;
  padding: 2px 6px;
  background: var(--user-color);
  color: #fff;
  border: 0;
  border-radius: 6px;
  border-bottom-left-radius: 0;
  line-height: normal;
  white-space: nowrap;
  font-size: 14px;
  font-style: normal;
  font-weight: 600;
  pointer-events: none;
  user-select: none;
  z-index: 1000;
}
```

You can then import this into your editor to enable live cursors:

```tsx file="Editor.tsx" highlight="1,9"
import { Cursors } from "@/components/Cursors";
// ...

export function CollaborativeEditor() {
  // ...

  return (
    <div className={styles.container}>
      {provider ? <Cursors yProvider={provider} /> : null}
      <div className={styles.editorContainer}>
        <Editor
          onMount={handleOnMount}
          height="100%"
          width="100%"
          theme="vs-light"
          defaultLanguage="typescript"
          defaultValue=""
          options={{
            tabSize: 2,
            padding: { top: 20 },
          }}
        />
      </div>
    </div>
  );
}
```

## Add a toolbar

From this point onwards, you can build your Monaco app as normal! For example,
should you wish to add a basic undo/redo toolbar to your app:

```tsx file="Toolbar.tsx"
import styles from "./Toolbar.module.css";
import { editor } from "monaco-editor";

type Props = {
  editor: editor.IStandaloneCodeEditor;
};

export function Toolbar({ editor }: Props) {
  return (
    <div className={styles.toolbar}>
      <button
        className={styles.button}
        onClick={() => editor.trigger("", "undo", null)}
      >
        Undo
      </button>
      <button
        className={styles.button}
        onClick={() => editor.trigger("", "redo", null)}
      >
        Redo
      </button>
    </div>
  );
}
```

Add some matching styles:

```css file="Toolbar.module.css" isCollapsed isCollapsable
.toolbar {
  display: flex;
  padding: 1em;
  gap: 6px;
}

.button {
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  border-radius: 6px;
  height: 32px;
  width: 32px;
  background: #fff;
  color: #1f2937;
  border: none;
  box-shadow:
    rgba(0, 0, 0, 0.12) 0 4px 8px 0,
    rgba(0, 0, 0, 0.02) 0 0 0 1px;
}

.button:hover {
  color: #111827;
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 5px 8px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}

.button:focus-visible {
  outline-offset: 2px;
}

.button:active {
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 2px 3px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}
```

You can then import this into your editor to enable basic Monaco features:

```tsx file="Editor.tsx" highlight="1,10-12"
import { Toolbar } from "@/components/Toolbar";
// ...

export function CollaborativeEditor() {
  // ...

  return (
    <div className={styles.container}>
      {provider ? <Cursors yProvider={provider} /> : null}
      <div className={styles.editorHeader}>
        <div>{editorRef ? <Toolbar editor={editorRef} /> : null}</div>
      </div>
      <div className={styles.editorContainer}>
        <Editor
          onMount={handleOnMount}
          height="100%"
          width="100%"
          theme="vs-light"
          defaultLanguage="typescript"
          defaultValue=""
          options={{
            tabSize: 2,
            padding: { top: 20 },
          }}
        />
      </div>
    </div>
  );
}
```

## Create live avatars with Liveblocks hooks

Along with building out your code editor, you can now use other Liveblocks
features, such as [Presence](/docs/ready-made-features/presence). The
[`useOthers`](/docs/api-reference/liveblocks-react#useOthers) hook allows us to
view information about each user currently online, and we can turn this into a
live avatars component.

```tsx file="Avatars.tsx"
import { useOthers, useSelf } from "@/liveblocks.config";
import styles from "./Avatars.module.css";

export function Avatars() {
  const users = useOthers();
  const currentUser = useSelf();

  return (
    <div className={styles.avatars}>
      {users.map(({ connectionId, info }) => {
        return (
          <Avatar key={connectionId} picture={info.picture} name={info.name} />
        );
      })}

      {currentUser && (
        <div className="relative ml-8 first:ml-0">
          <Avatar
            picture={currentUser.info.picture}
            name={currentUser.info.name}
          />
        </div>
      )}
    </div>
  );
}

export function Avatar({ picture, name }: { picture: string; name: string }) {
  return (
    <div className={styles.avatar} data-tooltip={name}>
      <img
        src={picture}
        className={styles.avatar_picture}
        data-tooltip={name}
      />
    </div>
  );
}
```

And here’s the styles:

```css file="Avatars.module.css" isCollapsed isCollapsable
.avatars {
  display: flex;
  padding: 0 0.75rem;
}

.avatar {
  display: flex;
  place-content: center;
  position: relative;
  border: 4px solid #fff;
  border-radius: 9999px;
  width: 42px;
  height: 42px;
  background-color: #9ca3af;
  margin-left: -0.75rem;
}

.avatar:before {
  content: attr(data-tooltip);
  position: absolute;
  top: 100%;
  opacity: 0;
  transition: opacity 0.15s ease;
  padding: 5px 10px;
  color: white;
  font-size: 0.75rem;
  border-radius: 8px;
  margin-top: 10px;
  z-index: 1;
  background: black;
  white-space: nowrap;
}

.avatar:hover:before {
  opacity: 1;
}

.avatar_picture {
  width: 100%;
  height: 100%;
  border-radius: 9999px;
}
```

You can then import this to your editor to see it in action:

```tsx file="Editor.tsx" highlight="1,12"
import { Avatars } from "@/components/Avatars";
// ...

export function CollaborativeEditor() {
  // ...

  return (
    <div className={styles.container}>
      {provider ? <Cursors yProvider={provider} /> : null}
      <div className={styles.editorHeader}>
        <div>{editorRef ? <Toolbar editor={editorRef} /> : null}</div>
        <Avatars />
      </div>
      <div className={styles.editorContainer}>
        <Editor
          onMount={handleOnMount}
          height="100%"
          width="100%"
          theme="vs-light"
          defaultLanguage="typescript"
          defaultValue=""
          options={{
            tabSize: 2,
            padding: { top: 20 },
          }}
        />
      </div>
    </div>
  );
}
```

Note that the cursors and avatars match in color and name, as the info for both
is sourced from the Liveblocks authentication endpoint.

## Try it out

You should now see the complete editor, along with live cursors, live avatars,
and some basic features! On GitHub we have a working example of this
[multiplayer code editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-monaco).

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/code-editors/code-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

---
meta:
  title:
    "How to create a collaborative online whiteboard with React, Redux, and
    Liveblocks"
  description:
    "Build a multiplayer whiteboard app with React, Redux, and Liveblocks"
---

In this 25-minute tutorial, we’ll be building a collaborative whiteboard app
using React, Redux and Liveblocks. As users add and move rectangles in a canvas,
changes will be automatically synced and persisted, allowing for a canvas that
updates in realtime across clients. Users will also be able to see other users
selections, and undo and redo actions.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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 whiteboard tutorials:

- [React tutorial](/docs/tutorials/collaborative-online-whiteboard/react)
- [React + Zustand tutorial](/docs/tutorials/collaborative-online-whiteboard/react-zustand)

A live demo and the source code for this guide are
[in our examples](/examples/collaborative-whiteboard/redux-whiteboard).

## Install Liveblocks into your project [#install-liveblocks]

### Install Liveblocks packages

Create a new app with [`create-react-app`](https://create-react-app.dev/):

```bash
npx create-react-app redux-whiteboard
```

Then run the following command to install the Liveblocks packages and Redux:

```bash
npm install redux react-redux @reduxjs/toolkit @liveblocks/client @liveblocks/redux
```

### 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](/auth/signup), then navigate to
[the dashboard](/dashboard/apikeys) to find your public key. It should start
with `pk_`.

<Banner title="Public vs secret key">

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).

</Banner>

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";

export const client = createClient({
  publicApiKey: "{{PUBLIC_KEY}}",
});

const initialState = {
  /* default state will go there */
};

const slice = createSlice({
  name: "state",
  initialState,
  reducers: {
    /* Reducer logic will go there */
  },
});

export function makeStore() {
  return configureStore({
    reducer: slice.reducer,
    enhancers: (getDefaultEnhancers) =>
      getDefaultEnhancers().concat(liveblocksEnhancer({ client })),
  });
}

const store = makeStore();

export default store;
```

And edit `src/index.js` to add the react-redux provider to your app:

```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(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  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.

Our enhancer is responsible to enter or leave a room when you dispatch
[`enterRoom`](/docs/api-reference/liveblocks-redux#actions-enter) or
[`leaveRoom`](/docs/api-reference/liveblocks-redux#actions-leave).

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 { useEffect } from "react";
import "./App.css";
import { useDispatch } from "react-redux";
import { actions } from "@liveblocks/redux";

const roomId = "redux-whiteboard";

export default function App() {
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(actions.enterRoom(roomId));

    return () => {
      dispatch(actions.leaveRoom());
    };
  }, [dispatch]);

  return <div className="container">Whiteboard app</div>;
}
```

## Create a canvas [#basic-canvas]

Whiteboard shapes will be stored even after all users disconnect, so we will use
Liveblocks storage to persist them.

Add a `shapes` property to your store, and configure the enhancer to sync and
persist them with Liveblocks.

To achieve that, we are going to use the enhancer option
[`storageMapping: { shapes: true }`](/docs/api-reference/liveblocks-redux#middleware-option-storage-mapping).
It means that the part of the state named `shapes` should be automatically
synced with Liveblocks Storage.

```js file="src/store.js" highlight="10,28"
import { createClient } from "@liveblocks/client";
import { liveblocksEnhancer } from "@liveblocks/redux";
import { configureStore, createSlice } from "@reduxjs/toolkit";

export const client = createClient({
  publicApiKey: "{{PUBLIC_KEY}}",
});

const initialState = {
  shapes: {},
};

const slice = createSlice({
  name: "state",
  initialState,
  reducers: {
    /* Reducer logic will go there */
  },
});

export function makeStore() {
  return configureStore({
    reducer: slice.reducer,
    enhancers: (getDefaultEnhancers) =>
      getDefaultEnhancers().concat(
        liveblocksEnhancer({
          client,
          storageMapping: { scientist: true },
        })
      ),
  });
}

const store = makeStore();

export default store;
```

Afterwards, we draw the shapes in our canvas. To keep it simple for the
tutorial, we are going to only support rectangle.

```jsx file="src/App.js" highlight="3,9-10,25-35,38-48"
import { useEffect } from "react";
import "./App.css";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@liveblocks/redux";

const roomId = "redux-whiteboard";

export default function App() {
  const shapes = useSelector((state) => state.shapes);
  const isLoading = useSelector((state) => state.liveblocks.isStorageLoading);
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(actions.enterRoom(roomId));

    return () => {
      dispatch(actions.leaveRoom());
    };
  }, [dispatch]);

  if (isLoading) {
    return <div className="loading">Loading</div>;
  }

  return (
    <div className="canvas">
      {Object.entries(shapes).map(([shapeId, shape]) => {
        return <Rectangle key={shapeId} shape={shape} />;
      })}
    </div>
  );
}

const Rectangle = ({ shape }) => {
  return (
    <div
      className="rectangle"
      style={{
        transform: `translate(${shape.x}px, ${shape.y}px)`,
        backgroundColor: shape.fill ? shape.fill : "#CCC",
      }}
    ></div>
  );
};
```

_Place the following within `src/App.css`, and then you will be able to insert
rectangular shapes into the whiteboard._

```css file="src/App.css" isCollapsed isCollapsable
body {
  background-color: #eeeeee;
}

.loading {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  width: 100vw;
}

.canvas {
  background-color: #eeeeee;
  touch-action: none;
  width: 100vw;
  height: 100vh;
}

.rectangle {
  position: absolute;
  /* transition: all 0.1s ease; */
  stroke-width: 1;
  border-style: solid;
  border-width: 2px;
  height: 100px;
  width: 100px;
}

.toolbar {
  position: fixed;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  padding: 4px;
  border-radius: 8px;
  box-shadow:
    0px 2px 4px rgba(0, 0, 0, 0.1),
    0px 0px 0px 1px rgba(0, 0, 0, 0.05);
  display: flex;
  background-color: #ffffff;
  user-select: none;
}

.toolbar button {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4px 8px;
  border-radius: 4px;
  background-color: #f8f8f8;
  color: #181818;
  border: none;
  box-shadow:
    0px 2px 4px rgba(0, 0, 0, 0.1),
    0px 0px 0px 1px rgba(0, 0, 0, 0.05);
  margin: 4px;
  font-weight: 500;
  font-size: 12px;
}

.toolbar button:hover,
.toolbar button:focus {
  background-color: #ffffff;
}

.toolbar button:active {
  background-color: #eeeeee;
}
```

## Insert rectangles

Currently our whiteboard is empty, and there’s no way to add rectangles. Let’s
create a button that adds a randomly placed rectangle to the board.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-1.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

Add a new action to your store that randomly insert a rectangle on the board.

```js file="src/store.js" highlight="9-17,27-35,39"
import { createClient } from "@liveblocks/client";
import { liveblocksEnhancer } from "@liveblocks/redux";
import { configureStore, createSlice } from "@reduxjs/toolkit";

export const client = createClient({
  publicApiKey: "{{PUBLIC_KEY}}",
});

const COLORS = ["#DC2626", "#D97706", "#059669", "#7C3AED", "#DB2777"];

function getRandomInt(max) {
  return Math.floor(Math.random() * max);
}

function getRandomColor() {
  return COLORS[getRandomInt(COLORS.length)];
}

const initialState = {
  shapes: {},
};

const slice = createSlice({
  name: "state",
  initialState,
  reducers: {
    insertRectangle: (state) => {
      const shapeId = Date.now().toString();
      const shape = {
        x: getRandomInt(300),
        y: getRandomInt(300),
        fill: getRandomColor(),
      };
      state.shapes[shapeId] = shape;
    },
  },
});

export const { insertRectangle } = slice.actions;

export function makeStore() {
  return configureStore({
    reducer: slice.reducer,
    enhancers: (getDefaultEnhancers) =>
      getDefaultEnhancers().concat(
        liveblocksEnhancer({
          client,
          storageMapping: { shapes: true },
        })
      ),
  });
}

const store = makeStore();

export default store;
```

Then add a button to dispatch this action from the board.

```jsx file="src/App.js" highlight="6,18,24-27"
import { useEffect } from "react";
import "./App.css";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@liveblocks/redux";

import { insertRectangle } from "./store";

const roomId = "redux-whiteboard";

export default function App() {
  const shapes = useSelector((state) => state.shapes);
  const isLoading = useSelector((state) => state.liveblocks.isStorageLoading);
  const dispatch = useDispatch();

  /* ... */

  return (
    <>
      <div className="canvas">
        {Object.entries(shapes).map(([shapeId, shape]) => {
          return <Rectangle key={shapeId} shape={shape} />;
        })}
      </div>
      <div className="toolbar">
        <button onClick={() => dispatch(insertRectangle())}>Rectangle</button>
      </div>
    </>
  );
}

/* Rectangle */
```

## Add selection [#selection]

We can use Liveblocks to display which shape each user is currently selecting,
in this case by adding a border to the rectangles. We’ll use a blue border to
represent the local user, and green borders for remote users.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-2.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

Any online user could select a shape, and we need to keep track of this, so it’s
best if each user holds their own `selectedShape` 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, or in this case the selected shape in a design tool.

We want to add some data to our Redux store, `selectedShape` will contain the
selected shape id. `selectedShape` will be set when the user select or insert a
rectangle.

The middleware option
[`presenceMapping: { selectedShape: true }`](/docs/api-reference/liveblocks-redux#middleware-option-presence-mapping)
means that we want to automatically sync the part of the state named
`selectedShape` to Liveblocks Presence.

```js file="src/store.js" highlight="5,15-17,21,31"
/* ... */

const initialState = {
  shapes: {},
  selectedShape: null,
};

const slice = createSlice({
  name: "state",
  initialState,
  reducers: {
    insertRectangle: (state) => {
      /* ... */
    },
    onShapePointerDown: (state, action) => {
      state.selectedShape = action.payload;
    },
  },
});

export const { insertRectangle, onShapePointerDown } = slice.actions;

export function makeStore() {
  return configureStore({
    reducer: slice.reducer,
    enhancers: (getDefaultEnhancers) =>
      getDefaultEnhancers().concat(
        liveblocksEnhancer({
          client,
          storageMapping: { shapes: true },
          presenceMapping: { selectedShape: true },
        })
      ),
  });
}

const store = makeStore();

export default store;
```

Update your `App` and `Rectangle` components to show if a shape is selected by
the current user or someone else in the room.

```jsx file="src/App.js" highlight="6,13-14,23-31,36,38,50-51,59,61-64"
import { useEffect } from "react";
import "./App.css";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@liveblocks/redux";

import { insertRectangle, onShapePointerDown } from "./store";

const roomId = "redux-whiteboard";

export default function App() {
  const shapes = useSelector((state) => state.shapes);
  const isLoading = useSelector((state) => state.liveblocks.isStorageLoading);
  const others = useSelector((state) => state.liveblocks.others);
  const selectedShape = useSelector((state) => state.selectedShape);
  const dispatch = useDispatch();

  /* ... */

  return (
    <>
      <div className="canvas">
        {Object.entries(shapes).map(([shapeId, shape]) => {
          let selectionColor = "transparent";

          if (selectedShape === shapeId) {
            selectionColor = "blue";
          } else if (
            others.some((user) => user.presence?.selectedShape === shapeId)
          ) {
            selectionColor = "green";
          }

          return (
            <Rectangle
              key={shapeId}
              id={shapeId}
              shape={shape}
              selectionColor={selectionColor}
            />
          );
        })}
      </div>
      <div className="toolbar">
        <button onClick={() => dispatch(insertRectangle())}>Rectangle</button>
      </div>
    </>
  );
}

const Rectangle = ({ shape, selectionColor, id }) => {
  const dispatch = useDispatch();

  return (
    <div
      className="rectangle"
      style={{
        transform: `translate(${shape.x}px, ${shape.y}px)`,
        backgroundColor: shape.fill ? shape.fill : "#CCC",
        borderColor: selectionColor,
      }}
      onPointerDown={(e) => {
        e.stopPropagation();
        dispatch(onShapePointerDown(id));
      }}
    ></div>
  );
};
```

## Delete rectangles

Now that users can select rectangles, we can add a button that allow deleting
rectangles too.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-3.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

Create a `deleteShape` action to remove the selected shape from `shapes`, and
then reset the user’s selection:

```js file="src/store.js" highlight="20,25-30,34"
/* ... */

const initialState = {
  shapes: {},
  selectedShape: null,
};

const slice = createSlice({
  name: "state",
  initialState,
  reducers: {
    insertRectangle: (state) => {
      const shapeId = Date.now().toString();
      const shape = {
        x: getRandomInt(300),
        y: getRandomInt(300),
        fill: getRandomColor(),
      };
      state.shapes[shapeId] = shape;
      state.selectedShape = shapeId;
    },
    onShapePointerDown: (state, action) => {
      /* ... */
    },
    deleteShape: (state) => {
      if (state.selectedShape) {
        delete state.shapes[state.selectedShape];
        state.selectedShape = null;
      }
    },
  },
});

export const { insertRectangle, onShapePointerDown, deleteShape } =
  slice.actions;

export function makeStore() {
  return configureStore({
    reducer: slice.reducer,
    enhancers: (getDefaultEnhancers) =>
      getDefaultEnhancers().concat(
        liveblocksEnhancer({
          client,
          presenceMapping: { selectedShape: true },
          storageMapping: { shapes: true },
        })
      ),
  });
}

const store = makeStore();

export default store;
```

```jsx file="src/App.js" highlight="6,25-30"
import { useEffect } from "react";
import "./App.css";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@liveblocks/redux";

import { insertRectangle, onShapePointerDown, deleteShape } from "./store";

const roomId = "redux-whiteboard";

export default function App() {
  const shapes = useSelector((state) => state.shapes);
  const isLoading = useSelector((state) => state.liveblocks.isStorageLoading);
  const selectedShape = useSelector((state) => state.selectedShape);
  const others = useSelector((state) => state.liveblocks.others);

  const dispatch = useDispatch();

  /* ... */

  return (
    <>
      <div className="canvas">{/* ... */}</div>
      <div className="toolbar">
        <button onClick={() => dispatch(insertRectangle())}>Rectangle</button>
        <button
          onClick={() => dispatch(deleteShape())}
          disabled={selectedShape == null}
        >
          Delete
        </button>
      </div>
    </>
  );
}

/* Rectangle */
```

## Move rectangles

Let’s move some rectangles!

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-4.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

To allow users to move rectangles, we’ll update the `x` and `y` properties of
the selected shape when a user drags it:

```js file="src/store.js" highlight="6,18,23-31,39-40"
/* ... */

const initialState = {
  shapes: {},
  selectedShape: null,
  isDragging: false,
};

const slice = createSlice({
  name: "state",
  initialState,
  reducers: {
    insertRectangle: (state) => {
      /* ... */
    },
    onShapePointerDown: (state, action) => {
      state.selectedShape = action.payload;
      state.isDragging = true;
    },
    deleteShape: (state) => {
      /* ... */
    },
    onCanvasPointerUp: (state) => {
      state.isDragging = false;
    },
    onCanvasPointerMove: (state, action) => {
      if (state.isDragging && state.selectedShape) {
        state.shapes[state.selectedShape].x = action.payload.x - 50;
        state.shapes[state.selectedShape].y = action.payload.y - 50;
      }
    },
  },
});

export const {
  insertRectangle,
  onShapePointerDown,
  deleteShape,
  onCanvasPointerUp,
  onCanvasPointerMove,
} = slice.actions;

export function makeStore() {
  return configureStore({
    reducer: slice.reducer,
    enhancers: (getDefaultEnhancers) =>
      getDefaultEnhancers().concat(
        liveblocksEnhancer({
          client,
          presenceMapping: { selectedShape: true },
          storageMapping: { shapes: true },
        })
      ),
  });
}

const store = makeStore();

export default store;
```

```jsx file="src/App.js" highlight="10-11,30-36"
import { useEffect } from "react";
import "./App.css";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@liveblocks/redux";

import {
  insertRectangle,
  onShapePointerDown,
  deleteShape,
  onCanvasPointerUp,
  onCanvasPointerMove,
} from "./store";

const roomId = "redux-whiteboard";

export default function App() {
  const shapes = useSelector((state) => state.shapes);
  const isLoading = useSelector((state) => state.liveblocks.isStorageLoading);
  const selectedShape = useSelector((state) => state.selectedShape);
  const others = useSelector((state) => state.liveblocks.others);

  const dispatch = useDispatch();

  /* ... */

  return (
    <>
      <div
        className="canvas"
        onPointerMove={(e) => {
          e.preventDefault();
          dispatch(onCanvasPointerMove({ x: e.clientX, y: e.clientY }));
        }}
        onPointerUp={() => {
          dispatch(onCanvasPointerUp());
        }}
      >
        {/* ... */}
      </div>
      <div className="toolbar">{/* ... */}</div>
    </>
  );
}

/* ... */
```

## Multiplayer undo/redo [#undo-redo]

With Liveblocks, you can enable multiplayer undo/redo in just a few lines of
code.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-5.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

Add two buttons to the toolbar and bind them to
[`room.history.undo`](/docs/api-reference/liveblocks-client#Room.history.undo)
and
[`room.history.redo`](/docs/api-reference/liveblocks-client#Room.history.redo).
These functions only impact modifications made to the room’s storage.

```jsx file="src/App.js" highlight="12,48-53"
import { useEffect } from "react";
import "./App.css";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@liveblocks/redux";

import {
  insertRectangle,
  onShapePointerDown,
  deleteShape,
  onCanvasPointerUp,
  onCanvasPointerMove,
  client,
} from "./store";

const roomId = "redux-whiteboard";

export default function App() {
  const shapes = useSelector((state) => state.shapes);
  const isLoading = useSelector((state) => state.liveblocks.isStorageLoading);
  const selectedShape = useSelector((state) => state.selectedShape);
  const others = useSelector((state) => state.liveblocks.others);

  const dispatch = useDispatch();

  /* ... */

  return (
    <>
      <div
        className="canvas"
        onPointerMove={(e) => {
          /* ... */
        }}
        onPointerUp={() => {
          /* ... */
        }}
      >
        {/* ... */}
      </div>
      <div className="toolbar">
        <button onClick={() => dispatch(insertRectangle())}>Rectangle</button>
        <button
          onClick={() => dispatch(deleteShape())}
          disabled={selectedShape == null}
        >
          Delete
        </button>
        <button onClick={() => client.getRoom(roomId).history.undo()}>
          Undo
        </button>
        <button onClick={() => client.getRoom(roomId).history.redo()}>
          Redo
        </button>
      </div>
    </>
  );
}

/* Rectangle */
```

### Pause and resume history [#pause-resume-history]

When a user moves a rectangle, a large number of actions are sent to Liveblocks
and live synced, enabling other users to see movements in realtime.

The problem with this is that the undo button returns the rectangle to the last
intermediary position, and not the position where the rectangle started its
movement. We can fix this by pausing storage history until the move has
completed.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-6.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

We’ll use
[`history.pause`](/docs/api-reference/liveblocks-client#Room.history.pause) to
disable adding any positions to the history stack while the cursors moves, and
then call
[`history.resume`](/docs/api-reference/liveblocks-client#Room.history.resume)
afterwards.

```js file="src/App.js" highlight="36,59"
import { useEffect } from "react";
import "./App.css";
import { useDispatch, useSelector } from "react-redux";
import { actions } from "@liveblocks/redux";

import {
  insertRectangle,
  onShapePointerDown,
  deleteShape,
  onCanvasPointerUp,
  onCanvasPointerMove,
  client,
} from "./store";

const roomId = "redux-whiteboard";

export default function App() {
  const shapes = useSelector((state) => state.shapes);
  const isLoading = useSelector((state) => state.liveblocks.isStorageLoading);
  const selectedShape = useSelector((state) => state.selectedShape);
  const others = useSelector((state) => state.liveblocks.others);

  const dispatch = useDispatch();

  /* ... */

  return (
    <>
      <div
        className="canvas"
        onPointerMove={(e) => {
          /* ... */
        }}
        onPointerUp={() => {
          dispatch(onCanvasPointerUp());
          client.getRoom(roomId).history.resume();
        }}
      >
        {/* ... */}
      </div>
      <div className="toolbar">{/* ... */}</div>
    </>
  );
}

const Rectangle = ({ shape, selectionColor, id }) => {
  const dispatch = useDispatch();

  return (
    <div
      className="rectangle"
      style={{
        transform: `translate(${shape.x}px, ${shape.y}px)`,
        backgroundColor: shape.fill ? shape.fill : "#CCC",
        borderColor: selectionColor,
      }}
      onPointerDown={(e) => {
        e.stopPropagation();
        client.getRoom(roomId).history.pause();
        dispatch(onShapePointerDown(id));
      }}
    ></div>
  );
};
```

Voilà! We have a working collaborative whiteboard app, 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).

<Figure>
  <Image
    src="/assets/tutorials/whiteboard/tutorial-whiteboard-dashboard.png"
    alt="Liveblocks dashboard"
    width={1336}
    height={822}
    quality={80}
  />
</Figure>

## Next steps

- [API reference](/docs/api-reference/liveblocks-redux)
- [Authentication](/docs/authentication)

---
meta:
  title:
    "How to create a collaborative online whiteboard with React, Zustand, and
    Liveblocks"
  description:
    "Build a multiplayer whiteboard app with React, Zustand, and Liveblocks"
---

In this 25-minute tutorial, we’ll be building a collaborative whiteboard app
using React, Zustand and Liveblocks. As users add and move rectangles in a
canvas, changes will be automatically synced and persisted, allowing for a
canvas that updates in realtime across clients. Users will also be able to see
other users selections, and undo and redo actions.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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
whiteboard tutorials:

- [React tutorial](/docs/tutorials/collaborative-online-whiteboard/react)
- [React + Redux tutorial](/docs/tutorials/collaborative-online-whiteboard/react-redux)

A live demo and the source code for this guide are
[in our examples](/examples/collaborative-whiteboard/zustand-whiteboard).

## Install Liveblocks into your project [#install-liveblocks]

### Install Liveblocks packages

Create a new app with [`create-react-app`](https://create-react-app.dev/):

```bash
npx create-react-app zustand-whiteboard --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
```

### 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](/auth/signup), then navigate to
[the dashboard](/dashboard/apikeys) to find your public key. It should start
with `pk_`.

<Banner title="Public vs secret key">

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).

</Banner>

Create a new file `src/store.ts` and initialize the Liveblocks client with your
public API key. Then add our
[`liveblocks` middleware](/docs/api-reference/liveblocks-zustand#middleware) 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<WithLiveblocks<State>>()(
  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 multiplayer 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 { useEffect } from "react";
import useStore from "./store";

import "./App.css";

export default function App() {
  const enterRoom = useStore((state) => state.liveblocks.enterRoom);
  const leaveRoom = useStore((state) => state.liveblocks.leaveRoom);

  useEffect(() => {
    enterRoom("zustand-whiteboard");
    return () => {
      leaveRoom("zustand-whiteboard");
    };
  }, [enterRoom, leaveRoom]);

  return <div className="container">Whiteboard app</div>;
}
```

## Create a canvas [#basic-canvas]

Whiteboard shapes will be stored even after all users disconnect, so we will use
Liveblocks storage to persist them.

Add a `shapes` property to your store, and tell the middleware to sync and
persist them with Liveblocks.

To achieve that, we are going to use the middleware option
[`storageMapping: { shapes: true }`](/docs/api-reference/liveblocks-zustand#middleware-option-storage-mapping).
It means that the part of the state named `shapes` should be automatically
synced with Liveblocks Storage.

```ts file="src/store.ts" highlight="6-10,13,23,27"
import create from "zustand";
import { createClient } from "@liveblocks/client";
import { liveblocks } from "@liveblocks/zustand";
import type { WithLiveblocks } from "@liveblocks/zustand";

export type Shape = {
  x: number;
  y: number;
  fill: string;
};

type State = {
  shapes: Record<string, Shape>;
};

const client = createClient({
  publicApiKey: "{{PUBLIC_KEY}}",
});

const useStore = create<WithLiveblocks<State>>()(
  liveblocks(
    (set) => ({
      shapes: {},
    }),
    {
      client,
      storageMapping: { shapes: true },
    }
  )
);

export default useStore;
```

Afterwards, we draw the shapes in our canvas. To keep it simple for the
tutorial, we are going to only support rectangle.

```tsx file="src/App.tsx" highlight="8,11,20-30,33-43"
import { useEffect } from "react";
import useStore from "./store";
import type { Shape } from "./store";

import "./App.css";

export default function App() {
  const shapes = useStore((state) => state.shapes);
  const enterRoom = useStore((state) => state.liveblocks.enterRoom);
  const leaveRoom = useStore((state) => state.liveblocks.leaveRoom);
  const isLoading = useStore((state) => state.liveblocks.isStorageLoading);

  useEffect(() => {
    enterRoom("zustand-whiteboard");
    return () => {
      leaveRoom("zustand-whiteboard");
    };
  }, [enterRoom, leaveRoom]);

  if (isLoading) {
    return <div className="loading">Loading...</div>;
  }

  return (
    <div className="canvas">
      {Object.entries(shapes).map(([shapeId, shape]) => {
        return <Rectangle key={shapeId} shape={shape} />;
      })}
    </div>
  );
}

const Rectangle = ({ shape }: { shape: Shape }) => {
  return (
    <div
      className="rectangle"
      style={{
        transform: `translate(${shape.x}px, ${shape.y}px)`,
        backgroundColor: shape.fill ? shape.fill : "#CCC",
      }}
    ></div>
  );
};
```

_Place the following within `src/App.css`, and then you will be able to insert
rectangular shapes into the whiteboard._

```css file="src/App.css" isCollapsed isCollapsable
body {
  background-color: #eeeeee;
}

.loading {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  width: 100vw;
}

.canvas {
  background-color: #eeeeee;
  touch-action: none;
  width: 100vw;
  height: 100vh;
}

.rectangle {
  position: absolute;
  /* transition: all 0.1s ease; */
  stroke-width: 1;
  border-style: solid;
  border-width: 2px;
  height: 100px;
  width: 100px;
}

.toolbar {
  position: fixed;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  padding: 4px;
  border-radius: 8px;
  box-shadow:
    0px 2px 4px rgba(0, 0, 0, 0.1),
    0px 0px 0px 1px rgba(0, 0, 0, 0.05);
  display: flex;
  background-color: #ffffff;
  user-select: none;
}

.toolbar button {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4px 8px;
  border-radius: 4px;
  background-color: #f8f8f8;
  color: #181818;
  border: none;
  box-shadow:
    0px 2px 4px rgba(0, 0, 0, 0.1),
    0px 0px 0px 1px rgba(0, 0, 0, 0.05);
  margin: 4px;
  font-weight: 500;
  font-size: 12px;
}

.toolbar button:hover,
.toolbar button:focus {
  background-color: #ffffff;
}

.toolbar button:active {
  background-color: #eeeeee;
}
```

## Insert rectangles

Currently our whiteboard is empty, and there’s no way to add rectangles. Let’s
create a button that adds a randomly placed rectangle to the board.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-1.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

Add a new function to your store that randomly insert a rectangle on the board.

```ts file="src/store.ts" highlight="14,21-29,33,35-48"
import create from "zustand";
import { createClient } from "@liveblocks/client";
import { liveblocks } from "@liveblocks/zustand";
import type { WithLiveblocks } from "@liveblocks/zustand";

export type Shape = {
  x: number;
  y: number;
  fill: string;
};

type State = {
  shapes: Record<string, Shape>;
  insertRectangle: () => void;
};

const client = createClient({
  publicApiKey: "{{PUBLIC_KEY}}",
});

const COLORS = ["#DC2626", "#D97706", "#059669", "#7C3AED", "#DB2777"];

function getRandomInt(max: number) {
  return Math.floor(Math.random() * max);
}

function getRandomColor() {
  return COLORS[getRandomInt(COLORS.length)];
}

const useStore = create<WithLiveblocks<State>>()(
  liveblocks(
    (set, get) => ({
      shapes: {},
      insertRectangle: () => {
        const { shapes } = get();

        const shapeId = Date.now().toString();
        const shape = {
          x: getRandomInt(300),
          y: getRandomInt(300),
          fill: getRandomColor(),
        };

        set({
          shapes: { ...shapes, [shapeId]: shape },
        });
      },
    }),
    {
      client,
      storageMapping: { shapes: true },
    }
  )
);

export default useStore;
```

Then add a button to call this function from the board.

```tsx file="src/App.tsx" highlight="8,13,19-22"
import { useEffect } from "react";
import useStore from "./store";

import "./App.css";

export default function App() {
  const shapes = useStore((state) => state.shapes);
  const insertRectangle = useStore((state) => state.insertRectangle);

  /* ... */

  return (
    <>
      <div className="canvas">
        {Object.entries(shapes).map(([shapeId, shape]) => {
          return <Rectangle key={shapeId} shape={shape} />;
        })}
      </div>
      <div className="toolbar">
        <button onClick={insertRectangle}>Rectangle</button>
      </div>
    </>
  );
}

/* Rectangle */
```

## Add selection [#selection]

We can use Liveblocks to display which shape each user is currently selecting,
in this case by adding a border to the rectangles. We’ll use a blue border to
represent the local user, and green borders for remote users.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-2.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

Any online user could select a shape, and we need to keep track of this, so it’s
best if each user holds their own `selectedShape` 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, or in this case the selected shape in a design tool.

We want to add some data to our Zustand store, `selectedShape` will contain the
selected shape id. `selectedShape` will be set when the user select or insert a
rectangle.

The middleware option
[`presenceMapping: { selectedShape: true }`](/docs/api-reference/liveblocks-zustand#middleware-option-presence-mapping)
means that we want to automatically sync the part of the state named
`selectedShape` to Liveblocks Presence.

```ts file="src/store.ts" highlight="5,7,16,29,32-34,39"
/* ... */

type State = {
  shapes: Record<string, Shape>;
  selectedShape: string | null;
  insertRectangle: () => void;
  onShapePointerDown: (shapeId: string | null) => void;
};

/* ... */

const useStore = create<WithLiveblocks<State>>()(
  liveblocks(
    (set, get) => ({
      shapes: {},
      selectedShape: null,
      insertRectangle: () => {
        const { shapes } = get();

        const shapeId = Date.now().toString();
        const shape = {
          x: getRandomInt(300),
          y: getRandomInt(300),
          fill: getRandomColor(),
        };

        set({
          shapes: { ...shapes, [shapeId]: shape },
          selectedShape: shapeId,
        });
      },
      onShapePointerDown: (shapeId) => {
        set({ selectedShape: shapeId });
      },
    }),
    {
      client,
      storageMapping: { shapes: true },
      presenceMapping: { selectedShape: true },
    }
  )
);

export default useStore;
```

Update your `App` and `Rectangle` components to show if a shape is selected by
the current user or someone else in the room.

```tsx file="src/App.tsx" highlight="9,10,18-26,31,33,45-51,59,61-64"
import { useEffect } from "react";
import useStore from "./store";

import "./App.css";

export default function App() {
  const shapes = useStore((state) => state.shapes);
  const insertRectangle = useStore((state) => state.insertRectangle);
  const others = useStore((state) => state.liveblocks.others);
  const selectedShape = useStore((state) => state.selectedShape);

  /* ... */

  return (
    <>
      <div className="canvas">
        {Object.entries(shapes).map(([shapeId, shape]) => {
          let selectionColor = "transparent";

          if (selectedShape === shapeId) {
            selectionColor = "blue";
          } else if (
            others.some((user) => user.presence?.selectedShape === shapeId)
          ) {
            selectionColor = "green";
          }

          return (
            <Rectangle
              key={shapeId}
              id={shapeId}
              shape={shape}
              selectionColor={selectionColor}
            />
          );
        })}
      </div>
      <div className="toolbar">
        <button onClick={insertRectangle}>Rectangle</button>
      </div>
    </>
  );
}

const Rectangle = (props: {
  id: string;
  shape: Shape;
  selectionColor: string;
}) => {
  const { id, shape, selectionColor } = props;
  const onShapePointerDown = useStore((state) => state.onShapePointerDown);

  return (
    <div
      className="rectangle"
      style={{
        transform: `translate(${shape.x}px, ${shape.y}px)`,
        backgroundColor: shape.fill ? shape.fill : "#CCC",
        borderColor: selectionColor,
      }}
      onPointerDown={(e) => {
        e.stopPropagation();
        onShapePointerDown(id);
      }}
    ></div>
  );
};
```

## Delete rectangles

Now that users can select rectangles, we can add a button that allow deleting
rectangles too.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-3.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

Add a `deleteShape` to remove the selected shape from `shapes`, and then reset
the user’s selection:

```ts file="src/store.ts" highlight="8,24-35"
/* ... */

type State = {
  shapes: Record<string, Shape>;
  selectedShape: string | null;
  insertRectangle: () => void;
  onShapePointerDown: (shapeId: string | null) => void;
  deleteShape: () => void;
};

/* ... */

const useStore = create<WithLiveblocks<State>>()(
  liveblocks(
    (set, get) => ({
      shapes: {},
      selectedShape: null,
      insertRectangle: () => {
        /* ... */
      },
      onShapePointerDown: (shapeId) => {
        /* ... */
      },
      deleteShape: () => {
        const { shapes, selectedShape } = get();
        if (!selectedShape) {
          /* Nothing todo */
          return;
        }
        const { [selectedShape]: shapeToDelete, ...newShapes } = shapes;
        set({
          shapes: newShapes,
          selectedShape: null,
        });
      },
    }),
    {
      client,
      storageMapping: { shapes: true },
      presenceMapping: { selectedShape: true },
    }
  )
);

export default useStore;
```

```tsx file="src/App.ts" highlight="11,20-22"
import { useEffect } from "react";
import useStore from "./store";

import "./App.css";

export default function App() {
  const shapes = useStore((state) => state.shapes);
  const insertRectangle = useStore((state) => state.insertRectangle);
  const others = useStore((state) => state.liveblocks.others);
  const selectedShape = useStore((state) => state.selectedShape);
  const deleteShape = useStore((state) => state.deleteShape);

  /* ... */

  return (
    <>
      <div className="canvas">{/* ... */}</div>
      <div className="toolbar">
        <button onClick={insertRectangle}>Rectangle</button>
        <button onClick={deleteShape} disabled={selectedShape === null}>
          Delete
        </button>
      </div>
    </>
  );
}

/* Rectangle */
```

## Move rectangles

Let’s move some rectangles!

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-4.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

To allow users to move rectangles, we’ll update the `x` and `y` properties of
the selected shape when a user drags it:

```ts file="src/store.ts" highlight="6,10-11,21,27,32-58"
/* ... */

type State = {
  shapes: Record<string, Shape>;
  selectedShape: string | null;
  isDragging: boolean;
  insertRectangle: () => void;
  onShapePointerDown: (shapeId: string | null) => void;
  deleteShape: () => void;
  onCanvasPointerUp: () => void;
  onCanvasPointerMove: (e: React.PointerEvent) => void;
};

/* ... */

const useStore = create<WithLiveblocks<State>>()(
  liveblocks(
    (set, get) => ({
      shapes: {},
      selectedShape: null,
      isDragging: false,

      insertRectangle: () => {
        /* ... */
      },
      onShapePointerDown: (shapeId) => {
        set({ selectedShape: shapeId, isDragging: true });
      },
      deleteShape: () => {
        /* ... */
      },
      onCanvasPointerUp: () => {
        set({ isDragging: false });
      },
      onCanvasPointerMove: (e) => {
        e.preventDefault();

        const { isDragging, shapes, selectedShape } = get();
        if (!selectedShape) {
          /* Nothing todo */
          return;
        }

        const shape = shapes[selectedShape];

        if (shape && isDragging) {
          set({
            shapes: {
              ...shapes,
              [selectedShape]: {
                ...shape,
                x: e.clientX - 50,
                y: e.clientY - 50,
              },
            },
          });
        }
      },
    }),
    {
      client,
      storageMapping: { shapes: true },
      presenceMapping: { selectedShape: true },
    }
  )
);

export default useStore;
```

```tsx file="src/App.tsx" highlight="12,13,21,22"
import { useEffect } from "react";
import useStore from "./store";

import "./App.css";

export default function App() {
  const shapes = useStore((state) => state.shapes);
  const insertRectangle = useStore((state) => state.insertRectangle);
  const others = useStore((state) => state.liveblocks.others);
  const selectedShape = useStore((state) => state.selectedShape);
  const deleteShape = useStore((state) => state.deleteShape);
  const onCanvasPointerMove = useStore((state) => state.onCanvasPointerMove);
  const onCanvasPointerUp = useStore((state) => state.onCanvasPointerUp);

  /* ... */

  return (
    <>
      <div
        className="canvas"
        onPointerMove={onCanvasPointerMove}
        onPointerUp={onCanvasPointerUp}
      >
        {/* ... */}
      </div>
      <div className="toolbar">{/* ... */}</div>
    </>
  );
}

/* Rectangle */
```

## Multiplayer undo/redo [#undo-redo]

With Liveblocks, you can enable multiplayer undo/redo in just a few lines of
code.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-5.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

Add two buttons to the toolbar and bind them to
[`room.history.undo`](/docs/api-reference/liveblocks-client#Room.history.undo)
and
[`room.history.redo`](/docs/api-reference/liveblocks-client#Room.history.redo).
These functions only impact modifications made to the room’s storage.

```tsx file="src/App.tsx" highlight="14,15,33,34"
import { useEffect } from "react";
import useStore from "./store";

import "./App.css";

export default function App() {
  const shapes = useStore((state) => state.shapes);
  const insertRectangle = useStore((state) => state.insertRectangle);
  const others = useStore((state) => state.liveblocks.others);
  const selectedShape = useStore((state) => state.selectedShape);
  const deleteShape = useStore((state) => state.deleteShape);
  const onCanvasPointerMove = useStore((state) => state.onCanvasPointerMove);
  const onCanvasPointerUp = useStore((state) => state.onCanvasPointerUp);
  const undo = useStore((state) => state.liveblocks.room?.history.undo);
  const redo = useStore((state) => state.liveblocks.room?.history.redo);

  /* ... */

  return (
    <>
      <div
        className="canvas"
        onPointerMove={onCanvasPointerMove}
        onPointerUp={onCanvasPointerUp}
      >
        {/* ... */}
      </div>
      <div className="toolbar">
        <button onClick={insertRectangle}>Rectangle</button>
        <button onClick={deleteShape} disabled={selectedShape === null}>
          Delete
        </button>
        <button onClick={undo}>Undo</button>
        <button onClick={redo}>Redo</button>
      </div>
    </>
  );
}

/* Rectangle */
```

### Pause and resume history [#pause-resume-history]

When a user moves a rectangle, a large number of actions are sent to Liveblocks
and live synced, enabling other users to see movements in realtime.

The problem with this is that the undo button returns the rectangle to the last
intermediary position, and not the position where the rectangle started its
movement. We can fix this by pausing storage history until the move has
completed.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/whiteboard/tutorial-whiteboard-6.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

We’ll use
[`history.pause`](/docs/api-reference/liveblocks-client#Room.history.pause) to
disable adding any positions to the history stack while the cursors moves, and
then call
[`history.resume`](/docs/api-reference/liveblocks-client#Room.history.resume)
afterwards.

```ts file="src/store.ts" highlight="14,22"
/* ... */

const useStore = create<WithLiveblocks<State>>()(
  liveblocks(
    (set, get) => ({
      isDragging: false,
      shapes: {},
      selectedShape: null,

      insertRectangle: () => {
        /* ... */
      },
      onShapePointerDown: (shapeId) => {
        get().liveblocks.room?.history.pause();
        set({ selectedShape: shapeId, isDragging: true });
      },
      deleteShape: () => {
        /* ... */
      },
      onCanvasPointerUp: () => {
        set({ isDragging: false });
        get().liveblocks.room?.history.resume();
      },
      onCanvasPointerMove: (e) => {
        /* ... */
      },
    }),
    {
      client,
      storageMapping: { shapes: true },
      presenceMapping: { selectedShape: true },
    }
  )
);

export default useStore;
```

### Add selection to history [#selection-history]

By default, presence updates are not added to the room’s history. Let’s add the
current user selection to the room’s history to improve our undo/redo behavior.

To accomplish that, use
[`room.updatePresence`](/docs/api-reference/liveblocks-client#Room.updatePresence)
with the option `addToHistory` to update `selectedShape`. Liveblocks middleware
will update store `selectedShape` for you.

```ts file="src/store.ts" highlight="11,20-26,29-35,38,44-48"
/* ... */

const useStore = create<WithLiveblocks<State>>()(
  liveblocks(
    (set, get) => ({
      isDragging: false,
      shapes: {},
      selectedShape: null,

      insertRectangle: () => {
        const { shapes, liveblocks } = get();

        const shapeId = Date.now().toString();
        const shape = {
          x: getRandomInt(300),
          y: getRandomInt(300),
          fill: getRandomColor(),
        };

        liveblocks.room?.updatePresence(
          { selectedShape: shapeId },
          { addToHistory: true }
        );
        set({
          shapes: { ...shapes, [shapeId]: shape },
        });
      },
      onShapePointerDown: (shapeId) => {
        const room = get().liveblocks.room;
        room?.history.pause();
        room?.updatePresence(
          { selectedShape: shapeId },
          { addToHistory: true }
        );
        set({ isDragging: true });
      },
      deleteShape: () => {
        const { shapes, selectedShape, liveblocks } = get();
        if (!selectedShape) {
          /* Nothing todo */
          return;
        }
        const { [selectedShape]: shapeToDelete, ...newShapes } = shapes;
        liveblocks.room?.updatePresence(
          { selectedShape: null },
          { addToHistory: true }
        );
        set({ shapes: newShapes });
      },
      onCanvasPointerUp: () => {
        /* ... */
      },
      onCanvasPointerMove: (e) => {
        /* ... */
      },
    }),
    {
      client,
      storageMapping: { shapes: true },
      presenceMapping: { selectedShape: true },
    }
  )
);

export default useStore;
```

Voilà! We have a working collaborative whiteboard app, 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).

<Figure>
  <Image
    src="/assets/tutorials/whiteboard/tutorial-whiteboard-dashboard.png"
    alt="Liveblocks dashboard"
    width={1336}
    height={822}
    quality={80}
  />
</Figure>

## Next steps

- [API reference](/docs/api-reference/liveblocks-zustand)
- [Authentication](/docs/authentication)

---
meta:
  title:
    "How to create a collaborative text editor with Lexical, Yjs, Next.js, and
    Liveblocks"
  description:
    "Build a collaborative text editor with Lexical, Yjs, Next.js, and
    Liveblocks"
---

In this tutorial, we’ll be building a collaborative text editor using Lexical,
Yjs, Next.js, and Liveblocks.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/text-editors/text-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

<Banner title="No longer recommended" type="warning">

We now have an official Lexical package, and we recommend you use this instead
of following this guide. Read more in our
[Lexical getting started guide](/docs/get-started/nextjs-lexical). However,
should you like to set up Lexical manually with Yjs, without using our full
feature set, continue reading.

</Banner>

This guide assumes that you’re already familiar with
[React](https://react.dev/), [Next.js](https://nextjs.org),
[TypeScript](https://www.typescriptlang.org/), and
[Lexical](https://lexical.dev).

## Install Lexical, Yjs, and Liveblocks into your React application

<Banner title="Download the example">

On GitHub we have a working example of this
[collaborative Lexical editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-lexical)
for you to download and run.

</Banner>

Run the following command to install the Lexical, Yjs, and Liveblocks packages:

```bash
npm install lexical @lexical/react @lexical/yjs @liveblocks/client @liveblocks/react @liveblocks/yjs yjs
```

## Set up access token authentication

The first step in connecting to Liveblocks is to set up an authentication
endpoint in `/app/api/liveblocks-auth/route.ts`.

```ts
import { Liveblocks } from "@liveblocks/node";
import { NextRequest } from "next/server";

const API_KEY = "{{SECRET_KEY}}";

const liveblocks = new Liveblocks({
  secret: API_KEY!,
});

export async function POST(request: NextRequest) {
  // Get the current user's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = await request.json();
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { body, status } = await session.authorize();
  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's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = request.body;
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { status, body } = await session.authorize();
  response.status(status).send(body);
}
```

## Initialize your Liveblocks config file

Let’s initialize the `liveblocks.config.ts` file in which you’ll set up the
Liveblocks client.

```bash
npx create-liveblocks-app@latest --init --framework 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="/app/Room.tsx"
"use client";

import { ReactNode } from "react";
import {
  LiveblocksProvider,
  RoomProvider,
  ClientSideSuspense,
} from "@liveblocks/react/suspense";

export function Room({ children }: { children: ReactNode }) {
  return (
    <LiveblocksProvider authEndpoint="/api/liveblocks-auth">
      <RoomProvider id="my-room">
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          {children}
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}
```

## Set up the Lexical editor

Now that we set up Liveblocks, we can start integrating Lexical and Yjs in the
`Editor.tsx` file.

```tsx file="Editor.tsx"
"use client";

import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import * as Y from "yjs";
import { useRoom } from "@/liveblocks.config";
import styles from "./Editor.module.css";
import {
  $createParagraphNode,
  $createTextNode,
  $getRoot,
  LexicalEditor,
} from "lexical";
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 { CollaborationPlugin } from "@lexical/react/LexicalCollaborationPlugin";
import { Provider } from "@lexical/yjs";

// Set up editor config
const initialConfig = {
  // NOTE: This is critical for collaboration plugin to set editor state to null. It
  // would indicate that the editor should not try to set any default state
  // (not even empty one), and let collaboration plugin do it instead
  editorState: null,
  namespace: "Demo",
  nodes: [],
  onError: (error: unknown) => {
    throw error;
  },
};

// Define initial editor state
function initialEditorState(editor: LexicalEditor): void {
  const root = $getRoot();
  const paragraph = $createParagraphNode();
  const text = $createTextNode();
  paragraph.append(text);
  root.append(paragraph);
}

// Collaborative text editor with simple rich text
export default function Editor() {
  // Get Liveblocks room
  const room = useRoom();

  return (
    <div className={styles.container}>
      <LexicalComposer initialConfig={initialConfig}>
        <div className={styles.editorContainer}>
          <RichTextPlugin
            contentEditable={<ContentEditable className={styles.editor} />}
            placeholder={
              <p className={styles.placeholder}>Start typing here…</p>
            }
            ErrorBoundary={LexicalErrorBoundary}
          />
          <CollaborationPlugin
            id="yjs-plugin"
            providerFactory={(id, yjsDocMap) => {
              // Set up Liveblocks Yjs provider
              const doc = new Y.Doc();
              yjsDocMap.set(id, doc);
              const provider = new LiveblocksYjsProvider(room, doc) as Provider;
              return provider;
            }}
            initialEditorState={initialEditorState}
            shouldBootstrap={true}
          />
        </div>
      </LexicalComposer>
    </div>
  );
}
```

And here is the `Editor.module.css` file to make sure your multiplayer text
editor looks nice and tidy.

```css file="Editor.module.css" isCollapsed isCollapsable
.container {
  display: flex;
  flex-direction: column;
  position: relative;
  border-radius: 12px;
  background: #fff;
  width: 100%;
  height: 100%;
  color: #111827;
}

.editorHeader {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.editor {
  border-radius: inherit;
  flex-grow: 1;
  width: 100%;
  height: 100%;
  padding: 1rem;
}

.editor:focus {
  outline: none;
}

.editorContainer {
  position: relative;
  height: 100%;
}

.placeholder {
  pointer-events: none;
  position: absolute;
  inset: 0;
  opacity: 0.3;
  padding: 0 1em;
}
```

## Add live cursors

To add live cursors to the text editor, we can pass the current user’s
information from our authentication endpoint into `CollaborationPlugin`.

```tsx file="Editor.tsx" highlight="1,7,22-23"
import { useSelf } from "../liveblocks.config";
// ...

export default function Editor() {
  // Get Liveblocks room, and user info from Liveblocks authentication endpoint
  const room = useRoom();
  const userInfo = useSelf((me) => me.info);

  return (
    <div className={styles.container}>
      <LexicalComposer initialConfig={initialConfig}>
        <div className={styles.editorContainer}>
          <RichTextPlugin
            contentEditable={<ContentEditable className={styles.editor} />}
            placeholder={
              <p className={styles.placeholder}>Start typing here…</p>
            }
            ErrorBoundary={LexicalErrorBoundary}
          />
          <CollaborationPlugin
            id="yjs-plugin"
            cursorColor={userInfo.color}
            username={userInfo.name}
            providerFactory={(id, yjsDocMap) => {
              // Set up Liveblocks Yjs provider
              const doc = new Y.Doc();
              yjsDocMap.set(id, doc);
              const provider = new LiveblocksYjsProvider(room, doc) as Provider;
              return provider;
            }}
            initialEditorState={initialEditorState}
            shouldBootstrap={true}
          />
        </div>
      </LexicalComposer>
    </div>
  );
}
```

## Add a toolbar

From this point onwards, you can build your Lexical app as normal! For example,
should you wish to add a basic text-style toolbar to your app:

```tsx file="Toolbar.tsx"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { FORMAT_TEXT_COMMAND } from "lexical";
import styles from "./Toolbar.module.css";

export function Toolbar() {
  const [editor] = useLexicalComposerContext();

  return (
    <div className={styles.toolbar}>
      <button
        className={styles.buttonBold}
        onClick={() => {
          editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
        }}
        aria-label="Format bold"
      >
        B
      </button>
      <button
        className={styles.buttonItalic}
        onClick={() => {
          editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
        }}
        aria-label="Format italic"
      >
        i
      </button>
      <button
        className={styles.buttonUnderline}
        onClick={() => {
          editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
        }}
        aria-label="Format underline"
      >
        u
      </button>
    </div>
  );
}
```

Add some matching styles:

```css file="Toolbar.module.css" isCollapsed isCollapsable
.toolbar {
  display: flex;
  padding: 1em;
  gap: 6px;
}

.button {
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  border-radius: 6px;
  height: 32px;
  width: 32px;
  background: #fff;
  color: #1f2937;
  border: none;
  box-shadow:
    rgba(0, 0, 0, 0.12) 0 4px 8px 0,
    rgba(0, 0, 0, 0.02) 0 0 0 1px;
}

.button:hover {
  color: #111827;
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 5px 8px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}

.button:focus-visible {
  outline-offset: 2px;
}

.button:active {
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 2px 3px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}
```

You can then import this into your editor to enable basic rich-text:

```tsx file="Editor.tsx" highlight="1,10-12"
import { Toolbar } from "./Toolbar";
// ...

export default function Editor() {
  // ...

  return (
    <div className={styles.container}>
      <LexicalComposer initialConfig={initialConfig}>
        <div className={styles.editorHeader}>
          <Toolbar />
        </div>
        <div className={styles.editorContainer}>{/* ... */}</div>
      </LexicalComposer>
    </div>
  );
}
```

### Theme your text styles

You can go a step further and theme your basic custom text styles by using the
`theme` property, and adding corresponding styles:

```tsx file="Editor.tsx" highlight="15-22"
import styles from "./Editor.module.css";
// ...

// Set up editor config and theme
const initialConfig = {
  // NOTE: This is critical for collaboration plugin to set editor state to null. It
  // would indicate that the editor should not try to set any default state
  // (not even empty one), and let collaboration plugin do it instead
  editorState: null,
  namespace: "Demo",
  nodes: [],
  onError: (error: unknown) => {
    throw error;
  },
  theme: {
    text: {
      bold: styles.textBold,
      italic: styles.textItalic,
      underline: styles.textUnderline,
    },
    paragraph: styles.paragraph,
  },
};

// ...
```

And then in your CSS module, you can style your rich-text:

```css file="Editor.module.css" isCollapsed isCollapsable
/* ... */

.textBold {
  font-weight: bold;
}

.textItalic {
  font-style: italic;
}

.textUnderline {
  text-decoration: underline;
}

.paragraph {
  margin: 0.2rem 0;
}
```

## Create live avatars with Liveblocks hooks

Along with building out your text editor, you can now use other Liveblocks
features, such as [Presence](/docs/ready-made-features/presence). The
[`useOthers`](/docs/api-reference/liveblocks-react#useOthers) hook allows us to
view information about each user currently online, and we can turn this into a
live avatars component.

```tsx file="Avatars.tsx"
import { useOthers, useSelf } from "@/liveblocks.config";
import styles from "./Avatars.module.css";

export function Avatars() {
  const users = useOthers();
  const currentUser = useSelf();

  return (
    <div className={styles.avatars}>
      {users.map(({ connectionId, info }) => {
        return (
          <Avatar key={connectionId} picture={info.picture} name={info.name} />
        );
      })}

      {currentUser && (
        <div className="relative ml-8 first:ml-0">
          <Avatar
            picture={currentUser.info.picture}
            name={currentUser.info.name}
          />
        </div>
      )}
    </div>
  );
}

export function Avatar({ picture, name }: { picture: string; name: string }) {
  return (
    <div className={styles.avatar} data-tooltip={name}>
      <img
        src={picture}
        className={styles.avatar_picture}
        data-tooltip={name}
      />
    </div>
  );
}
```

And here’s the styles:

```css file="Avatars.module.css" isCollapsed isCollapsable
.avatars {
  display: flex;
  padding: 0 0.75rem;
}

.avatar {
  display: flex;
  place-content: center;
  position: relative;
  border: 4px solid #fff;
  border-radius: 9999px;
  width: 42px;
  height: 42px;
  background-color: #9ca3af;
  margin-left: -0.75rem;
}

.avatar:before {
  content: attr(data-tooltip);
  position: absolute;
  top: 100%;
  opacity: 0;
  transition: opacity 0.15s ease;
  padding: 5px 10px;
  color: white;
  font-size: 0.75rem;
  border-radius: 8px;
  margin-top: 10px;
  z-index: 1;
  background: black;
  white-space: nowrap;
}

.avatar:hover:before {
  opacity: 1;
}

.avatar_picture {
  width: 100%;
  height: 100%;
  border-radius: 9999px;
}
```

You can then import this to your editor to see it in action:

```tsx file="Editor.tsx" highlight="1,12"
import { Avatars } from "./Avatars";
// ...

export default function Editor() {
  // ...

  return (
    <div className={styles.container}>
      <LexicalComposer initialConfig={initialConfig}>
        <div className={styles.editorHeader}>
          <Toolbar />
          <Avatars />
        </div>
        <div className={styles.editorContainer}>{/* ... */}</div>
      </LexicalComposer>
    </div>
  );
}
```

Note that the cursors and avatars match in color and name, as the info for both
is sourced from the Liveblocks authentication endpoint.

## Try it out

You should now see the complete editor, along with live cursors, live avatars,
and some basic rich-text features! On GitHub we have a working example of this
[multiplayer text editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-lexical).

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/text-editors/text-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

---
meta:
  title:
    "How to create a collaborative text editor with Quill, Yjs, Next.js, and
    Liveblocks"
  description:
    "Build a collaborative text editor with Quill, Yjs, Next.js, and Liveblocks"
---

In this tutorial, we’ll be building a collaborative text editor using Quill,
Yjs, Next.js, and Liveblocks.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/text-editors/text-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

This guide assumes that you’re already familiar with
[React](https://react.dev/), [Next.js](https://nextjs.org/),
[TypeScript](https://www.typescriptlang.org/), and
[Quill](https://quilljs.com/).

## Install Quill, Yjs, and Liveblocks into your Next.js application

<Banner title="Download the example">

On GitHub we have a working example of this
[collaborative Quill editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-quill)
for you to download and run.

</Banner>

Run the following command to install the Quill, Yjs, and Liveblocks packages:

```bash
npm install @liveblocks/client @liveblocks/react @liveblocks/yjs yjs quill quill-cursors react-quill y-quill
```

## Set up access token authentication

The first step in connecting to Liveblocks is to set up an authentication
endpoint in `/app/api/liveblocks-auth/route.ts`.

```ts
import { Liveblocks } from "@liveblocks/node";
import { NextRequest } from "next/server";

const API_KEY = "{{SECRET_KEY}}";

const liveblocks = new Liveblocks({
  secret: API_KEY!,
});

export async function POST(request: NextRequest) {
  // Get the current user's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = await request.json();
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { body, status } = await session.authorize();
  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's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = request.body;
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { status, body } = await session.authorize();
  response.status(status).send(body);
}
```

## Initialize your Liveblocks config file

Let’s initialize the `liveblocks.config.ts` file in which you’ll set up the
Liveblocks client.

```bash
npx create-liveblocks-app@latest --init --framework 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="/app/Room.tsx"
"use client";

import { ReactNode } from "react";
import {
  LiveblocksProvider,
  RoomProvider,
  ClientSideSuspense,
} from "@liveblocks/react/suspense";

export function Room({ children }: { children: ReactNode }) {
  return (
    <LiveblocksProvider authEndpoint="/api/liveblocks-auth">
      <RoomProvider id="my-room">
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          {children}
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}
```

## Set up the Quill editor

Now that we’ve set up Liveblocks, we can start integrating Quill and Yjs in the
`Editor.tsx` file.

```tsx file="Editor.tsx"
"use client";

import Quill from "quill";
import ReactQuill from "react-quill";
import { QuillBinding } from "y-quill";
import * as Y from "yjs";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { useRoom } from "@/liveblocks.config";
import { useEffect, useRef, useState } from "react";
import styles from "./Editor.module.css";

// Collaborative text editor with simple rich text, live cursors, and live avatars
export function CollaborativeEditor() {
  const room = useRoom();
  const [text, setText] = useState<Y.Text>();
  const [provider, setProvider] = useState<any>();

  // Set up Liveblocks Yjs provider
  useEffect(() => {
    const yDoc = new Y.Doc();
    const yText = yDoc.getText("quill");
    const yProvider = new LiveblocksYjsProvider(room, yDoc);
    setText(yText);
    setProvider(yProvider);

    return () => {
      yDoc?.destroy();
      yProvider?.destroy();
    };
  }, [room]);

  if (!text || !provider) {
    return null;
  }

  return <QuillEditor yText={text} provider={provider} />;
}

type EditorProps = {
  yText: Y.Text;
  provider: any;
};

function QuillEditor({ yText, provider }: EditorProps) {
  const reactQuillRef = useRef<ReactQuill>(null);

  // Set up Yjs and Quill
  useEffect(() => {
    let quill: ReturnType<ReactQuill["getEditor"]>;
    let binding: QuillBinding;

    if (!reactQuillRef.current) {
      return;
    }

    quill = reactQuillRef.current.getEditor();
    binding = new QuillBinding(yText, quill, provider.awareness);
    return () => {
      binding?.destroy?.();
    };
  }, [yText, provider]);

  return (
    <div className={styles.container}>
      <div className={styles.editorContainer}>
        <ReactQuill
          className={styles.editor}
          placeholder="Start typing here…"
          ref={reactQuillRef}
          theme="snow"
          modules={{
            toolbar: false,
            history: {
              // Local undo shouldn't undo changes from remote users
              userOnly: true,
            },
          }}
        />
      </div>
    </div>
  );
}
```

And here is the `Editor.module.css` file to make sure your multiplayer text
editor looks nice and tidy.

```css file="Editor.module.css" isCollapsed isCollapsable
.container {
  display: flex;
  flex-direction: column;
  position: relative;
  border-radius: 12px;
  background: #fff;
  width: 100%;
  height: 100%;
  color: #111827;
}

.editorHeader {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.editor {
  border-radius: inherit;
  flex-grow: 1;
  width: 100%;
  height: 100%;
  padding: 1rem;
  outline: none;
}

.editorContainer {
  position: relative;
  height: 100%;
}

.placeholder {
  pointer-events: none;
  position: absolute;
  inset: 0;
  opacity: 0.3;
  padding: 0 1em;
}

.editor p {
  margin: 1em 0;
}
```

We recommend importing some global styles into your `layout.tsx` or `page.tsx`
file too. We’ll be creating a custom UI, but some classes are still necessary
for Quill:

```css file="globals.css" isCollapsed isCollapsable
/* Let cursor flags show on the first line */
div.ql-container {
  overflow: unset;
}

/* Remove editor focus */
div.ql-editor:focus {
  outline: none;
}

/* Hide non-visible clipboard element */
div.ql-clipboard {
  left: -100000px;
  height: 1px;
  overflow-y: hidden;
  position: absolute;
  top: 50%;
}
```

## Add your editor to the current page

Next, add the `CollaborativeEditor` into the page file, and place it inside the
`Room` component we created earlier. We should now be seeing a basic
collaborative editor!

```tsx file="/app/page.tsx"
import { Room } from "./Room";
import CollaborativeEditor from "@/components/Editor";

export default function Page() {
  return (
    <Room>
      <CollaborativeEditor />
    </Room>
  );
}
```

## Add live cursors

To add live cursors to the text editor, we can get the `userInfo` for the
current user with [`useSelf`](/docs/api-reference/liveblocks-react#useSelf), and
feed it into `editor`. We should now see some cursors with names.

```tsx file="CollaborativeEditor.tsx" highlight="1,4,9-17,30"
import { useSelf } from "../liveblocks.config.ts";
// ...

Quill.register("modules/cursors", QuillCursors);

// ...

function QuillEditor({ yText, provider }: EditorProps) {
  // Add user info to cursors from Liveblocks authentication endpoint
  const userInfo = useSelf((me) => me.info);
  useEffect(() => {
    const { name, color } = userInfo;
    provider.awareness.setLocalStateField("user", {
      name,
      color,
    });
  }, [userInfo]);

  // ...

  return (
    <div className={styles.container}>
      <div className={styles.editorContainer}>
        <ReactQuill
          className={styles.editor}
          placeholder="Start typing here…"
          ref={reactQuillRef}
          theme="snow"
          modules={{
            cursors: true,
            toolbar: false,
            history: {
              // Local undo shouldn't undo changes from remote users
              userOnly: true,
            },
          }}
        />
      </div>
    </div>
  );
}
```

## Add a toolbar

From this point onwards, you can build your Quill app as normal! For example,
should you wish to add a basic text-style toolbar to your app:

```tsx file="Toolbar.tsx"
import styles from "./Toolbar.module.css";
import { QuillEditorType } from "@/components/CollaborativeEditor";
import { useCallback } from "react";

type Props = {
  getQuill: () => QuillEditorType | null;
};

type Formats = "bold" | "italic" | "underline";

export function Toolbar({ getQuill }: Props) {
  const formatSelection = useCallback(
    (format: Formats) => {
      const quill = getQuill();
      if (!quill) {
        return;
      }

      const selection = quill.getSelection();
      if (!selection) {
        return;
      }

      const { index, length } = selection;
      const hasFormat = quill.getFormat(index, length)[format];

      quill.formatText(
        index,
        length,
        {
          [format]: !hasFormat,
        },
        "user"
      );
    },
    [getQuill]
  );

  return (
    <div className={styles.toolbar}>
      <button
        className={styles.button}
        onClick={() => formatSelection("bold")}
        aria-label="bold"
      >
        B
      </button>
      <button
        onClick={() => formatSelection("italic")}
        className={styles.button}
        aria-label="italic"
      >
        I
      </button>
      <button
        onClick={() => formatSelection("underline")}
        className={styles.button}
        aria-label="underline"
      >
        U
      </button>
    </div>
  );
}
```

Add some matching styles:

```css file="Toolbar.module.css" isCollapsed isCollapsable
.toolbar {
  display: flex;
  padding: 1em;
  gap: 6px;
}

.button {
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  border-radius: 6px;
  height: 32px;
  width: 32px;
  background: #fff;
  color: #1f2937;
  border: none;
  box-shadow:
    rgba(0, 0, 0, 0.12) 0 4px 8px 0,
    rgba(0, 0, 0, 0.02) 0 0 0 1px;
}

.button:hover {
  color: #111827;
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 5px 8px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}

.button:focus-visible {
  outline-offset: 2px;
}

.button:active {
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 2px 3px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}

.button[data-active] {
  background: #fafafa;
}
```

You can then import this into your editor, and create the `getQuill` function to
enable basic rich-text:

```tsx file="Editor.tsx" highlight="1,7-14,18-20"
import { Toolbar } from "./Toolbar";
// ...

function QuillEditor({ yText, provider }: EditorProps) {
  // ...

  // Function to get the current Quill editor
  const getQuill = useCallback(() => {
    if (!reactQuillRef.current) {
      return null;
    }

    return reactQuillRef.current.getEditor();
  }, []);

  return (
    <div className={styles.container}>
      <div className={styles.editorHeader}>
        <Toolbar getQuill={getQuill} />
      </div>
      <div className={styles.editorContainer}>{/* ... */}</div>
    </div>
  );
}
```

## Create live avatars with Liveblocks hooks

Along with building out your text editor, you can now use other Liveblocks
features, such as [Presence](/docs/ready-made-features/presence). The
[`useOthers`](/docs/api-reference/liveblocks-react#useOthers) hook allows us to
view information about each user currently online, and we can turn this into a
live avatars component.

```tsx file="Avatars.tsx"
import { useOthers, useSelf } from "@/liveblocks.config";
import styles from "./Avatars.module.css";

export function Avatars() {
  const users = useOthers();
  const currentUser = useSelf();

  return (
    <div className={styles.avatars}>
      {users.map(({ connectionId, info }) => {
        return (
          <Avatar key={connectionId} picture={info.picture} name={info.name} />
        );
      })}

      {currentUser && (
        <div className="relative ml-8 first:ml-0">
          <Avatar
            picture={currentUser.info.picture}
            name={currentUser.info.name}
          />
        </div>
      )}
    </div>
  );
}

export function Avatar({ picture, name }: { picture: string; name: string }) {
  return (
    <div className={styles.avatar} data-tooltip={name}>
      <img
        src={picture}
        className={styles.avatar_picture}
        data-tooltip={name}
      />
    </div>
  );
}
```

And here’s the styles:

```css file="Avatars.module.css" isCollapsed isCollapsable
.avatars {
  display: flex;
  padding: 0 0.75rem;
}

.avatar {
  display: flex;
  place-content: center;
  position: relative;
  border: 4px solid #fff;
  border-radius: 9999px;
  width: 42px;
  height: 42px;
  background-color: #9ca3af;
  margin-left: -0.75rem;
}

.avatar:before {
  content: attr(data-tooltip);
  position: absolute;
  top: 100%;
  opacity: 0;
  transition: opacity 0.15s ease;
  padding: 5px 10px;
  color: white;
  font-size: 0.75rem;
  border-radius: 8px;
  margin-top: 10px;
  z-index: 1;
  background: black;
  white-space: nowrap;
}

.avatar:hover:before {
  opacity: 1;
}

.avatar_picture {
  width: 100%;
  height: 100%;
  border-radius: 9999px;
}
```

You can then import this into your editor to see it in action:

```tsx file="Editor.tsx" highlight="1,11"
import { Avatars } from "./Avatars";
// ...

function QuillEditor({ yText, provider }: EditorProps) {
  // ...

  return (
    <div className={styles.container}>
      <div className={styles.editorHeader}>
        <Toolbar getQuill={getQuill} />
        <Avatars />
      </div>
      <div className={styles.editorContainer}>{/* ... */}</div>
    </div>
  );
}
```

Note that the cursors and avatars match in color and name, as the info for both
is sourced from the Liveblocks authentication endpoint.

## Try it out

You should now see the complete editor, along with live cursors, live avatars,
and some basic rich-text features! On GitHub we have a working example of this
[multiplayer text editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-quill).

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/text-editors/text-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

---
meta:
  title:
    "How to create a collaborative text editor with Slate, Yjs, Next.js, and
    Liveblocks"
  description:
    "Build a collaborative text editor with Slate, Yjs, Next.js, and Liveblocks"
---

In this tutorial, we’ll be building a collaborative text editor using Slate,
Yjs, Next.js, and Liveblocks.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/text-editors/text-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

This guide assumes that you’re already familiar with
[React](https://react.dev/), [Next.js](https://nextjs.org/),
[TypeScript](https://www.typescriptlang.org/), and
[Slate](https://docs.slatejs.org/).

## Install Slate, Yjs, and Liveblocks into your Next.js application

<Banner title="Download the example">

On GitHub we have a working example of this
[collaborative Slate editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-slate)
for you to download and run.

</Banner>

Run the following command to install the Slate, Yjs, and Liveblocks packages:

```bash
npm install @liveblocks/client @liveblocks/react @liveblocks/yjs @liveblocks/node yjs slate slate-react @slate-yjs/core @slate-yjs/react
```

### Transpile slate-yjs

Add `transpilePackages` to your `next.config.ts` file to allow
`@slate-yjs/react` to be bundled correctly.

```js highlight="2"
module.exports = {
  transpilePackages: ["@slate-yjs/react"],

  // ...
};
```

#### Got an error?

Note that if you’re seeing an error that resembles the following, you’re
probably using an older version of Next.js that doesn’t support
`transpilePackages`.

```
Error: Directory import '/node_modules/use-sync-external-store/shim' is not supported resolving ES modules imported from /node_modules/@slate-yjs/react/dist/index.js
Did you mean to import use-sync-external-store/shim/index.js?
```

You may need to upgrade to a newer version of Next.js for this to work.

```bash
npm i next@latest
```

## Set up access token authentication

The first step in connecting to Liveblocks is to set up an authentication
endpoint in `/app/api/liveblocks-auth/route.ts`.

```ts
import { Liveblocks } from "@liveblocks/node";
import { NextRequest } from "next/server";

const API_KEY = "{{SECRET_KEY}}";

const liveblocks = new Liveblocks({
  secret: API_KEY!,
});

export async function POST(request: NextRequest) {
  // Get the current user's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = await request.json();
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { body, status } = await session.authorize();
  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's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = request.body;
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { body, status } = await session.authorize();
  response.status(status).send(body);
}
```

## Initialize your Liveblocks config file

Let’s initialize the `liveblocks.config.ts` file in which you’ll set up the
Liveblocks client.

```bash
npx create-liveblocks-app@latest --init --framework 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="/app/Room.tsx"
"use client";

import { ReactNode } from "react";
import {
  LiveblocksProvider,
  RoomProvider,
  ClientSideSuspense,
} from "@liveblocks/react/suspense";

export function Room({ children }: { children: ReactNode }) {
  return (
    <LiveblocksProvider authEndpoint="/api/liveblocks-auth">
      <RoomProvider id="my-room">
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          {children}
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}
```

## Set up the Slate editor

Now that we’ve set up Liveblocks, we can start integrating Slate and Yjs in the
`Editor.tsx` file.

```tsx file="Editor.tsx"
import { LiveblocksYjsProvider } 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 { LiveblocksProviderType, useRoom } from "../liveblocks.config";
import styles from "./Editor.module.css";

export default function CollaborativeEditor() {
  const room = useRoom();
  const [connected, setConnected] = useState(false);
  const [sharedType, setSharedType] = useState<Y.XmlText>();
  const [provider, setProvider] = useState<LiveblocksProviderType>();

  // Set up Liveblocks Yjs provider
  useEffect(() => {
    const yDoc = new Y.Doc();
    const yProvider = new LiveblocksYjsProvider(room, yDoc);
    const sharedDoc = yDoc.get("slate", Y.XmlText) as Y.XmlText;
    yProvider.on("sync", setConnected);

    setSharedType(sharedDoc);
    setProvider(yProvider);

    return () => {
      yDoc?.destroy();
      yProvider?.off("sync", setConnected);
      yProvider?.destroy();
    };
  }, [room]);

  if (!connected || !sharedType || !provider) {
    return <div>Loading…</div>;
  }

  return <SlateEditor sharedType={sharedType} />;
}

const emptyNode = {
  children: [{ text: "" }],
};

function SlateEditor({ sharedType }: { sharedType: Y.XmlText }) {
  // Set up editor with plugins
  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;
  }, [sharedType]);

  useEffect(() => {
    YjsEditor.connect(editor);
    return () => YjsEditor.disconnect(editor);
  }, [editor]);

  return (
    <div className={styles.container}>
      <div className={styles.editorContainer}>
        <Slate editor={editor} initialValue={[emptyNode]}>
          <Editable
            className={styles.editor}
            placeholder="Start typing here…"
          />
        </Slate>
      </div>
    </div>
  );
}
```

And here is the `Editor.module.css` file to make sure your multiplayer text
editor looks nice and tidy.

```css file="Editor.module.css" isCollapsed isCollapsable
.container {
  display: flex;
  flex-direction: column;
  position: relative;
  border-radius: 12px;
  background: #fff;
  width: 100%;
  height: 100%;
  color: #111827;
}

.editorHeader {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.editor {
  border-radius: inherit;
  flex-grow: 1;
  width: 100%;
  height: 100%;
  padding: 1rem;
}

.editor:focus {
  outline: none;
}

.editorContainer {
  position: relative;
  height: 100%;
}

.placeholder {
  pointer-events: none;
  position: absolute;
  inset: 0;
  opacity: 0.3;
  padding: 0 1em;
}

.editor p {
  margin: 1em 0;
}
```

## Add your editor to the current page

Next, add the `CollaborativeEditor` into the page file, and place it inside the
`Room` component we created earlier. We should now be seeing a basic
collaborative editor!

```tsx file="/app/page.tsx"
import { Room } from "@/app/Room";
import CollaborativeEditor from "@/components/Editor";

export default function Page() {
  return (
    <Room>
      <CollaborativeEditor />
    </Room>
  );
}
```

## Add live cursors

To add live cursors to the text editor, we can rely on hooks from the
`@slate-yjs/react` package.

```tsx file="Cursors.tsx"
import {
  CursorOverlayData,
  useRemoteCursorOverlayPositions,
} from "@slate-yjs/react";
import React, { CSSProperties, ReactNode, useRef } from "react";
import { Cursor } from "./types";
import styles from "./Cursors.module.css";

export function Cursors({ children }: { children: ReactNode }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [cursors] = useRemoteCursorOverlayPositions<Cursor>({
    containerRef,
  });

  return (
    <div className={styles.cursors} ref={containerRef}>
      {children}
      {cursors.map((cursor) => (
        <Selection key={cursor.clientId} {...cursor} />
      ))}
    </div>
  );
}

function Selection({
  data,
  selectionRects,
  caretPosition,
}: CursorOverlayData<Cursor>) {
  if (!data) {
    return null;
  }

  const selectionStyle: CSSProperties = {
    backgroundColor: data.color,
  };

  return (
    <>
      {selectionRects.map((position, i) => (
        <div
          style={{ ...selectionStyle, ...position }}
          className={styles.selection}
          key={i}
        />
      ))}
      {caretPosition && <Caret caretPosition={caretPosition} data={data} />}
    </>
  );
}

type CaretProps = Pick<CursorOverlayData<Cursor>, "caretPosition" | "data">;

function Caret({ caretPosition, data }: CaretProps) {
  const caretStyle: CSSProperties = {
    ...caretPosition,
    background: data?.color,
  };

  const labelStyle: CSSProperties = {
    transform: "translateY(-100%)",
    background: data?.color,
  };

  return (
    <div style={caretStyle} className={styles.caretMarker}>
      <div className={styles.caret} style={labelStyle}>
        {data?.name}
      </div>
    </div>
  );
}
```

And of course we need some corresponding CSS to keep the cursors in the correct
positions.

```css file="Cursors.module.css" isCollapsed isCollapsable
.cursors {
  position: relative;
}

.caretMarker {
  position: absolute;
  width: 2px;
}

.caret {
  position: absolute;
  font-size: 14px;
  color: #fff;
  white-space: nowrap;
  top: 0;
  border-radius: 6px;
  border-bottom-left-radius: 0;
  padding: 2px 6px;
  pointer-events: none;
}

.selection {
  position: absolute;
  pointer-events: none;
  opacity: 0.2;
}
```

Back in your `Editor` component, get the current user’s info with
[`useSelf`](/docs/api-reference/liveblocks-react#useSelf), add the `withCursors`
plugin, and pass the user info and awareness. Then wrap your editor in the
Cursors component to see them working.

```tsx file="Editor.tsx" highlight="1-3,7-8,12-20,29,34"
import { withCursors, withYjs, YjsEditor } from "@slate-yjs/core";
import { useSelf } from "../liveblocks.config";
import { Cursors } from "./Cursors";
// ...

export default function SlateEditor() {
  // Get user info from Liveblocks authentication endpoint
  const userInfo = useSelf((self) => self.info);

  // Set up editor with plugins, and place user info into Yjs awareness and cursors
  const editor = useMemo(() => {
    const e = withReact(
      withCursors(
        withYjs(createEditor(), sharedType),
        provider.awareness as any,
        {
          data: userInfo,
        }
      )
    );

    // ...
  }, [sharedType, provider.awareness, userInfo]);

  return (
    <div className={styles.container}>
      <div className={styles.editorContainer}>
        <Slate editor={editor} initialValue={[emptyNode]}>
          <Cursors>
            <Editable
              className={styles.editor}
              placeholder="Start typing here…"
            />
          </Cursors>
        </Slate>
      </div>
    </div>
  );
}
```

## Add a toolbar

From this point onwards, you can build your Slate app as normal! For example,
should you wish to add a basic text-style toolbar to your app:

```tsx file="Toolbar.tsx"
import { Editor } from "slate";
import { useSlate } from "slate-react";
import styles from "./Toolbar.module.css";
import { CustomText } from "./types";

export function Toolbar() {
  const editor = useSlate();

  return (
    <div className={styles.toolbar}>
      <button
        className={styles.button}
        data-active={isMarkActive(editor, "bold") || undefined}
        onClick={(event) => {
          event.preventDefault();
          toggleMark(editor, "bold");
        }}
      >
        B
      </button>
      <button
        className={styles.button}
        data-active={isMarkActive(editor, "italic") || undefined}
        onClick={(event) => {
          event.preventDefault();
          toggleMark(editor, "italic");
        }}
      >
        i
      </button>
      <button
        className={styles.button}
        data-active={isMarkActive(editor, "underline") || undefined}
        onClick={(event) => {
          event.preventDefault();
          toggleMark(editor, "underline");
        }}
      >
        u
      </button>
    </div>
  );
}

function toggleMark(editor: Editor, format: keyof CustomText) {
  const isActive = isMarkActive(editor, format);

  if (isActive) {
    Editor.removeMark(editor, format);
  } else {
    Editor.addMark(editor, format, true);
  }
}

function isMarkActive(editor: Editor, format: keyof CustomText) {
  const marks = Editor.marks(editor);
  return marks ? marks?.[format] === true : false;
}
```

Add some matching styles:

```css file="Toolbar.module.css" isCollapsed isCollapsable
.toolbar {
  display: flex;
  padding: 1em;
  gap: 6px;
}

.button {
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  border-radius: 6px;
  height: 32px;
  width: 32px;
  background: #fff;
  color: #1f2937;
  border: none;
  box-shadow:
    rgba(0, 0, 0, 0.12) 0 4px 8px 0,
    rgba(0, 0, 0, 0.02) 0 0 0 1px;
}

.button:hover {
  color: #111827;
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 5px 8px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}

.button:focus-visible {
  outline-offset: 2px;
}

.button:active {
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 2px 3px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}

.button[data-active] {
  background: #fafafa;
}
```

### Theme your leaf styles

These styles are rendered with leaf components, which we’ll add to a new file:

```tsx file="Leaf.tsx"
import { PropsWithChildren } from "react";
import { CustomText } from "@/src/types";

type Props = PropsWithChildren<{
  attributes: Record<string, string>;
  leaf: CustomText;
}>;

export function Leaf({ attributes, children, leaf }: Props) {
  if (leaf.bold) {
    children = <strong>{children}</strong>;
  }

  if (leaf.italic) {
    children = <em>{children}</em>;
  }

  if (leaf.underline) {
    children = <u>{children}</u>;
  }

  return <span {...attributes}>{children}</span>;
}
```

You can then import this into your editor to enable basic rich-text:

```tsx file="Editor.tsx" highlight="1,2,8,13,15-17"
import { Toolbar } from "@/src/Toolbar";
import { Leaf } from "@/src/Leaf";
// ...

export default function SlateEditor() {
  // ...

  const renderLeaf = useCallback((props: any) => <Leaf {...props} />, []);

  return (
    <div className={styles.container}>
      <div className={styles.editorContainer}>
        <Slate editor={editor} initialValue={[emptyNode]}>
          <Cursors>
            <div className={styles.editorHeader}>
              <Toolbar />
            </div>
            <Editable
              className={styles.editor}
              placeholder="Start typing here…"
              renderLeaf={renderLeaf}
            />
          </Cursors>
        </Slate>
      </div>
    </div>
  );
}
```

## Create live avatars with Liveblocks hooks

Along with building out your text editor, you can now use other Liveblocks
features, such as [Presence](/docs/ready-made-features/presence). The
[`useOthers`](/docs/api-reference/liveblocks-react#useOthers) hook allows us to
view information about each user currently online, and we can turn this into a
live avatars component.

```tsx file="Avatars.tsx"
import { useOthers, useSelf } from "@/liveblocks.config";
import styles from "./Avatars.module.css";

export function Avatars() {
  const users = useOthers();
  const currentUser = useSelf();

  return (
    <div className={styles.avatars}>
      {users.map(({ connectionId, info }) => {
        return (
          <Avatar key={connectionId} picture={info.picture} name={info.name} />
        );
      })}

      {currentUser && (
        <div className="relative ml-8 first:ml-0">
          <Avatar
            picture={currentUser.info.picture}
            name={currentUser.info.name}
          />
        </div>
      )}
    </div>
  );
}

export function Avatar({ picture, name }: { picture: string; name: string }) {
  return (
    <div className={styles.avatar} data-tooltip={name}>
      <img
        src={picture}
        className={styles.avatar_picture}
        data-tooltip={name}
      />
    </div>
  );
}
```

And here’s the styles:

```css file="Avatars.module.css" isCollapsed isCollapsable
.avatars {
  display: flex;
  padding: 0 0.75rem;
}

.avatar {
  display: flex;
  place-content: center;
  position: relative;
  border: 4px solid #fff;
  border-radius: 9999px;
  width: 42px;
  height: 42px;
  background-color: #9ca3af;
  margin-left: -0.75rem;
}

.avatar:before {
  content: attr(data-tooltip);
  position: absolute;
  top: 100%;
  opacity: 0;
  transition: opacity 0.15s ease;
  padding: 5px 10px;
  color: white;
  font-size: 0.75rem;
  border-radius: 8px;
  margin-top: 10px;
  z-index: 1;
  background: black;
  white-space: nowrap;
}

.avatar:hover:before {
  opacity: 1;
}

.avatar_picture {
  border-radius: 9999px;
}
```

You can then import this to your editor to see it in action:

```tsx file="Editor.tsx" highlight="1,14"
import { Avatars } from "./Avatars";
// ...

export default function SlateEditor() {
  // ...

  return (
    <div className={styles.container}>
      <div className={styles.editorContainer}>
        <Slate editor={editor} initialValue={[emptyNode]}>
          <Cursors>
            <div className={styles.editorHeader}>
              <Toolbar />
              <Avatars />
            </div>
            <Editable
              className={styles.editor}
              placeholder="Start typing here…"
              renderLeaf={renderLeaf}
            />
          </Cursors>
        </Slate>
      </div>
    </div>
  );
}
```

Note that the cursors and avatars match in color and name, as the info for both
is sourced from the Liveblocks authentication endpoint.

## Try it out

You should now see the complete editor, along with live cursors, live avatars,
and some basic rich-text features! On GitHub we have a working example of this
[multiplayer text editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-slate).

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/text-editors/text-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

---
meta:
  title:
    "How to create a collaborative text editor with Tiptap, Yjs, Next.js, and
    Liveblocks"
  description:
    "Build a collaborative text editor with Tiptap, Yjs, Next.js, and Liveblocks"
---

In this tutorial, we’ll be building a collaborative text editor using Tiptap,
Yjs, Next.js, and Liveblocks.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/text-editors/text-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

<Banner title="No longer recommended" type="warning">

We now have an official Tiptap package, and we recommend you use this instead of
following this guide. Read more in our
[Tiptap getting started guide](/docs/get-started/nextjs-tiptap). However, should
you like to set up Tiptap manually with Yjs, without using our full feature set,
continue reading.

</Banner>

This guide assumes that you’re already familiar with
[React](https://react.dev/), [Next.js](https://nextjs.org/),
[TypeScript](https://www.typescriptlang.org/), and
[Tiptap](https://tiptap.dev/).

## Install Tiptap, Yjs, and Liveblocks into your Next.js application

<Banner title="Download the example">

On GitHub we have a working example of this
[collaborative Tiptap editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-tiptap)
for you to download and run.

</Banner>

Run the following command to install the Tiptap, Yjs, and Liveblocks packages:

```bash
npm install @liveblocks/client @liveblocks/react @liveblocks/yjs yjs @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor @tiptap/pm @tiptap/react @tiptap/starter-kit
```

## Set up access token authentication

The first step in connecting to Liveblocks is to set up an authentication
endpoint in `/app/api/liveblocks-auth/route.ts`.

```ts
import { Liveblocks } from "@liveblocks/node";
import { NextRequest } from "next/server";

const API_KEY = "{{SECRET_KEY}}";

const liveblocks = new Liveblocks({
  secret: API_KEY!,
});

export async function POST(request: NextRequest) {
  // Get the current user's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = await request.json();
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { body, status } = await session.authorize();
  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's info from your database
  const user = {
    id: "charlielayne@example.com",
    info: {
      name: "Charlie Layne",
      color: "#D583F0",
      picture: "https://liveblocks.io/avatars/avatar-1.png",
    },
  };

  // Create a session for the current user
  // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
  const session = liveblocks.prepareSession(user.id, {
    userInfo: user.info,
  });

  // Give the user access to the room
  const { room } = request.body;
  session.allow(room, session.FULL_ACCESS);

  // Authorize the user and return the result
  const { status, body } = await session.authorize();
  response.status(status).send(body);
}
```

## Initialize your Liveblocks config file

Let’s initialize the `liveblocks.config.ts` file in which you’ll set up the
Liveblocks client.

```bash
npx create-liveblocks-app@latest --init --framework 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="/app/Room.tsx"
"use client";

import { ReactNode } from "react";
import {
  LiveblocksProvider,
  RoomProvider,
  ClientSideSuspense,
} from "@liveblocks/react/suspense";

export function Room({ children }: { children: ReactNode }) {
  return (
    <LiveblocksProvider authEndpoint="/api/liveblocks-auth">
      <RoomProvider id="my-room">
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          {children}
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}
```

## Set up the Tiptap editor

Now that we’ve set up Liveblocks, we can start integrating Tiptap and Yjs in the
`Editor.tsx` file.

```tsx file="Editor.tsx"
"use client";

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import * as Y from "yjs";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { useRoom } from "@/liveblocks.config";
import { useEffect, useState } from "react";
import styles from "./CollaborativeEditor.module.css";

// Collaborative text editor with simple rich text, live cursors, and live avatars
export function CollaborativeEditor() {
  const room = useRoom();
  const [doc, setDoc] = useState<Y.Doc>();
  const [provider, setProvider] = useState<any>();

  // Set up Liveblocks Yjs provider
  useEffect(() => {
    const yDoc = new Y.Doc();
    const yProvider = new LiveblocksYjsProvider(room, yDoc);
    setDoc(yDoc);
    setProvider(yProvider);

    return () => {
      yDoc?.destroy();
      yProvider?.destroy();
    };
  }, [room]);

  if (!doc || !provider) {
    return null;
  }

  return <TiptapEditor doc={doc} provider={provider} />;
}

type EditorProps = {
  doc: Y.Doc;
  provider: any;
};

function TiptapEditor({ doc, provider }: EditorProps) {
  // Set up editor with plugins
  const editor = useEditor({
    editorProps: {
      attributes: {
        // Add styles to editor element
        class: styles.editor,
      },
    },
    extensions: [
      StarterKit.configure({
        // The Collaboration extension comes with its own history handling
        undoRedo: false,
      }),
      // Register the document with Tiptap
      Collaboration.configure({
        document: doc,
      }),
      // Attach provider and user info
      CollaborationCursor.configure({
        provider: provider,
      }),
    ],
  });

  return (
    <div className={styles.container}>
      <EditorContent editor={editor} className={styles.editorContainer} />
    </div>
  );
}
```

And here is the `Editor.module.css` file to make sure your multiplayer text
editor looks nice and tidy.

```css file="Editor.module.css" isCollapsed isCollapsable
.container {
  display: flex;
  flex-direction: column;
  position: relative;
  border-radius: 12px;
  background: #fff;
  width: 100%;
  height: 100%;
  color: #111827;
}

.editorHeader {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.editor {
  border-radius: inherit;
  flex-grow: 1;
  width: 100%;
  height: 100%;
  padding: 1rem;
}

.editor:focus {
  outline: none;
}

.editorContainer {
  position: relative;
  height: 100%;
}

.placeholder {
  pointer-events: none;
  position: absolute;
  inset: 0;
  opacity: 0.3;
  padding: 0 1em;
}

.editor p {
  margin: 1em 0;
}
```

## Add your editor to the current page

Next, add the `CollaborativeEditor` into the page file, and place it inside the
`Room` component we created earlier. We should now be seeing a basic
collaborative editor!

```tsx file="/app/page.tsx"
import { Room } from "./Room";
import CollaborativeEditor from "@/components/Editor";

export default function Page() {
  return (
    <Room>
      <CollaborativeEditor />
    </Room>
  );
}
```

## Add live cursors

To add live cursors to the text editor, we can get the `userInfo` for the
current user with [`useSelf`](/docs/api-reference/liveblocks-react#useSelf), and
feed it into `editor`. We should now see some cursors with names.

```tsx file="CollaborativeEditor.tsx" highlight="1,5-6,17"
import { useSelf } from "../liveblocks.config.ts";
// ...

function TiptapEditor({ doc, provider }: EditorProps) {
  // Get user info from Liveblocks authentication endpoint
  const userInfo = useSelf((me) => me.info);

  // Set up editor with plugins, and place user info into Yjs awareness and cursors
  const editor = useEditor({
    // ...
    extensions: [
      // ...

      // Attach provider and user info
      CollaborationCursor.configure({
        provider: provider,
        user: userInfo,
      }),
    ],
  });

  return (
    <div className={styles.container}>
      <EditorContent editor={editor} className={styles.editorContainer} />
    </div>
  );
}
```

We can style these cursors by placing CSS in a global CSS file.

```css file="globals.css" isCollapsed isCollapsable
/* Give a remote user a caret */
.collaboration-carets__caret {
  border-left: 1px solid #0d0d0d;
  border-right: 1px solid #0d0d0d;
  margin-left: -1px;
  margin-right: -1px;
  pointer-events: none;
  position: relative;
  word-break: normal;
}

/* Render the username above the caret */
.collaboration-carets__label {
  font-style: normal;
  font-weight: 600;
  left: -1px;
  line-height: normal;
  position: absolute;
  user-select: none;
  white-space: nowrap;
  font-size: 14px;
  color: #fff;
  top: -1.4em;
  border-radius: 6px;
  border-bottom-left-radius: 0;
  padding: 2px 6px;
  pointer-events: none;
}
```

## Add a toolbar

From this point onwards, you can build your Tiptap app as normal! For example,
should you wish to add a basic text-style toolbar to your app:

```tsx file="Toolbar.tsx"
import { Editor } from "@tiptap/react";
import styles from "./Toolbar.module.css";

type Props = {
  editor: Editor | null;
};

export function Toolbar({ editor }: Props) {
  if (!editor) {
    return null;
  }

  return (
    <div className={styles.toolbar}>
      <button
        className={styles.button}
        onClick={() => editor.chain().focus().toggleBold().run()}
        disabled={!editor.can().chain().focus().toggleBold().run()}
        data-active={editor.isActive("bold") ? "is-active" : undefined}
        aria-label="bold"
      >
        B
      </button>
      <button
        className={styles.button}
        onClick={() => editor.chain().focus().toggleItalic().run()}
        disabled={!editor.can().chain().focus().toggleItalic().run()}
        data-active={editor.isActive("italic") ? "is-active" : undefined}
        aria-label="italic"
      >
        i
      </button>
      <button
        className={styles.button}
        onClick={() => editor.chain().focus().toggleStrike().run()}
        disabled={!editor.can().chain().focus().toggleStrike().run()}
        data-active={editor.isActive("strike") ? "is-active" : undefined}
        aria-label="strikethrough"
      >
        S
      </button>
    </div>
  );
}
```

Add some matching styles:

```css file="Toolbar.module.css" isCollapsed isCollapsable
.toolbar {
  display: flex;
  padding: 1em;
  gap: 6px;
}

.button {
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  border-radius: 6px;
  height: 32px;
  width: 32px;
  background: #fff;
  color: #1f2937;
  border: none;
  box-shadow:
    rgba(0, 0, 0, 0.12) 0 4px 8px 0,
    rgba(0, 0, 0, 0.02) 0 0 0 1px;
}

.button:hover {
  color: #111827;
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 5px 8px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}

.button:focus-visible {
  outline-offset: 2px;
}

.button:active {
  box-shadow:
    rgba(0, 0, 0, 0.16) 0 2px 3px 0,
    rgba(0, 0, 0, 0.04) 0 0 0 1px;
}

.button[data-active] {
  background: #fafafa;
}
```

You can then import this into your editor to enable basic rich-text:

```tsx file="Editor.tsx" highlight="1,9-11"
import { Toolbar } from "./Toolbar";
// ...

function TiptapEditor({ doc, provider }: EditorProps) {
  // ...

  return (
    <div className={styles.container}>
      <div className={styles.editorHeader}>
        <Toolbar editor={editor} />
      </div>
      <EditorContent editor={editor} className={styles.editorContainer} />
    </div>
  );
}
```

## Create live avatars with Liveblocks hooks

Along with building out your text editor, you can now use other Liveblocks
features, such as [Presence](/docs/ready-made-features/presence). The
[`useOthers`](/docs/api-reference/liveblocks-react#useOthers) hook allows us to
view information about each user currently online, and we can turn this into a
live avatars component.

```tsx file="Avatars.tsx"
import { useOthers, useSelf } from "@/liveblocks.config";
import styles from "./Avatars.module.css";

export function Avatars() {
  const users = useOthers();
  const currentUser = useSelf();

  return (
    <div className={styles.avatars}>
      {users.map(({ connectionId, info }) => {
        return (
          <Avatar key={connectionId} picture={info.picture} name={info.name} />
        );
      })}

      {currentUser && (
        <div className="relative ml-8 first:ml-0">
          <Avatar
            picture={currentUser.info.picture}
            name={currentUser.info.name}
          />
        </div>
      )}
    </div>
  );
}

export function Avatar({ picture, name }: { picture: string; name: string }) {
  return (
    <div className={styles.avatar} data-tooltip={name}>
      <img
        src={picture}
        className={styles.avatar_picture}
        data-tooltip={name}
      />
    </div>
  );
}
```

And here’s the styles:

```css file="Avatars.module.css" isCollapsed isCollapsable
.avatars {
  display: flex;
  padding: 0 0.75rem;
}

.avatar {
  display: flex;
  place-content: center;
  position: relative;
  border: 4px solid #fff;
  border-radius: 9999px;
  width: 42px;
  height: 42px;
  background-color: #9ca3af;
  margin-left: -0.75rem;
}

.avatar:before {
  content: attr(data-tooltip);
  position: absolute;
  top: 100%;
  opacity: 0;
  transition: opacity 0.15s ease;
  padding: 5px 10px;
  color: white;
  font-size: 0.75rem;
  border-radius: 8px;
  margin-top: 10px;
  z-index: 1;
  background: black;
  white-space: nowrap;
}

.avatar:hover:before {
  opacity: 1;
}

.avatar_picture {
  width: 100%;
  height: 100%;
  border-radius: 9999px;
}
```

You can then import this to your editor to see it in action:

```tsx file="Editor.tsx" highlight="11"
import { Avatars } from "./Avatars";
// ...

function TiptapEditor({ doc, provider }: EditorProps) {
  // ...

  return (
    <div className={styles.container}>
      <div className={styles.editorHeader}>
        <Toolbar editor={editor} />
        <Avatars />
      </div>
      <EditorContent editor={editor} className={styles.editorContainer} />
    </div>
  );
}
```

Note that the cursors and avatars match in color and name, as the info for both
is sourced from the Liveblocks authentication endpoint.

## Try it out

You should now see the complete editor, along with live cursors, live avatars,
and some basic rich-text features! On GitHub we have a working example of this
[multiplayer text editor](https://github.com/liveblocks/liveblocks/tree/main/examples/nextjs-yjs-tiptap).

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/text-editors/text-editor-demo.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

---
meta:
  title:
    "How to create a collaborative to-do list with JavaScript and Liveblocks"
  description: "Build a collaborative to-do list with JavaScript and Liveblocks"
---

The goal of this tutorial is to show you how to build a collaborative to-do list
app in 15 minutes. The to-dos will be persisted on Liveblocks backend and synced
in realtime across clients. Users will also be able to see who’s currently using
the app and when someone is typing.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-1.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

We’re going to use vanilla JavaScript and [esbuild](https://esbuild.github.io/)
for the bundling.

If you’re using a front-end framework such as React, 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)
- [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/javascript-todo-list).

## Install Liveblocks into your project [#install-liveblocks]

### Install Liveblocks packages

Create an empty node project with `npm init` and run the following command to
install Liveblocks package and `esbuild`:

```bash
npm install @liveblocks/client esbuild
```

[`@liveblocks/client`][] lets you interact with Liveblocks servers.
[`esbuild`](https://esbuild.github.io/) lets your bundle your app with
`@liveblocks/client`.

Then add a build script to your `package.json` that will be responsible to
bundle our app.

```json
"scripts": {
  "build": "esbuild app.js --bundle --outfile=static/app.js"
},
```

### Connect to Liveblocks servers [#connect-liveblocks-servers]

You’ll need an API key in order to use Liveblocks.
[Create a Liveblocks account](/auth/signup) to get
[your API key](/dashboard/apikeys). It should start with `pk_`.

<Banner title="Public vs secret key">

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).

</Banner>

To connect to Liveblocks servers, create a client with [`createClient`][] and
set your public API key like below.

```jsx file="app.js"
import { createClient } from "@liveblocks/client";

const client = createClient({
  publicApiKey: "{{PUBLIC_KEY}}",
});
```

### Connect to a Liveblocks room [#connect-liveblocks-room]

A room is the virtual space where people collaborate. To create a collaborative
experience, you’ll need to connect your users to a Liveblocks room following the
instructions below.

You can easily connect to a room by using [`client.enter`][] by passing the room
id as a parameter. For this tutorial we’ll use `javascript-todo-app`.

```jsx highlight="7-13" 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 },
  });
}

run();
```

We’ve also passed an `initialPresence` value here—we’ll be using this later.

## Show who’s currently in the room [#who-is-here]

<Banner title="Need help troubleshooting presence?">

Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your
collaborative experiences as you build them, in realtime.

</Banner>

Now that Liveblocks is set up, we’re going to use [`room.subscribe("others")`][]
to show who’s currently inside the room.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-2.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

Create a file `static/index.html` with the following content:

```html file="static/index.html"
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Liveblocks - Todo list</title>
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="container">
      <div id="who_is_here" class="who_is_here"></div>
    </div>
    <script src="app.js"></script>
  </body>
</html>
```

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]

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-3.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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"
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Liveblocks - Todo list</title>
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="container">
      <div id="who_is_here" class="who_is_here"></div>
      <input id="todo_input" type="text" placeholder="What needs to be done?" />
    </div>
    <script src="app.js"></script>
  </body>
</html>
```

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"
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Liveblocks - Todo list</title>
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="container">
      <div id="who_is_here" class="who_is_here"></div>
      <input id="todo_input" type="text" placeholder="What needs to be done?" />
      <div id="someone_is_typing" class="someone_is_typing"></div>
    </div>
    <script src="app.js"></script>
  </body>
</html>
```

```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]

<Banner title="Need help troubleshooting storage?">

Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your
collaborative experiences as you build them, in realtime.

</Banner>

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`.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-1.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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"
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Liveblocks - Todo list</title>
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="container">
      <div id="who_is_here" class="who_is_here"></div>
      <input id="todo_input" type="text" placeholder="What needs to be done?" />
      <div id="someone_is_typing" class="someone_is_typing"></div>
      <div id="todos_container"></div>
    </div>
    <script src="app.js"></script>
  </body>
</html>
```

```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).

<Figure>
  <Image
    src="/assets/tutorials/todo-list/tutorial-todo-list-dashboard.png"
    alt="Liveblocks dashboard"
    width={1380}
    height={778}
  />
</Figure>

## 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.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-1.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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
```

<Banner title="Next.js setup options">

For this tutorial, we won’t use the `src` directory or the experimental `app`
directory.

</Banner>

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 (
    <LiveblocksProvider publicApiKey={"{{SECRET_KEY}}"}>
      <RoomProvider id="my-room">
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          <TodoList />
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}

function TodoList() {
  return (
    <div>
      {/* We’re starting to implement the to-do list in the next section */}
    </div>
  );
}
```

## Show who’s currently in the room [#who-is-here]

<Banner title="Need help troubleshooting presence?">

Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your
collaborative experiences as you build them, in realtime.

</Banner>

Now that Liveblocks is set up, we can start using the hooks to display how many
users are currently online.

<figure className="relative my-10 overflow-hidden rounded-lg">
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-2.mp4"
      type="video/mp4"
    />
  </video>
  <div className="shadow-thin-border-300 pointer-events-none absolute inset-0 rounded-lg" />
</figure>

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 (
    <div className="who_is_here">There are {userCount} other users online</div>
  );
}

function TodoList() {
  return (
    <div className="container">
      <WhoIsHere />
    </div>
  );
}

/* 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.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-3.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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 (
    <div className="container">
      <WhoIsHere />
      <input
        type="text"
        placeholder="What needs to be done?"
        value={draft}
        onChange={(e) => {
          setDraft(e.target.value);
          updateMyPresence({ isTyping: true });
        }}
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            updateMyPresence({ isTyping: false });
            setDraft("");
          }
        }}
        onBlur={() => updateMyPresence({ isTyping: false })}
      />
    </div>
  );
}

/* 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 (
    <div className="someone_is_typing">
      {someoneIsTyping ? "Someone is typing..." : ""}
    </div>
  );
}

function TodoList() {
  const [draft, setDraft] = useState("");
  const updateMyPresence = useUpdateMyPresence();

  return (
    <div className="container">
      <WhoIsHere />
      <input {/* ... */ } />
      <SomeoneIsTyping />
    </div>
  );
}

/* 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 (
    <LiveblocksProvider publicApiKey={"{{SECRET_KEY}}"}>
      // +++
      <RoomProvider id="my-room" initialPresence={{ isTyping: false }}>
        // +++
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          <ToDoList />
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}
```

## Sync and persist to-dos [#add-liveblocks-storage]

<Banner title="Need help troubleshooting storage?">

Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your
collaborative experiences as you build them, in realtime.

</Banner>

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.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-1.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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 (
    <LiveblocksProvider publicApiKey={"{{SECRET_KEY}}"}>
      <RoomProvider
        id="my-room"
        initialPresence={{ isTyping: false }}
        // +++
        initialStorage={{ todos: new LiveList() }}
        // +++
      >
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          <ToDoList />
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}
```

### 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 (
    <div className="container">
      <WhoIsHere />
      <input
        {/* ... */}
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            updateMyPresence({ isTyping: false });
            setDraft("");
          }
        }}
      />
      <SomeoneIsTyping />
      {todos.map((todo, index) => {
        return (
          <div key={index} className="todo_container">
            <div className="todo">{todo.text}</div>
            <button
              className="delete_button"
            >
              ✕
            </button>
          </div>
        );
      })}
    </div>
  );
}

/* 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 (
    <div className="container">
      <WhoIsHere />
      <input
        {/* ... */}

        onKeyDown={(e) => {
          if (e.key === "Enter") {
            updateMyPresence({ isTyping: false });
            addTodo(draft);
            setDraft("");
          }
        }}
      />
      <SomeoneIsTyping />
      {todos.map((todo, index) => {
        return (
          <div key={index} className="todo_container">
            <div className="todo">{todo.text}</div>

            <button
              className="delete_button"
              onClick={() => deleteTodo(index)}
            >
              ✕
            </button>

          </div>
        );
      })}
    </div>
  );
}

/* 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).

<Figure>
  <Image
    src="/assets/tutorials/todo-list/tutorial-todo-list-dashboard.png"
    alt="Liveblocks dashboard"
    width={1380}
    height={778}
  />
</Figure>

## 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.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-1.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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](/auth/signup), then navigate to
[the dashboard](/dashboard/apikeys) to find your public key (it starts with
`pk_`).

<Banner title="Public vs secret key">

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).

</Banner>

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: (getDefaultEnhancers) =>
      getDefaultEnhancers().concat(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(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  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 <div className="container">To-do list app</div>;
}
```

## Show who’s currently in the room [#who-is-here]

<Banner title="Need help troubleshooting presence?">

Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your
collaborative experiences as you build them, in realtime.

</Banner>

Now that Liveblocks is set up, we can start updating our code to display how
many users are currently online.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-2.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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 (
    <div className="who_is_here">
      There are {othersUsersCount} other users online
    </div>
  );
}

export default function App() {
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(actions.enterRoom("redux-demo-room"));

    return () => {
      dispatch(actions.leaveRoom("redux-demo-room"));
    };
  }, [dispatch]);

  return (
    <div className="container">
      <WhoIsHere />
    </div>
  );
}
```

_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.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-3.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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,34" 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: (getDefaultEnhancers) =>
      getDefaultEnhancers().concat(
        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 ? (
    <div className="someone_is_typing">Someone is typing</div>
  ) : 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 (
    <div className="container">
      <WhoIsHere />
      <input
        className="input"
        type="text"
        placeholder="What needs to be done?"
        value={draft}
        onChange={(e) => dispatch(setDraft(e.target.value))}
      ></input>
      <SomeoneIsTyping />
    </div>
  );
}
```

## Sync and persist to-dos [#add-liveblocks-storage]

<Banner title="Need help troubleshooting storage?">

Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your
collaborative experiences as you build them, in realtime.

</Banner>

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.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-1.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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,43" 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: (getDefaultEnhancers) =>
      getDefaultEnhancers().concat(
        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 (
    <div className="container">
      <WhoIsHere />
      <input
        className="input"
        type="text"
        placeholder="What needs to be done?"
        value={draft}
        onChange={(e) => dispatch(setDraft(e.target.value))}
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            dispatch(addTodo());
          }
        }}
      ></input>
      <SomeoneIsTyping />
      {todos.map((todo, index) => {
        return (
          <div className="todo_container" key={index}>
            <div className="todo">{todo.text}</div>
            <button
              className="delete_button"
              onClick={() => dispatch(deleteTodo(index))}
            >
              ✕
            </button>
          </div>
        );
      })}
    </div>
  );
}
```

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).

<Figure>
  <Image
    src="/assets/tutorials/todo-list/tutorial-todo-list-dashboard.png"
    alt="Liveblocks dashboard"
    width={1380}
    height={778}
  />
</Figure>

## 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.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-1.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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](/auth/signup), then navigate to
[the dashboard](/dashboard/apikeys) to find your public key (it starts with
`pk_`).

<Banner title="Public vs secret key">

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).

</Banner>

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<WithLiveblocks<State>>()(
  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 <div className="container">To-do list app</div>;
}
```

## Show who’s currently in the room [#who-is-here]

<Banner title="Need help troubleshooting presence?">

Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your
collaborative experiences as you build them, in realtime.

</Banner>

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.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-2.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

```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 (
    <div className="who_is_here">
      There are {othersUsersCount} other users online
    </div>
  );
}

export default function App() {
  const {
    liveblocks: { enterRoom, leaveRoom },
  } = useStore();

  useEffect(() => {
    enterRoom("zustand-todo-app");
    return () => {
      leaveRoom("zustand-todo-app");
    };
  }, [enterRoom, leaveRoom]);

  return (
    <div className="container">
      <WhoIsHere />
    </div>
  );
}
```

_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.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-3.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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<WithLiveblocks<State>>()(
  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 ? (
    <div className="someone_is_typing">Someone is typing</div>
  ) : 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 (
    <div className="container">
      <WhoIsHere />
      <input
        className="input"
        type="text"
        placeholder="What needs to be done?"
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
      ></input>
      <SomeoneIsTyping />
    </div>
  );
}
```

## Sync and persist to-dos [#add-liveblocks-storage]

<Banner title="Need help troubleshooting storage?">

Try the [Liveblocks DevTools extension](/devtools) to inspect and debug your
collaborative experiences as you build them, in realtime.

</Banner>

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.

<Figure>
  <video autoPlay loop muted playsInline>
    <source
      src="/assets/tutorials/todo-list/tutorial-todo-list-1.mp4"
      type="video/mp4"
    />
  </video>
</Figure>

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<WithLiveblocks<State>>()(
  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 <div>Loading...</div>;
  }

  return (
    <div className="container">
      <WhoIsHere />
      <input
        className="input"
        type="text"
        placeholder="What needs to be done?"
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            addTodo();
          }
        }}
      ></input>
      <SomeoneIsTyping />
      {todos.map((todo, index) => {
        return (
          <div className="todo_container" key={index}>
            <div className="todo">{todo.text}</div>
            <button
              className="delete_button"
              onClick={() => {
                deleteTodo(index);
              }}
            >
              ✕
            </button>
          </div>
        );
      })}
    </div>
  );
}
```

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).

<Figure>
  <Image
    src="/assets/tutorials/todo-list/tutorial-todo-list-dashboard.png"
    alt="Liveblocks dashboard"
    width={1380}
    height={778}
  />
</Figure>

## 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.

<Figure>
  <Image
    src="/assets/notifications/notification-settings.jpg"
    alt="Notification settings"
    width={1344}
    height={896}
  />
</Figure>

## 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.

<Figure>
  <Image
    src="/assets/tutorials/webhooks/liveblocks-click-notifications.png"
    alt="Notifications dashboard page"
    width={712}
    height={445}
  />
</Figure>

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 ? (
        <>
          // +++
          <label>
            Receive thread notifications by email
            <input
              type="checkbox"
              checked={settings.email.thread}
              onChange={(e) =>
                updateSettings({ email: { thread: e.target.checked } })
              }
            />
          </label>
          // +++
        </>
      ) : 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 ? (
        <>
          // +++
          <label>
            Receive document invites notifications by email
            <input
              type="checkbox"
              checked={settings.email.$documentInvite}
              onChange={(e) =>
                updateSettings({ email: { $documentInvite: e.target.checked } })
              }
            />
          </label>
          // +++
          <label>
            Receive thread notifications by email
            <input
              type="checkbox"
              checked={settings.email.thread}
              onChange={(e) =>
                updateSettings({ email: { thread: e.target.checked } })
              }
            />
          </label>
        </>
      ) : 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 ? (
        <>
          <label>
            Receive document invites notifications by email
            <input
              type="checkbox"
              checked={settings.email.$documentInvite}
              onChange={(e) =>
                updateSettings({ email: { $documentInvite: e.target.checked } })
              }
            />
          </label>
          <label>
            Receive thread notifications by email
            <input
              type="checkbox"
              checked={settings.email.thread}
              onChange={(e) =>
                updateSettings({ email: { thread: e.target.checked } })
              }
            />
          </label>
          <label>
            Receive text mentions notifications by email
            <input
              type="checkbox"
              checked={settings.email.textMention}
              onChange={(e) =>
                updateSettings({ email: { textMention: e.target.checked } })
              }
            />
          </label>
        </>
      ) : null}

      {settings.slack ? (
        <>
          <label>
            Receive document invites notifications on Slack
            <input
              type="checkbox"
              checked={settings.slack.$documentInvite}
              onChange={(e) =>
                updateSettings({ slack: { $documentInvite: e.target.checked } })
              }
            />
          </label>
          <label>
            Receive thread notifications on Slack
            <input
              type="checkbox"
              checked={settings.slack.thread}
              onChange={(e) =>
                updateSettings({ slack: { thread: e.target.checked } })
              }
            />
          </label>
          <label>
            Receive text mentions notifications on Slack
            <input
              type="checkbox"
              checked={settings.slack.textMention}
              onChange={(e) =>
                updateSettings({ slack: { textMention: e.target.checked } })
              }
            />
          </label>
        </>
      ) : 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 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 customize room names in inbox notifications"
  description: "Learn how to display custom room names instead of room IDs in inbox notifications using resolveRoomsInfo."
---

When using inbox notifications with Comments, you’ll notice that room IDs are displayed by default (e.g., ”Chris mentioned you in _room-abc123_”). This isn’t ideal for users who need to see meaningful context. In this guide, you’ll learn how to replace room IDs with actual document or room names using `resolveRoomsInfo`.

## Use resolveRoomsInfo to display custom names

You can provide a [`resolveRoomsInfo`](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveRoomsInfo) function to [`LiveblocksProvider`](/docs/api-reference/liveblocks-react#LiveblocksProvider) that maps room IDs to human-readable names. This function receives an array of room IDs and should return an array of room info objects with a `name` property.

```tsx
import { LiveblocksProvider } from "@liveblocks/react/suspense";

function App() {
  return (
    <LiveblocksProvider
      resolveRoomsInfo={async ({ roomIds }) => {
        // Fetch your documents or rooms from your database
        const documentsData = await __getDocumentsFromDB__(roomIds);

        // Return room info with names
        return documentsData.map((documentData) => ({
          name: documentData.name,
        }));
      }}
      // Other props
      // ...
    >
      {/* children */}
    </LiveblocksProvider>
  );
}
```

Once you’ve added this, the [`InboxNotification`](/docs/api-reference/liveblocks-react-ui#InboxNotification) component will automatically use the room names instead of IDs, showing notifications like “Chris mentioned you in _Document A_” instead of “Chris mentioned you in _room-abc123_”.

## Adding room URLs

You can also provide a `url` property for each room, which will be used by the `InboxNotification` component to make notifications clickable.

```tsx
<LiveblocksProvider
  resolveRoomsInfo={async ({ roomIds }) => {
    const documentsData = await __getDocumentsFromDB__(roomIds);

    return documentsData.map((documentData) => ({
      name: documentData.name,
      url: documentData.url,
    }));
  }}
  // Other props
  // ...
>
  {/* children */}
</LiveblocksProvider>
```

This is particularly useful when you need additional data from your backend to construct the URL, rather than building it directly in your React components using just the room ID.

---
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'
```

<Banner>

The `AND` is optional and can actually be omitted, but we’re using it here for
clarity.

</Banner>

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:'
```

<Banner>

The `AND` is optional and can actually be omitted, but we’re using it here for
clarity.

</Banner>

### 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"
---

<Banner title="Looking for more complex permissions?" type="warning">

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).

</Banner>

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(`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.

<Banner title="Before starting" type="warning">

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_.

</Banner>

## 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, it can also be useful to modify your realtime storage from server-side
Node.js too.

## What we’re doing

<Banner title="Make sure you’re on the latest version" type="info">

This guide assumes you’re using the latest version of `@liveblocks/node`.

</Banner>

In this guide, we’ll demonstrate how to easily modify storage from the server.
We’ll do this using `@liveblocks/node`.

## Start modifying storage

We can now start modifying storage from the server!

```ts file="route.ts"
// Creating a node client (centrally)
const liveblocks = new Liveblocks({
  secret: "{{SECRET_KEY}}",
});

export async function POST() {
  console.log("Updating storage");

  // Mutate storage
  +++
  await liveblocks.mutateStorage(
    "my-room",

    ({ root }) => {
      root.get("list").push("item3");
    }
  );
  +++

  console.log("Storage update complete!");
}
```

---
meta:
  title: "How to notify users about unread comments outside of your app"
  description:
    "Learn how to trigger thread notifications externally, such as in Slack,
    Microsoft Teams, and more."
---

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, and use them to trigger notifications in
Slack, Microsoft Teams, or any other external service with an API, using
webhooks. 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.

<Banner title="Want to send email notifications?">

We have a specific guide on
[how to send email notifications of unread comments](/docs/guides/how-to-send-email-notifications-of-unread-comments),
which we recommend reading if you’re building emails.

</Banner>

## What we’re building

In this guide we’ll be learning how to notify users about unread comments
outside of your app, 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 using the
  [`@liveblocks/node`](/docs/api-reference/liveblocks-node) package.

<Banner title="Have a project ready">

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.

</Banner>

## What are inbox notifications?

Liveblocks uses the concept of inbox notifications, which differ to external
notifications. Inbox notifications are displayed within in-app inboxes, group
multiple activities together, and can change over time, like when a new comment
is added to a thread.

External notifications, such as Slack, and Microsoft Teams, are different, and
Liveblocks is set up to send them in a way that won’t overload your users with
notifications. This means that Liveblocks will wait to trigger these
notifications until a certain amount of time has passed, and will only trigger
them if your users has not read the notification on the front-end, which we
automatically keep track of. We then aggregate multiple unread comments into a
single notification, so your users just get a single ping per thread.

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, and can be
used to send external notifications 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 external notification. 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 guide, we’ll be using the Slack 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.

<Banner title="Which notification channel?">

All channels currently work the same way, apart from in-app, but we suggest
selecting the service you’re setting up, such as Slack or Microsoft Teams, so
it’s easy to tell which you’ve implemented.

</Banner>

## 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