Skip to main content
Back to Blog
Tutorials
4 min read
December 8, 2024

How to Build a Real-Time Collaborative Text Editor with Next.js

Build a collaborative text editor with real-time sync using Yjs, TipTap, and WebSocket connections in Next.js.

Ryel Banfield

Founder & Lead Developer

Real-time collaboration lets multiple users edit the same document simultaneously, like Google Docs. This tutorial uses Yjs (a CRDT library) and TipTap for the rich text editor.

Install Dependencies

pnpm add @tiptap/react @tiptap/starter-kit @tiptap/extension-collaboration \
  @tiptap/extension-collaboration-cursor @tiptap/extension-placeholder \
  yjs y-websocket y-prosemirror

Set Up the Yjs WebSocket Server

// server/collab-server.ts
import { WebSocketServer } from "ws";
import { setupWSConnection } from "y-websocket/bin/utils";

const wss = new WebSocketServer({ port: 1234 });

wss.on("connection", (ws, req) => {
  const docName = new URL(req.url ?? "/", "http://localhost").searchParams.get(
    "room"
  ) ?? "default";

  setupWSConnection(ws, req, { docName });
});

console.log("Yjs WebSocket server running on ws://localhost:1234");

Create the Collaborative Editor Component

"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 Placeholder from "@tiptap/extension-placeholder";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { useEffect, useMemo, useState } from "react";

const COLORS = [
  "#958DF1",
  "#F98181",
  "#FBBC88",
  "#FAF594",
  "#70CFF8",
  "#94FADB",
  "#B9F18D",
];

function getRandomColor() {
  return COLORS[Math.floor(Math.random() * COLORS.length)];
}

interface CollabEditorProps {
  roomId: string;
  userName: string;
}

export function CollabEditor({ roomId, userName }: CollabEditorProps) {
  const [status, setStatus] = useState<"connecting" | "connected" | "disconnected">(
    "connecting"
  );
  const [connectedUsers, setConnectedUsers] = useState<
    { name: string; color: string }[]
  >([]);

  const { ydoc, provider } = useMemo(() => {
    const ydoc = new Y.Doc();
    const provider = new WebsocketProvider(
      "ws://localhost:1234",
      roomId,
      ydoc
    );

    return { ydoc, provider };
  }, [roomId]);

  useEffect(() => {
    provider.on("status", ({ status }: { status: string }) => {
      setStatus(status as "connecting" | "connected" | "disconnected");
    });

    provider.awareness.setLocalStateField("user", {
      name: userName,
      color: getRandomColor(),
    });

    const updateUsers = () => {
      const users: { name: string; color: string }[] = [];
      provider.awareness.getStates().forEach((state) => {
        if (state.user) {
          users.push(state.user);
        }
      });
      setConnectedUsers(users);
    };

    provider.awareness.on("change", updateUsers);
    updateUsers();

    return () => {
      provider.awareness.off("change", updateUsers);
      provider.destroy();
      ydoc.destroy();
    };
  }, [provider, ydoc, userName]);

  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        history: false, // Yjs handles undo/redo
      }),
      Collaboration.configure({
        document: ydoc,
      }),
      CollaborationCursor.configure({
        provider,
        user: {
          name: userName,
          color: getRandomColor(),
        },
      }),
      Placeholder.configure({
        placeholder: "Start typing...",
      }),
    ],
  });

  return (
    <div className="border rounded-lg overflow-hidden">
      <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
        <div className="flex items-center gap-2">
          <div
            className={`h-2 w-2 rounded-full ${
              status === "connected"
                ? "bg-green-500"
                : status === "connecting"
                  ? "bg-yellow-500"
                  : "bg-red-500"
            }`}
          />
          <span className="text-xs text-muted-foreground capitalize">{status}</span>
        </div>

        <div className="flex -space-x-2">
          {connectedUsers.map((user, i) => (
            <div
              key={i}
              className="h-6 w-6 rounded-full border-2 border-background flex items-center justify-center text-[10px] font-bold text-white"
              style={{ backgroundColor: user.color }}
              title={user.name}
            >
              {user.name[0]?.toUpperCase()}
            </div>
          ))}
        </div>
      </div>

      <Toolbar editor={editor} />

      <EditorContent editor={editor} className="prose max-w-none p-4 min-h-[300px]" />
    </div>
  );
}

