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.