Tiptap collaboration
Turn a single-user Tiptap editor into a shared one — the provider, the shared Y.Doc, remote cursors, the undo trap, and what the saved document actually looks like.
Tiptap has a first-class Yjs binding, which makes it the shortest path from a normal editor to a collaborative one: the extension writes into the shared document, and every keystroke becomes a CRDT operation that syncs and converges. There is no save button and no conflict dialog to build.
The client package already ships yjs and y-protocols. What is missing is Tiptap's side:
pnpm add @tiptap/extension-collaboration @tiptap/extension-collaboration-caret y-prosemirrorOn Tiptap 2 the caret extension was called @tiptap/extension-collaboration-cursor and exported
CollaborationCursor. Tiptap 3 renamed it; the rest of this page assumes 3.
Above the editor — in the layout, not the page, so navigating away and back reuses the session:
import { CollaborationProvider } from '@adonis-agora/collaboration-client'
export default function WritingPage({ research }: { research: { id: string } }) {
return (
<CollaborationProvider baseUrl={window.location.origin}>
<WritingEditor researchId={research.id} />
</CollaborationProvider>
)
}import { useAwareness, useCollabDoc } from '@adonis-agora/collaboration-client'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCaret from '@tiptap/extension-collaboration-caret'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export function WritingEditor({
researchId,
currentUser,
}: {
researchId: string
currentUser: { id: string; name: string; color: string }
}) {
const docName = `researches/${researchId}/writing`
const { doc, synced, error } = useCollabDoc({ docName })
const { peers, awareness } = useAwareness({
docName,
user: { userId: currentUser.id, name: currentUser.name },
})
const editor = useEditor({
extensions: [
StarterKit.configure({ undoRedo: false }), // ← the CRDT owns undo
Collaboration.configure({ document: doc }),
// CollaborationCaret reads `provider.awareness`, so the awareness
// object is all it needs — there is no provider to hand it.
CollaborationCaret.configure({
provider: { awareness },
user: { name: currentUser.name, color: currentUser.color },
}),
],
// The awareness object is rebuilt on reconnect, so re-create the editor
// when it changes rather than holding a stale channel.
}, [awareness])
if (error) return <p>Could not open this document: {error.message}</p>
if (!synced) return <p>Loading the document…</p>
return (
<>
<PeerAvatars peers={peers} />
<EditorContent editor={editor} />
</>
)
}The two lines that matter
undoRedo: false is not optional
ProseMirror's undo stack does not know which operations were yours. Leave it enabled and Ctrl+Z will
happily undo the paragraph your colleague just typed — a bug that only shows up with two people in
the room, which is to say only in production. undoRedo: false hands undo to the CRDT's own manager,
which tracks origin and undoes only your own edits.
The option was named history in Tiptap 2. On Tiptap 3 that key no longer exists, so
StarterKit.configure({ history: false }) silently leaves the undo extension on — the exact
collision it was written to prevent.
if (!synced) return … is the second one. useCollabDoc returns the Y.Doc immediately and
connects in the background, so the editor mounts against a document that is briefly empty. Typing
during that window produces local operations that merge with the server's state when it arrives —
the CRDT behaving exactly as specified, and rarely what the writer meant. synced is true once
the server's initial state has actually been applied, so gating the first paint on it means the
editor never renders an empty document that is about to fill in.
Do not gate `editable` on the connection
editable: status === 'connected' looks like the same fix and is not. 'connected' means the
socket is open, not that the state arrived — so it does not close the empty-document window — and
it introduces a worse failure: a WebSocket that drops for four seconds turns the editor read-only
mid-sentence with nothing on screen to explain why. Yjs is built for offline edits. Gate the
initial render on synced, leave editable alone after that, and show the connection state next
to the document title.
Why passing doc straight in is safe
The session caches one Y.Doc per document name for as long as the provider lives, so doc is the
same object on every render. Collaboration.configure({ document: doc }) therefore never receives a
new document mid-life, and you do not need to memoize it or key the editor on it.
Remote cursors
useAwareness returns two things, and the difference matters. peers is a snapshot — everyone
else in the room, re-read on every awareness change — which is what you render an avatar stack from.
awareness is the channel object itself, which is what an editor extension needs in order to
publish the local cursor and subscribe to remote ones.
CollaborationCaret takes a provider and reads provider.awareness off it, so { awareness } is
a complete provider as far as it is concerned. Both views come from the same channel, which is why
the avatar stack and the in-editor carets never disagree.
peers reads the user field of everyone else's awareness state, so somebody has to publish
yours. Pass user to useAwareness (as above) or call setLocalState yourself — without it the
avatar stack is a row of anonymous client ids, and it stays that way after a reconnect too, because
whatever you last published is what gets re-applied to the rebuilt channel.
The channel is rebuilt on every reconnect, so key the editor on it — otherwise a reconnected session leaves the extension publishing into an abandoned channel and remote carets quietly stop moving.
Give each user a stable colour. Deriving it from the user id rather than assigning it at random per session means the same person is the same colour in every tab and after every reconnect:
const color = `hsl(${hashToDegrees(currentUser.id)} 70% 50%)`What gets saved
The document is a standard Tiptap/Yjs structure: a Y.XmlFragment named default. Everything else
in the library reads it as such:
// Plain text — for search indexing, RAG, grammar checking, word counts
const text = await collaboration.current.getDocumentText({ docName })
// A named checkpoint of exactly this state
await collaboration.current.createVersion({ docName, createdBy: user.id, label: 'before review' })getDocumentText walks the Tiptap layout and returns the plain text, preferring the live document
when someone has it open. A document that is not in that layout returns an empty string rather than
throwing — worth checking before you feed the result to something that expects content.
Adding comments to the editor
Comments anchor to a text range, and a Tiptap selection is exactly that:
const { create } = useComments({ docName, space: 'text' })
function commentOnSelection(body: string) {
const { from, to } = editor.state.selection
return create({
space: 'text',
anchor: {
kind: 'text-range',
start: from,
end: to,
selectedText: editor.state.doc.textBetween(from, to),
},
body,
userId: currentUser.id,
})
}Offsets drift as other people type above the range, which is why selectedText is part of the
anchor — it is what lets the sidebar still show what a comment was about. See
Comments.
Custom controllers
Take over the HTTP surface without losing the library's token semantics — the three levels of control, a complete working controller, and the authorization you take over along with it.
Testing
Prove the parts that actually break — that authorize denies, that a version snapshot is the version's, that the client survives a remount, and that a storage failure is reported — using in-memory storage, fake transports and no server.