Real-time collaborative editing has become a standard feature in modern web applications. Yjs provides a robust CRDT (Conflict-free Replicated Data Type) implementation that handles the complex synchronization logic, while Next.js 15 offers the perfect foundation for building performant full-stack applications. This combination enables developers to create production-ready collaborative editors with minimal infrastructure complexity.
Understanding Yjs and CRDTs
Yjs implements CRDTs to enable conflict-free merging of concurrent edits from multiple users. Unlike operational transformation (OT), which requires a central server to resolve conflicts, CRDTs allow each client to independently merge changes without coordination. This architecture provides better offline support and reduces server load.
The library works by tracking changes at a granular level—individual character insertions, deletions, and formatting changes. When multiple users edit the same document simultaneously, Yjs automatically resolves conflicts using its CRDT algorithms. The result is eventual consistency across all connected clients without requiring complex server-side logic.
Setting Up the Project Structure
Next.js 15 introduces several improvements for real-time applications, including enhanced Server Actions and improved WebSocket support. The architecture requires both client-side collaborative editing and server-side state synchronization.
1npm install yjs y-websocket y-prosemirror prosemirror-view prosemirror-state prosemirror-schema-basic
2npm install --save-dev @types/prosemirror-view @types/prosemirror-stateFor the WebSocket server, Next.js custom server setup works well with the y-websocket provider:
1// server.js
2const { createServer } = require('http');
3const { parse } = require('url');
4const next = require('next');
5const { WebSocketServer } = require('ws');
6const { setupWSConnection } = require('y-websocket/bin/utils');
7
8const dev = process.env.NODE_ENV !== 'production';
9const app = next({ dev });
10const handle = app.getRequestHandler();
11
12app.prepare().then(() => {
13 const server = createServer((req, res) => {
14 const parsedUrl = parse(req.url, true);
15 handle(req, res, parsedUrl);
16 });
17
18 const wss = new WebSocketServer({ server });
19
20 wss.on('connection', (ws, req) => {
21 setupWSConnection(ws, req, {
22 gc: true // Enable garbage collection for deleted content
23 });
24 });
25
26 const port = process.env.PORT || 3000;
27 server.listen(port, () => {
28 console.log(`> Ready on http://localhost:${port}`);
29 });
30});Building the Collaborative Editor Component
ProseMirror provides a solid foundation for rich text editing. The integration with Yjs happens through the y-prosemirror binding, which synchronizes ProseMirror's document state with Yjs's shared type.
1// components/CollaborativeEditor.tsx
2'use client';
3
4import { useEffect, useRef, useState } from 'react';
5import { EditorState } from 'prosemirror-state';
6import { EditorView } from 'prosemirror-view';
7import { Schema, DOMParser } from 'prosemirror-model';
8import { schema } from 'prosemirror-schema-basic';
9import { exampleSetup } from 'prosemirror-example-setup';
10import * as Y from 'yjs';
11import { WebsocketProvider } from 'y-websocket';
12import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror';
13
14interface CollaborativeEditorProps {
15 documentId: string;
16 userName: string;
17}
18
19export default function CollaborativeEditor({
20 documentId,
21 userName
22}: CollaborativeEditorProps) {
23 const editorRef = useRef<HTMLDivElement>(null);
24 const viewRef = useRef<EditorView | null>(null);
25 const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
26
27 useEffect(() => {
28 if (!editorRef.current) return;
29
30 // Initialize Yjs document and shared type
31 const ydoc = new Y.Doc();
32 const yXmlFragment = ydoc.getXmlFragment('prosemirror');
33
34 // Connect to WebSocket server
35 const provider = new WebsocketProvider(
36 process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:3000',
37 documentId,
38 ydoc,
39 {
40 connect: true,
41 params: { userName }
42 }
43 );
44
45 provider.on('status', (event: { status: string }) => {
46 setConnectionStatus(event.status as any);
47 });
48
49 // Configure awareness for cursor positions
50 const awareness = provider.awareness;
51 awareness.setLocalStateField('user', {
52 name: userName,
53 color: `#${Math.floor(Math.random()*16777215).toString(16)}`
54 });
55
56 // Create ProseMirror editor state
57 const state = EditorState.create({
58 schema,
59 plugins: [
60 ySyncPlugin(yXmlFragment),
61 yCursorPlugin(awareness),
62 yUndoPlugin(),
63 ...exampleSetup({ schema })
64 ]
65 });
66
67 // Initialize editor view
68 const view = new EditorView(editorRef.current, {
69 state,
70 dispatchTransaction(transaction) {
71 const newState = view.state.apply(transaction);
72 view.updateState(newState);
73 }
74 });
75
76 viewRef.current = view;
77
78 // Cleanup on unmount
79 return () => {
80 provider.destroy();
81 view.destroy();
82 };
83 }, [documentId, userName]);
84
85 return (
86 <div className="editor-container">
87 <div className="status-bar">
88 <span className={`status-indicator status-${connectionStatus}`}>
89 {connectionStatus}
90 </span>
91 </div>
92 <div ref={editorRef} className="editor" />
93 </div>
94 );
95}Implementing Persistence with Server Actions
Next.js 15 Server Actions provide a clean way to persist document snapshots. While Yjs handles real-time synchronization, periodic snapshots ensure data durability and faster initial loads for new clients.
1// app/actions/documents.ts
2'use server';
3
4import { sql } from '@vercel/postgres';
5import * as Y from 'yjs';
6
7export async function saveDocumentSnapshot(
8 documentId: string,
9 stateVector: Uint8Array
10) {
11 try {
12 // Convert Uint8Array to base64 for storage
13 const base64State = Buffer.from(stateVector).toString('base64');
14
15 await sql`
16 INSERT INTO document_snapshots (document_id, state_vector, updated_at)
17 VALUES (${documentId}, ${base64State}, NOW())
18 ON CONFLICT (document_id)
19 DO UPDATE SET state_vector = ${base64State}, updated_at = NOW()
20 `;
21
22 return { success: true };
23 } catch (error) {
24 console.error('Failed to save snapshot:', error);
25 return { success: false, error: 'Failed to save document' };
26 }
27}
28
29export async function loadDocumentSnapshot(documentId: string) {
30 try {
31 const result = await sql`
32 SELECT state_vector FROM document_snapshots
33 WHERE document_id = ${documentId}
34 `;
35
36 if (result.rows.length === 0) {
37 return null;
38 }
39
40 // Convert base64 back to Uint8Array
41 const stateVector = Buffer.from(result.rows[0].state_vector, 'base64');
42 return new Uint8Array(stateVector);
43 } catch (error) {
44 console.error('Failed to load snapshot:', error);
45 return null;
46 }
47}Advertisement
The WebSocket server can be enhanced to load initial state and periodically save snapshots:
1// Enhanced setupWSConnection callback
2wss.on('connection', async (ws, req) => {
3 const docName = req.url?.slice(1).split('?')[0];
4
5 setupWSConnection(ws, req, {
6 gc: true,
7 async persistence(doc) {
8 // Save snapshot every 30 seconds of activity
9 const stateVector = Y.encodeStateAsUpdate(doc);
10 await saveDocumentSnapshot(docName, stateVector);
11 }
12 });
13});Handling Offline Support and Conflict Resolution
One significant advantage of Yjs is built-in offline support. The library automatically queues changes made while disconnected and merges them when connection resumes. However, developers should implement proper UI feedback for offline states.
The y-indexeddb provider enables local persistence:
1import { IndexeddbPersistence } from 'y-indexeddb';
2
3// Add to the editor initialization
4const indexeddbProvider = new IndexeddbPersistence(documentId, ydoc);
5
6indexeddbProvider.on('synced', () => {
7 console.log('Local content loaded from IndexedDB');
8});
9
10// The document will sync with IndexedDB automatically
11// and merge with server state when connection is restoredFor applications requiring strict ordering or validation, consider implementing a middleware layer that validates changes before broadcasting. This adds latency but prevents invalid states:
1// Validation middleware in WebSocket server
2wss.on('connection', (ws, req) => {
3 const originalSend = ws.send.bind(ws);
4
5 ws.send = function(data, ...args) {
6 // Parse and validate Yjs updates
7 const update = new Uint8Array(data);
8 if (validateUpdate(update)) {
9 originalSend(data, ...args);
10 } else {
11 console.warn('Invalid update rejected');
12 }
13 };
14
15 setupWSConnection(ws, req);
16});Performance Considerations and Scaling
Yjs performs well for documents up to several thousand concurrent operations. For larger documents, consider implementing document sharding—splitting content into multiple Yjs documents based on logical sections.
Memory usage scales with document history. The garbage collection option (gc: true) removes tombstones from deleted content, but this prevents clients from syncing if they've been offline for extended periods. The trade-off depends on the application's requirements:
- Enable GC for applications where users rarely work offline for extended periods - Disable GC for applications requiring full history preservation or frequent offline work
WebSocket connection limits become relevant at scale. A single Node.js process handles approximately 10,000-15,000 concurrent WebSocket connections. Beyond this threshold, implement horizontal scaling with a message broker like Redis:
1// Using Redis for multi-server synchronization
2const Redis = require('ioredis');
3const pub = new Redis();
4const sub = new Redis();
5
6sub.subscribe('yjs-updates');
7
8sub.on('message', (channel, message) => {
9 // Broadcast to local WebSocket clients
10 wss.clients.forEach(client => {
11 if (client.readyState === WebSocket.OPEN) {
12 client.send(message);
13 }
14 });
15});
16
17// When receiving updates from clients
18ws.on('message', (data) => {
19 pub.publish('yjs-updates', data);
20});The combination of Next.js 15 and Yjs provides a robust foundation for collaborative editing. The CRDT approach eliminates complex server-side conflict resolution while maintaining strong consistency guarantees. For production deployments, implement proper monitoring for WebSocket connections, document size metrics, and sync latency to identify bottlenecks early.






