Yjs is a CRDT (conflict-free replicated data type) implementation for building collaborative applications — new Y.Doc() creates a shared document. doc.getMap("state") and doc.getArray("items") create CRDT types that merge conflict-free across peers. new WebsocketProvider(serverUrl, room, doc) syncs via WebSocket. new WebRTCProvider(room, doc) syncs peer-to-peer. provider.awareness manages cursor presence and user metadata. Changes from any peer are automatically merged without conflicts using vector clocks. doc.on("update", (update) => { /* persist or broadcast */ }) listens for changes. Y.applyUpdate(doc, update) applies received updates. new UndoManager(doc.getText("content")) adds undo/redo scoped to a type. Yjs binds to TipTap with @hocuspocus/provider, to Lexical with @lexical/yjs, and to CodeMirror with y-codemirror. y-indexeddb persists documents in the browser. Claude Code generates Yjs doc setup, WebSocket sync servers, awareness cursors, and editor bindings for collaborative applications.
CLAUDE.md for Yjs
## Yjs Stack
- Version: yjs >= 13.6, y-websocket >= 1.5
- Doc: const doc = new Y.Doc(); const map = doc.getMap("data"); const arr = doc.getArray("items")
- Sync: const provider = new WebsocketProvider("ws://localhost:1234", "room", doc)
- Awareness: provider.awareness.setLocalStateField("user", { name, color, cursor })
- Update: doc.on("update", update => broadcast(update)); Y.applyUpdate(doc, remoteUpdate)
- Persist: Y.encodeStateAsUpdate(doc) → store; Y.applyUpdate(doc, storedUpdate) → restore
- TipTap: @hocuspocus/provider with tiptap CollaborationExtension
- UndoMgr: new UndoManager([doc.getText("content")], { trackedOrigins: new Set(["local"]) })
Yjs Document and CRDT Types
// lib/yjs/doc.ts — shared document setup
import * as Y from "yjs"
// Shared document — all collaborative state lives here
export function createBoardDoc() {
const doc = new Y.Doc()
// Y.Map — key-value store, conflict-free merge on concurrent updates
const metadata = doc.getMap<string>("metadata")
// Nested map via Y.Map of Y.Maps
const cards = doc.getMap<Y.Map<unknown>>("cards")
// Y.Array — ordered list with conflict-free insert/delete
const columns = doc.getArray<string>("columns")
// Y.Text — conflict-free rich text editing
const description = doc.getText("description")
return { doc, metadata, cards, columns, description }
}
// Card operations — all CRDT, conflict-free
export function addCard(
cards: Y.Map<Y.Map<unknown>>,
card: { id: string; title: string; columnId: string; order: number },
) {
const cardMap = new Y.Map<unknown>()
cardMap.set("id", card.id)
cardMap.set("title", card.title)
cardMap.set("columnId", card.columnId)
cardMap.set("order", card.order)
cardMap.set("createdAt", Date.now())
cards.set(card.id, cardMap)
}
export function updateCardTitle(
cards: Y.Map<Y.Map<unknown>>,
cardId: string,
title: string,
) {
const card = cards.get(cardId)
if (card) {
card.set("title", title)
card.set("updatedAt", Date.now())
}
}
export function moveCard(
cards: Y.Map<Y.Map<unknown>>,
cardId: string,
newColumnId: string,
newOrder: number,
) {
// Transactional — both changes apply atomically
cards.doc!.transact(() => {
const card = cards.get(cardId)
if (card) {
card.set("columnId", newColumnId)
card.set("order", newOrder)
}
})
}
WebSocket Sync Server
// server/yjs-server.ts — y-websocket sync server (Node.js)
import { WebSocketServer } from "ws"
import { setupWSConnection, docs } from "y-websocket/bin/utils"
import http from "http"
import * as Y from "yjs"
import { db } from "@/lib/db"
const server = http.createServer((req, res) => {
res.writeHead(200)
res.end("Yjs WebSocket Server")
})
const wss = new WebSocketServer({ server })
wss.on("connection", (ws, req) => {
// Extract room from URL: /room-name
const room = req.url?.slice(1) ?? "default"
// Load persisted state for this room
const docName = room
setupWSConnection(ws, req, {
docName,
gc: true, // Garbage collect deleted items
})
})
// Persist Yjs updates to database
const persistDoc = async (room: string, doc: Y.Doc) => {
const update = Y.encodeStateAsUpdate(doc)
await db.yDoc.upsert({
where: { room },
create: { room, data: Buffer.from(update) },
update: { data: Buffer.from(update), updatedAt: new Date() },
})
}
// Load persisted docs on connection
const originalGetDoc = (docs as any).get.bind(docs)
;(docs as any).get = async (docName: string) => {
let doc = originalGetDoc(docName)
if (!doc._loaded) {
const saved = await db.yDoc.findUnique({ where: { room: docName } })
if (saved) {
Y.applyUpdate(doc, new Uint8Array(saved.data))
}
doc._loaded = true
}
return doc
}
server.listen(1234)
console.log("Yjs WebSocket server running on :1234")
React Collaboration Hook
// hooks/useCollaborativeDoc.ts — React hook for Yjs
"use client"
import { useEffect, useRef, useState, useCallback } from "react"
import * as Y from "yjs"
import { WebsocketProvider } from "y-websocket"
import type { Awareness } from "y-protocols/awareness"
type User = { name: string; color: string; cursor?: { x: number; y: number } }
export function useCollaborativeBoard(roomId: string, currentUser: User) {
const docRef = useRef<Y.Doc | null>(null)
const providerRef = useRef<WebsocketProvider | null>(null)
const [connected, setConnected] = useState(false)
const [awarenessUsers, setAwarenessUsers] = useState<Map<number, { user: User }>>(new Map())
useEffect(() => {
const doc = new Y.Doc()
const provider = new WebsocketProvider(
process.env.NEXT_PUBLIC_YJS_SERVER ?? "ws://localhost:1234",
`board-${roomId}`,
doc,
)
docRef.current = doc
providerRef.current = provider
// Set local awareness state
provider.awareness.setLocalStateField("user", currentUser)
provider.on("status", ({ status }: { status: string }) => {
setConnected(status === "connected")
})
// Track connected users via awareness
const updateAwareness = () => {
setAwarenessUsers(new Map(provider.awareness.getStates() as Map<number, { user: User }>))
}
provider.awareness.on("change", updateAwareness)
updateAwareness()
return () => {
provider.awareness.off("change", updateAwareness)
provider.destroy()
doc.destroy()
}
}, [roomId, currentUser])
const updateCursor = useCallback((x: number, y: number) => {
providerRef.current?.awareness.setLocalStateField("user", {
...currentUser,
cursor: { x, y },
})
}, [currentUser])
return {
doc: docRef.current,
cards: docRef.current?.getMap<Y.Map<unknown>>("cards"),
connected,
users: awarenessUsers,
updateCursor,
}
}
User Cursors Component
// components/collaboration/UserCursors.tsx — show remote cursors
"use client"
type AwarenessUser = { name: string; color: string; cursor?: { x: number; y: number } }
export function UserCursors({
users,
localClientId,
}: {
users: Map<number, { user: AwarenessUser }>
localClientId: number
}) {
return (
<div className="pointer-events-none fixed inset-0 z-50">
{Array.from(users.entries()).map(([clientId, state]) => {
if (clientId === localClientId || !state.user?.cursor) return null
const { cursor, name, color } = state.user
return (
<div
key={clientId}
className="absolute transition-all duration-100"
style={{ left: cursor.x, top: cursor.y }}
>
{/* Cursor arrow */}
<svg width="16" height="20" viewBox="0 0 16 20" className="drop-shadow-sm">
<path d="M0 0 L0 16 L4 12 L8 20 L10 19 L6 11 L12 11 Z" fill={color} />
</svg>
{/* Name label */}
<span
className="absolute left-4 top-0 text-xs px-1.5 py-0.5 rounded text-white whitespace-nowrap font-medium"
style={{ backgroundColor: color }}
>
{name}
</span>
</div>
)
})}
</div>
)
}
For the Liveblocks alternative when a managed real-time collaboration backend with built-in conflict resolution, REST APIs, webhooks, and a generous free tier are preferred over self-hosting a Yjs WebSocket server — Liveblocks wraps Yjs with a cloud sync layer, see the Liveblocks guide. For the Ably alternative when a general-purpose real-time messaging platform with channels, presence, and history is preferred over CRDT-based collaborative editing — Ably suits real-time chat and notifications better than document collaboration but doesn’t handle merge conflicts automatically, see the Ably guide. The Claude Skills 360 bundle includes Yjs skill sets covering CRDT types, sync providers, and awareness. Start with the free tier to try collaborative editing generation.