Add a Rich Text Toolbar

"use client";

import type { Editor } from "@tiptap/react";

interface ToolbarProps {
  editor: Editor | null;
}

function Toolbar({ editor }: ToolbarProps) {
  if (!editor) return null;

  const buttons = [
    {
      label: "B",
      action: () => editor.chain().focus().toggleBold().run(),
      active: editor.isActive("bold"),
      className: "font-bold",
    },
    {
      label: "I",
      action: () => editor.chain().focus().toggleItalic().run(),
      active: editor.isActive("italic"),
      className: "italic",
    },
    {
      label: "S",
      action: () => editor.chain().focus().toggleStrike().run(),
      active: editor.isActive("strike"),
      className: "line-through",
    },
    {
      label: "H1",
      action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
      active: editor.isActive("heading", { level: 1 }),
    },
    {
      label: "H2",
      action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
      active: editor.isActive("heading", { level: 2 }),
    },
    {
      label: "List",
      action: () => editor.chain().focus().toggleBulletList().run(),
      active: editor.isActive("bulletList"),
    },
    {
      label: "Quote",
      action: () => editor.chain().focus().toggleBlockquote().run(),
      active: editor.isActive("blockquote"),
    },
    {
      label: "Code",
      action: () => editor.chain().focus().toggleCodeBlock().run(),
      active: editor.isActive("codeBlock"),
    },
  ];

  return (
    <div className="flex items-center gap-1 px-4 py-2 border-b flex-wrap">
      {buttons.map((btn) => (
        <button
          key={btn.label}
          type="button"
          onClick={btn.action}
          className={`px-2 py-1 text-sm rounded ${btn.className ?? ""} ${
            btn.active
              ? "bg-primary text-primary-foreground"
              : "hover:bg-muted"
          }`}
        >
          {btn.label}
        </button>
      ))}

      <div className="ml-auto flex gap-1">
        <button
          type="button"
          onClick={() => editor.chain().focus().undo().run()}
          className="px-2 py-1 text-sm rounded hover:bg-muted"
          disabled={!editor.can().undo()}
        >
          Undo
        </button>
        <button
          type="button"
          onClick={() => editor.chain().focus().redo().run()}
          className="px-2 py-1 text-sm rounded hover:bg-muted"
          disabled={!editor.can().redo()}
        >
          Redo
        </button>
      </div>
    </div>
  );
}

Use the Editor in a Page

// app/documents/[id]/page.tsx
import { CollabEditor } from "@/components/CollabEditor";

interface Props {
  params: Promise<{ id: string }>;
}

export default async function DocumentPage({ params }: Props) {
  const { id } = await params;

  // In production, get user from auth
  const userName = "User " + Math.floor(Math.random() * 1000);

  return (
    <div className="max-w-4xl mx-auto py-8 px-4">
      <h1 className="text-2xl font-bold mb-6">Document Editor</h1>
      <CollabEditor roomId={id} userName={userName} />
    </div>
  );
}

Persist Documents to Database

// server/persistence.ts
import * as Y from "yjs";
import { db } from "@/db";
import { documents } from "@/db/schema";
import { eq } from "drizzle-orm";

export async function loadDocument(docName: string): Promise<Uint8Array | null> {
  const doc = await db.query.documents.findFirst({
    where: eq(documents.id, docName),
  });
  return doc?.content ?? null;
}

export async function saveDocument(docName: string, ydoc: Y.Doc) {
  const content = Y.encodeStateAsUpdate(ydoc);

  await db
    .insert(documents)
    .values({
      id: docName,
      content: Buffer.from(content),
      updatedAt: new Date(),
    })
    .onConflictDoUpdate({
      target: documents.id,
      set: {
        content: Buffer.from(content),
        updatedAt: new Date(),
      },
    });
}

Key Concepts

  • CRDT: Conflict-free Replicated Data Type. Yjs merges concurrent edits automatically without a central server deciding conflicts.
  • Awareness: Tracks ephemeral state like cursor positions and user presence.
  • Y.Doc: The shared document that syncs across all connected clients.
  • WebSocketProvider: Handles the transport layer between clients and the sync server.

Need a Collaborative Application Built?

We build real-time collaborative tools with conflict-free sync, presence awareness, and offline support. Contact us to discuss your project.

collaborative editingreal-timeYjsWebSocketNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles