Agora
Advanced

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-prosemirror

On 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:

app/pages/researches/writing.tsx
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>
  )
}
app/components/writing_editor.tsx
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.

On this page