Guide
How to build a collaborative document editor with DeepSpace
Published July 22, 2026 · Updated July 22, 2026
A collaborative document editor lets several people type in the same document at once, with every keystroke merged conflict-free and every caret visible to everyone else. This guide shows how to build one on DeepSpace — a full-stack SDK and hosting platform where real-time collaboration, auth, and hosting come from one npm package.
The walkthrough follows DeepSpace Docs, an open-source multiplayer editor built on the DeepSpace SDK and deployed at docs.app.space. Everything described below is in its public source.
What you will build
The finished app is a document workspace in the style of Google Docs: a library with folders and templates, and a rich-text editor where edits, cursors, and presence all sync live.
- Rich-text editing on Tiptap (ProseMirror) — headings, lists, images, slash commands, find & replace, and a document outline.
- Real-time co-editing backed by Yjs: concurrent edits merge without conflicts, and each document persists server-side in its own room.
- Multiplayer cursors, selection highlights, presence avatars, and typing indicators.
- Sharing with viewer and editor roles, including email invites for people who have never signed in.
Start from the DeepSpace scaffold
One command scaffolds the app — worker, schemas, pages, auth, and deploy config: npm create deepspace@latest my-docs. The frontend and the Cloudflare Worker backend ship as a single package.
For this archetype the shortcut is real: npx deepspace add docs installs the same collaborative editor DeepSpace Docs is built around — the Tiptap editor surface, the Yjs room hook, the document library with folders and favorites, presence, and the invite dialog — as code in your project that you can restyle and extend. npx deepspace dev runs it locally with hot reload.
Model documents and access control
DeepSpace Docs splits every document in two: a metadata record and a live content room. The documents collection stores the title and who can access the document; the text itself never touches this collection.
// src/schemas/docs-schema.ts (abridged from DeepSpace Docs)
import type { CollectionSchema } from 'deepspace/worker'
export const docsSchema: CollectionSchema = {
name: 'documents',
columns: [
{ name: 'title', storage: 'text', interpretation: 'plain', required: true },
{ name: 'ownerId', storage: 'text', interpretation: 'plain',
required: true, userBound: true, immutable: true },
// JSON arrays of user ids: who can read, and who can also write.
{ name: 'collaborators', storage: 'text', interpretation: 'plain' },
{ name: 'editors', storage: 'text', interpretation: 'plain' },
{ name: 'folderId', storage: 'text', interpretation: 'plain' },
],
ownerField: 'ownerId',
collaboratorsField: 'collaborators',
permissions: {
viewer: { read: 'collaborator', create: false, update: false, delete: false },
member: { read: 'collaborator', create: true, update: 'own', delete: 'own' },
admin: { read: true, create: true, update: true, delete: true },
},
}Three schema fields do the security work. userBound + immutable mean the server stamps ownerId from the verified session and nobody can rewrite it. read: 'collaborator' grants reads to the owner or anyone listed in collaborators — enforced per row in the Durable Object. The worker reuses the same fields to gate the live editing socket: owner and editors connect with write access, collaborators connect read-only, and everyone else is rejected before reaching the document.
Build the editor on Tiptap and Yjs
Concurrent editing is powered by Yjs, a conflict-free replicated data type (CRDT) library: every writer holds a local replica of the document, edits apply locally first, and replicas converge to the same state no matter the order updates arrive. DeepSpace gives each document its own server-side Yjs room that applies updates, persists the document state, and rebroadcasts changes to every other connected replica.
// Editor wiring (abridged from DeepSpace Docs)
import { useEditor } from '@tiptap/react'
import Collaboration from '@tiptap/extension-collaboration'
// Installed into your app by `npx deepspace add docs`,
// built on Yjs + awareness primitives exported from 'deepspace':
import { useYjsRoomWithAwareness } from './use-yjs-room-with-awareness'
import { CollaborationCaret } from './editor/extensions/CollaborationCaret'
const { doc, awareness, canWrite } =
useYjsRoomWithAwareness(docId, 'content')
// The shared XML fragment Tiptap binds to:
const fragment = doc.getXmlFragment('default')
const editor = useEditor({
extensions: [
// Tiptap binds ProseMirror to the shared Yjs fragment —
// concurrent edits merge without conflicts.
Collaboration.configure({ fragment }),
// Remote carets + selections, driven by Yjs awareness.
CollaborationCaret.configure({
provider: { awareness },
// name/color come from your signed-in user profile
user: { name, color },
}),
],
editable: canWrite, // server-confirmed; read-only until the auth frame lands
})Tiptap's Collaboration extension binds the ProseMirror document to a shared Yjs fragment, so the editor works on the replica directly — typing stays local-fast while sync happens underneath. Note editable: canWrite: the client treats every connection as read-only until the server confirms write access, so a viewer's editor can never even attempt a write.
Show multiplayer cursors and presence
Multiplayer cursors in a DeepSpace app come from Yjs awareness: a lightweight, ephemeral channel that shares each connected user's caret position, selection, name, and color. Awareness updates are relayed to other clients but never persisted — when someone disconnects, their cursor disappears.
DeepSpace Docs renders remote carets with a Tiptap collaboration-caret extension: each remote user gets a colored caret with a name label, and their text selection is highlighted in their color. Because awareness rides the same WebSocket as document sync, cursors and edits can never drift out of order.
Presence — who is in the document, and whether they are typing — is deliberately separate. DeepSpace Docs uses the SDK's presence room for the avatar row and "Sam is typing…" indicators, keeping caret rendering and presence UI from interfering with each other. The presence room tracks joins and leaves per document, so the avatar stack is always current.
Share documents with invites
Sharing by user id only works for people who already have an account. DeepSpace Docs also supports inviting by email: a pending invite is stored as a record, and a server action resolves it when the invitee first signs in — appending their new user id to the document's collaborators (and editors for editor invites), then deleting the invite.
The invite record's invitedBy column is userBound and immutable, and the claim action only honors invites created by the document's owner — so a forged invite row cannot grant anyone access. This is the kind of authorization detail the SDK's schema primitives make declarative instead of hand-rolled.
Hand the build to your coding agent
DeepSpace is designed so a coding agent can build this app end to end: the scaffold installs an agent skill, the docs feature drops in the whole editor, and the CLI is scriptable with --json output. Copy the prompt below into Claude Code, Cursor, or any coding agent to build your own version.
Build a collaborative document editor on DeepSpace (docs at https://docs.deep.space).
Setup: run `npx deepspace whoami` to check login, then `npm create deepspace@latest my-docs`. The scaffold installs the DeepSpace agent skill — read it before writing code. Run `npx deepspace add --list` first: the `docs` feature installs a complete Tiptap + Yjs collaborative editor with live cursors, a document library, and invite-based sharing — start from it instead of hand-building.
Data model: a `documents` collection holds metadata only (title, ownerId, collaborators, editors, folderId) with collaborator-based read permissions. Document content lives in one Yjs room per document, keyed by the document's recordId.
Editor: Tiptap with the Collaboration extension bound to the Yjs fragment, plus collaboration carets driven by Yjs awareness so every writer sees everyone else's cursor and selection. Presence avatars and typing indicators come from a separate presence room.
Sharing: invite by email. Store pending invites in an `invites` collection and resolve them to user ids with a server action when the invitee first signs in.
Verify multi-user editing with `npx deepspace test` (two-browser spec), then `npx deepspace deploy`.The agent scaffolds the project, installs the editor feature, wires schemas and sharing, verifies multi-user editing with a two-browser test, and deploys to a live .app.space URL.
Go deeper
The DeepSpace documentation covers the APIs behind this guide: Yjs rooms, presence, collection schemas, and deployment. The complete DeepSpace Docs source is on GitHub, and the comparison pages cover how this stack relates to Supabase, Firebase, and Convex.
Frequently asked questions
How do multiplayer cursors work in a DeepSpace app?
Cursors are driven by Yjs awareness — an ephemeral channel that shares each user’s caret position, selection, name, and color over the same WebSocket as document edits. The server relays awareness to other clients but never stores it, so cursors vanish on disconnect.
Where is the document content stored?
Each document has its own server-side Yjs room in a Cloudflare Durable Object, which applies incoming updates, persists the document state in SQLite, and rebroadcasts changes to connected clients. Document metadata — title, owner, collaborators — lives separately in a records collection.
Can two people type in the same paragraph at once?
Yes. Yjs is a CRDT (conflict-free replicated data type), so concurrent edits — even inside the same word — merge deterministically on every replica without locks or a "last write wins" race. Each writer keeps typing at local speed while sync happens underneath.
Is DeepSpace Docs open source?
Yes. The full source is public at github.com/deepdotspace/deepspace-docs, and the deployed app runs at docs.app.space. The guide above describes the code as it exists in that repository.