Online Document Editor Development
A collaborative document editor is one of the most technically complex tasks in web development. Google Docs took years to build. But if you don't need full parity with Docs and need specific functionality (text formatting, comments, multiple authors simultaneously) it's achievable in reasonable time with the right tools.
Choosing an Editor Engine
Three options with their own trade-offs.
ProseMirror — low-level, maximum flexibility, steep learning curve. Built Notion, Atlassian Confluence, GitLab on it. Suitable when you need non-standard document schema.
Tiptap — wrapper over ProseMirror, provides convenient extension API, good documentation, built-in Y.js collaboration support:
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 * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const ydoc = new Y.Doc();
const provider = new WebsocketProvider('wss://collab.example.com', documentId, ydoc);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }), // disable — Y.js manages history
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider,
user: { name: currentUser.name, color: currentUser.color },
}),
],
});
Lexical (Meta) — newer, better performance on large documents, active development. Fewer ready-made extensions.
CRDT via Y.js
Operational Transformation (OT) is the old approach Google Docs uses. CRDT (Conflict-free Replicated Data Types) is modern, simpler to implement distributed system. Y.js is the most mature CRDT library for JavaScript.
Principle: each change is an operation that can be applied in any order and give the same result. No central server to serialize operations.
import * as Y from 'yjs';
const doc = new Y.Doc();
const ytext = doc.getText('content');
// Two users edit offline
const doc1 = new Y.Doc();
const doc2 = new Y.Doc();
const text1 = doc1.getText('content');
const text2 = doc2.getText('content');
// Both start with same state
const initialState = Y.encodeStateAsUpdate(doc);
Y.applyUpdate(doc1, initialState);
Y.applyUpdate(doc2, initialState);
// User 1 inserts "Hello"
text1.insert(0, 'Hello');
// User 2 inserts "World" — offline
text2.insert(0, 'World');
// Sync: apply updates from doc1 to doc2 and vice versa
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1));
Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2));
// Both docs converge to one state (order depends on algorithm)
console.log(text1.toString()); // "HelloWorld" or "WorldHello" — deterministically
console.log(text2.toString()); // same
WebSocket Server for Y.js
y-websocket is the reference implementation, Node.js:
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils.js';
import { createClient } from 'redis';
const wss = new WebSocketServer({ port: 1234 });
// Persistence via Redis (instead of default in-memory)
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const persistence = {
provider: 'redis',
bindState: async (docName, ydoc) => {
const savedState = await redis.get(`ydoc:${docName}`);
if (savedState) {
Y.applyUpdate(ydoc, Buffer.from(savedState, 'base64'));
}
ydoc.on('update', async (update) => {
// Save full state on each update
const state = Y.encodeStateAsUpdate(ydoc);
await redis.set(
`ydoc:${docName}`,
Buffer.from(state).toString('base64'),
{ EX: 86400 * 30 } // 30 days
);
});
},
writeState: async () => {},
};
wss.on('connection', (ws, req) => {
const docName = new URL(req.url, 'ws://x').pathname.slice(1);
setupWSConnection(ws, req, { docName, persistence });
});
For production: hocuspocus (official backend server for Tiptap) or y-redis for persistence.
Document Structure: Database Schema
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL DEFAULT 'Untitled',
owner_id BIGINT REFERENCES users(id),
ydoc_state BYTEA, -- serialized Y.Doc state
snapshot_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE document_collaborators (
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES users(id),
role TEXT CHECK (role IN ('viewer', 'commenter', 'editor', 'owner')),
invited_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (document_id, user_id)
);
-- Version history (snapshots)
CREATE TABLE document_snapshots (
id BIGSERIAL PRIMARY KEY,
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
ydoc_state BYTEA NOT NULL,
created_by BIGINT REFERENCES users(id),
label TEXT, -- "before publication", "version for client"
created_at TIMESTAMPTZ DEFAULT NOW()
);
Comments and Change Tracking
Comments in ProseMirror/Tiptap are implemented via marks with ID:
// Extension for comments
const Comment = Mark.create({
name: 'comment',
inclusive: false,
addAttributes() {
return {
commentId: { default: null },
resolved: { default: false },
};
},
parseHTML() {
return [{ tag: 'span[data-comment-id]' }];
},
renderHTML({ HTMLAttributes }) {
return ['span', {
...HTMLAttributes,
'data-comment-id': HTMLAttributes.commentId,
class: HTMLAttributes.resolved ? 'comment resolved' : 'comment',
}, 0];
},
});
// Add comment to selection
function addComment(editor: Editor, text: string) {
const commentId = crypto.randomUUID();
editor.chain().focus().setMark('comment', { commentId }).run();
// Save comment text to DB
saveComment({ commentId, text, documentId });
}
Document Export
Export to DOCX via docx (npm) or via pandoc on backend:
// Convert ProseMirror JSON → HTML → DOCX via pandoc
async function exportToDocx(documentId: string): Promise<Buffer> {
const doc = await getDocument(documentId);
const html = prosemirrorToHtml(doc.content); // via prosemirror-to-html
// pandoc on backend
const { stdout } = await exec(
`echo '${html.replace(/'/g, "'\\''")}' | pandoc -f html -t docx -o -`,
{ encoding: 'buffer' }
);
return stdout;
}
// Or natively via docx npm package
import { Document, Paragraph, TextRun, Packer } from 'docx';
function generateDocx(nodes: ProseMirrorNode[]): Promise<Buffer> {
const paragraphs = nodes.map(node => {
const runs = node.content?.map(inline =>
new TextRun({
text: inline.text || '',
bold: inline.marks?.some(m => m.type === 'bold'),
italics: inline.marks?.some(m => m.type === 'italic'),
})
) ?? [];
return new Paragraph({ children: runs });
});
const doc = new Document({ sections: [{ children: paragraphs }] });
return Packer.toBuffer(doc);
}
Access Rights and Sharing
Three levels: view, comment, edit. Public links with optional password:
class DocumentShareController extends Controller
{
public function createShareLink(Request $request, string $docId): JsonResponse
{
$doc = Document::where('id', $docId)
->where('owner_id', $request->user()->id)
->firstOrFail();
$share = DocumentShare::create([
'document_id' => $docId,
'token' => Str::random(32),
'permission' => $request->input('permission', 'viewer'),
'password' => $request->filled('password')
? bcrypt($request->input('password'))
: null,
'expires_at' => $request->input('expires_at'),
]);
return response()->json([
'url' => route('doc.shared', $share->token),
]);
}
}
Timeline
Single editor with formatting, PDF/DOCX export, comments: 6–8 weeks. Adding real-time (Y.js + WebSocket), presence cursors, version history: another 4–6 weeks. Full permissions system, audit log, OAuth provider integration: another 3–4 weeks.







