How to create 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.

This guide assumes that you’re already familiar with React, Next.js, TypeScript, and Slate.

Install Slate, Yjs, and Liveblocks into your Next.js application

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

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

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

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

import { Liveblocks } from "@liveblocks/node";import { NextRequest } from "next/server";
const API_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.

Initialize your Liveblocks config file

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

$npx create-liveblocks-app@latest --init --framework react

Set up the client

Next, we can create the front end client which will be responsible for communicating with the back end. You can do this by modifying createClient in your config file, and passing the location of your endpoint.

const client = createClient({  authEndpoint: "/api/liveblocks-auth",});

Join a Liveblocks room

Liveblocks uses the concept of rooms, separate virtual spaces where people collaborate. To create a real-time experience, multiple users must be connected to the same room. Create a file in the current directory within /app, and name it Room.tsx.

/app/Room.tsx
"use client";
import { ReactNode } from "react";import { RoomProvider } from "../liveblocks.config";import { ClientSideSuspense } from "@liveblocks/react";
export function Room({ children }: { children: ReactNode }) { return ( <RoomProvider id={roomId} initialPresence={{ cursor: null, }} > <ClientSideSuspense fallback={<div>Loading…</div>}> {() => children} </ClientSideSuspense> </RoomProvider> );}

Set up the Slate editor

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

Editor.tsx
import LiveblocksProvider 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 LiveblocksProvider(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.

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!

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

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.

Back in your Editor component, get the current user’s info with useSelf, add the withCursors plugin, and pass the user info and awareness. Then wrap your editor in the Cursors component to see them working.

Editor.tsx
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:

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:

Theme your leaf styles

These styles are rendered with leaf components, which we’ll add to a new 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:

Editor.tsx
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. The useOthers hook allows us to view information about each user currently online, and we can turn this into a live avatars component.

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:

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

Editor.tsx
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.