Online Document Editor Development

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

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.