Skip to main content
RapidDev - Software Development Agency

How to Build a Document Collaboration with Lovable

Build a real-time collaborative document editor in Lovable with Supabase Realtime for live co-editing, Presence for cursor tracking, block-level section locking to prevent conflicts, and document_members for access control. Users see each other's cursors as colored badges, and locked sections show who is editing them in real time.

What you'll build

  • Documents stored as ordered JSON block arrays (paragraph, heading, code, image) for structured editing
  • document_members table controlling per-user access with roles: owner, editor, viewer
  • Supabase Realtime Broadcast for broadcasting block-level edits to all active collaborators
  • Presence subscription tracking each user's active block and cursor position with colored badge display
  • Block-level locking: a user editing a block acquires a soft lock via Presence, preventing overwrite conflicts
  • Last-write-wins conflict resolution with a version counter per block for basic optimistic concurrency
  • Sharing Dialog to invite collaborators by email with role selection and an accept-invitation flow
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced18 min read4–5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a real-time collaborative document editor in Lovable with Supabase Realtime for live co-editing, Presence for cursor tracking, block-level section locking to prevent conflicts, and document_members for access control. Users see each other's cursors as colored badges, and locked sections show who is editing them in real time.

What you're building

True operational transform (OT) collaborative editing — like Google Docs — requires a complex server-side algorithm that merges character-level operations. Building that from scratch is a multi-month engineering project. This guide takes a practical approach that works well for most real-world use cases: block-level editing with soft locking via Presence.

Documents are stored as a blocks JSONB array: [{id, type, content, version}]. Each block is an independently editable unit. When a user clicks into a block, their Presence payload updates with {user_id, block_id, color}. All other collaborators see this as a colored cursor badge on that block and treat it as soft-locked. If two users do edit the same block simultaneously, last-write-wins using the block's version counter.

Changes are broadcast with low latency using Supabase Realtime Broadcast (not postgres_changes). When a user finishes editing a block (on blur), they save to Supabase with an optimistic concurrency check: UPDATE documents SET blocks = new_blocks WHERE id = doc_id AND blocks->>[block_index]->>'version' = expected_version. If this fails, it means another user saved first — fetch the latest version and merge.

Presence tracks who is in the document and which block they are editing. The colored cursor badges render next to the block's input. Color assignment is deterministic based on the user's ID modulo a palette array, so the same user always gets the same color within a document session.

Final result

A collaborative document editor with block-level editing, live cursor tracking, soft locking, conflict resolution, and access control.

Tech stack

LovableFrontend + Edge Functions
SupabaseDatabase + Realtime Broadcast + Presence + Auth
shadcn/uiDialog, Command, Badge, Avatar, Tooltip
React DnD KitBlock reordering via drag and drop

Prerequisites

  • Lovable Pro account with Supabase Realtime access
  • Supabase project with URL and service role key in Cloud tab → Secrets
  • Supabase Auth enabled with multiple test accounts for co-editing testing
  • Understanding of JSONB in PostgreSQL — document blocks are stored as JSONB arrays
  • Two browser tabs or two browsers for testing real-time collaboration features

Build steps

1

Create the document schema with block storage

Design the documents table with a JSONB blocks column for structured content, plus the document_members access control table. The schema supports versioning at the document level for conflict detection.

prompt.txt
1Create a document collaboration schema in Supabase. Tables:
2
31. documents: id, title (text), blocks (jsonb default '[]'), version (int default 0), owner_id (references auth.users), last_edited_by (references auth.users nullable), last_edited_at (timestamptz), created_at
4
5Block structure (stored in blocks array):
6{ id: string (nanoid), type: 'paragraph' | 'heading1' | 'heading2' | 'code' | 'image' | 'divider', content: string, metadata: object, version: number }
7
82. document_members: id, document_id (references documents), user_id (references auth.users), role (text: owner|editor|viewer), invited_by (references auth.users nullable), joined_at, UNIQUE(document_id, user_id)
9
103. document_invitations: id, document_id, email (text), role (text), token (uuid default gen_random_uuid()), invited_by, status (text: pending|accepted|expired), created_at, expires_at (default now() + interval '7 days')
11
12RLS:
13- documents: SELECT for owners and document_members; UPDATE for owners and editors (role in owner, editor)
14- document_members: SELECT for members of same document; INSERT/DELETE for owners
15- document_invitations: SELECT by email = auth.jwt()->>'email' or document owner
16
17Trigger: on UPDATE to documents, set last_edited_by = auth.uid(), last_edited_at = now()
18
19Index: CREATE INDEX ON document_members(user_id, document_id);

Pro tip: Add a document_snapshots table (document_id, blocks jsonb, version int, saved_at) and a trigger that inserts a snapshot every 10 versions. This gives you a document history timeline without storing every keystroke, useful for a 'Restore version' feature.

Expected result: Documents and document_members tables are created with RLS. Blocks store as JSONB. TypeScript types are generated. The app preview loads without errors.

2

Build the Broadcast and Presence real-time layer

Set up the Realtime channel for the document editor. One channel handles two things: Broadcast for block edit events (low latency, no database writes), and Presence for tracking which users are active and on which blocks.

src/hooks/useDocumentRealtime.ts
1// src/hooks/useDocumentRealtime.ts
2import { useEffect, useRef, useState, useCallback } from 'react'
3import { supabase } from '@/lib/supabase'
4import { useAuth } from '@/hooks/useAuth'
5
6const CURSOR_COLORS = ['#e63946', '#2a9d8f', '#e9c46a', '#457b9d', '#a8dadc', '#f4a261']
7
8type CollaboratorState = { user_id: string; email: string; block_id: string | null; color: string }
9type BlockEditEvent = { block_id: string; content: string; version: number; user_id: string }
10
11export function useDocumentRealtime(documentId: string | null) {
12 const { user } = useAuth()
13 const [collaborators, setCollaborators] = useState<CollaboratorState[]>([])
14 const [incomingEdit, setIncomingEdit] = useState<BlockEditEvent | null>(null)
15 const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null)
16
17 useEffect(() => {
18 if (!documentId || !user?.id) return
19
20 const color = CURSOR_COLORS[parseInt(user.id.replace(/-/g, '').slice(0, 8), 16) % CURSOR_COLORS.length]
21
22 const channel = supabase.channel(`doc:${documentId}`, { config: { presence: { key: user.id } } })
23 channelRef.current = channel
24
25 channel
26 .on('presence', { event: 'sync' }, () => {
27 type P = { user_id: string; email: string; block_id: string | null; color: string }
28 const state = channel.presenceState<P>()
29 const list = Object.values(state).flat().filter((c) => c.user_id !== user.id)
30 setCollaborators(list)
31 })
32 .on('broadcast', { event: 'block_edit' }, (msg) => {
33 const payload = msg.payload as BlockEditEvent
34 if (payload.user_id !== user.id) setIncomingEdit(payload)
35 })
36 .subscribe(async (status) => {
37 if (status === 'SUBSCRIBED') {
38 await channel.track({ user_id: user.id, email: user.email ?? '', block_id: null, color })
39 }
40 })
41
42 return () => { supabase.removeChannel(channel) }
43 }, [documentId, user?.id])
44
45 const trackBlock = useCallback(async (blockId: string | null) => {
46 if (!channelRef.current || !user?.id) return
47 const color = CURSOR_COLORS[parseInt(user.id.replace(/-/g, '').slice(0, 8), 16) % CURSOR_COLORS.length]
48 await channelRef.current.track({ user_id: user.id, email: user.email ?? '', block_id: blockId, color })
49 }, [user?.id])
50
51 const broadcastEdit = useCallback((event: BlockEditEvent) => {
52 channelRef.current?.send({ type: 'broadcast', event: 'block_edit', payload: event })
53 }, [])
54
55 return { collaborators, incomingEdit, trackBlock, broadcastEdit }
56}

Pro tip: Pass { config: { presence: { key: user.id } } } as the second argument to supabase.channel(). Without this, Supabase generates a random key per Presence track, causing duplicate collaborator entries when the component re-renders in React StrictMode.

Expected result: Opening the document in two browser tabs shows both users' colored badges in the collaborators list. Clicking into a block in one tab updates the cursor position indicator visible in the other tab.

3

Build the block-based document editor

Build the main document editor that renders each block as an editable element. Clicking a block tracks the cursor via Presence. Editing a block broadcasts the change instantly and saves to Supabase on blur.

prompt.txt
1Build the document editor at src/components/DocumentEditor.tsx.
2
3Requirements:
4- Fetch document from Supabase on load, parse blocks from the JSONB column
5- Render each block based on its type:
6 - paragraph: a ContentEditable div or Textarea
7 - heading1: a large ContentEditable div (text-3xl font-bold)
8 - heading2: a medium ContentEditable div (text-xl font-semibold)
9 - code: a Textarea with font-mono bg-muted
10 - divider: an hr element (not editable)
11- Each block wrapper shows collaborator badges (Avatar with initials, colored border) for users whose block_id matches this block's id
12- Clicking a block: call trackBlock(block.id) to update Presence
13- On input change: call broadcastEdit({ block_id, content: newContent, version: block.version, user_id }) immediately (no debounce for feel)
14- On blur: save the updated block to Supabase using UPDATE documents SET blocks = new_blocks_array, version = version + 1 WHERE id = documentId
15- When incomingEdit arrives from useDocumentRealtime: update that block's content in local state only (do not save to DB the other user will save on their blur)
16- Block is soft-locked if any collaborator.block_id === block.id. Show a subtle colored outline and a 'Editing' label. Still allow editing but warn on conflict.
17- Add a '+' button between blocks that opens a small menu (Command) to insert a new block of any type
18- Support drag-to-reorder blocks using dnd-kit (sortable). On reorder, save the new blocks array to Supabase.

Pro tip: Use a local blocks state that is separate from the Supabase-persisted state. Apply incoming Broadcast edits only to local state. On blur, persist to Supabase with a version check. This pattern is called Controlled Sync — real-time feel without write storms during active typing.

Expected result: The document editor renders all blocks. Typing in one tab broadcasts updates visible in the other tab instantly. Clicking away saves the block to Supabase. Collaborator badges show on the block being edited.

4

Add the sharing dialog and document_members management

Build the sharing dialog that lets document owners invite collaborators by email. Invitation emails are sent via an Edge Function. The invite flow adds users to document_members and grants them the selected access level.

prompt.txt
1Build a document sharing system.
2
3Requirements:
4
51. Add a 'Share' Button in the document header. Clicking opens a shadcn/ui Dialog.
6
72. Sharing Dialog:
8 - Show current members as a list: Avatar, email, role Badge (owner/editor/viewer), and 'Remove' Button for non-owners
9 - Invite input: email text Input + role Select (Editor/Viewer) + 'Send Invite' Button
10 - On invite: INSERT into document_invitations, then invoke 'send-doc-invite' Edge Function
11 - Copy link Button: copies a shareable URL https://yourapp.com/doc/{documentId} to clipboard
12
133. Edge Function supabase/functions/send-doc-invite/index.ts:
14 - Accept POST: { invitation_id: string }
15 - Fetch invitation, document title, and inviter's email from Supabase
16 - Send email via Resend: subject 'You've been invited to edit {document_title}', body with accept link https://yourapp.com/join-doc?token={invitation.token}
17
184. Accept invitation page at src/pages/JoinDocument.tsx:
19 - Read token from URL query params
20 - If user is logged in: look up invitation by token, INSERT into document_members with the invitation's role, set invitation status='accepted', redirect to the document
21 - If user is not logged in: redirect to signup/login with the token preserved in the URL, then complete on return

Pro tip: Check if the invited email already has a Supabase Auth account before sending the invite email. If they do, add them to document_members directly without requiring the invitation flow. Use the service role key in the Edge Function to query auth.users by email.

Expected result: Clicking Share opens the dialog. Sending an invite dispatches the email via Resend. Clicking the link in the email adds the user to document_members and opens the document.

5

Implement conflict resolution for simultaneous block edits

Add the conflict detection and resolution flow. When two users edit the same block and both try to save, the version check catches the conflict and shows a merge dialog instead of silently overwriting.

prompt.txt
1Add block-level conflict resolution to the document editor.
2
3Requirements:
4
51. When saving a block on blur, include a version check in the UPDATE:
6 UPDATE documents
7 SET blocks = new_blocks_array, version = version + 1
8 WHERE id = documentId AND (blocks->block_index->>'version')::int = expected_version
9 Use supabase.from('documents').update() and check if data is null (update matched 0 rows = conflict)
10
112. If the update returns 0 rows (conflict detected):
12 a. Fetch the latest document from Supabase
13 b. Find the conflicting block in the latest version
14 c. Open a ConflictDialog showing two columns: 'Your version' (left) and 'Current version from {editor name}' (right)
15 d. Two Buttons: 'Keep mine' (overwrites current with user's content, increments version) and 'Keep theirs' (discards user's edit, updates local state)
16 e. No 'Merge' option for text blocks, ask the user to manually merge the content if needed
17
183. Add a version indicator in the document header: 'Version {version}' that increments when any save occurs. Clicking opens a simple version list from document_snapshots if that table exists.
19
204. Visual feedback: show a subtle 'Saving...' 'Saved' badge in the header using a state variable that tracks pending saves. On conflict, show 'Conflict — review needed' in amber.

Pro tip: Add a soft lock visualization before conflict resolution kicks in: if a collaborator badge is on the block you are about to save, show a warning tooltip 'Another editor is here'. This pre-empts most conflicts before they happen, since users will naturally wait for the other person to finish editing.

Expected result: If two users edit the same block simultaneously and both save, the second save detects the version mismatch and opens the ConflictDialog. Choosing a resolution saves the selected version and updates the version counter.

Complete code

src/hooks/useDocumentRealtime.ts
1import { useEffect, useRef, useState, useCallback } from 'react'
2import { supabase } from '@/lib/supabase'
3import { useAuth } from '@/hooks/useAuth'
4
5const CURSOR_COLORS = ['#e63946', '#2a9d8f', '#e9c46a', '#457b9d', '#f4a261', '#a8dadc']
6
7export type CollaboratorState = { user_id: string; email: string; block_id: string | null; color: string }
8export type BlockEditEvent = { block_id: string; content: string; version: number; user_id: string }
9
10const getUserColor = (userId: string) =>
11 CURSOR_COLORS[parseInt(userId.replace(/-/g, '').slice(0, 8), 16) % CURSOR_COLORS.length]
12
13export function useDocumentRealtime(documentId: string | null) {
14 const { user } = useAuth()
15 const [collaborators, setCollaborators] = useState<CollaboratorState[]>([])
16 const [incomingEdit, setIncomingEdit] = useState<BlockEditEvent | null>(null)
17 const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null)
18
19 useEffect(() => {
20 if (!documentId || !user?.id) return
21 const color = getUserColor(user.id)
22 const channel = supabase.channel(`doc:${documentId}`, { config: { presence: { key: user.id } } })
23 channelRef.current = channel
24
25 channel
26 .on('presence', { event: 'sync' }, () => {
27 const state = channel.presenceState<CollaboratorState>()
28 setCollaborators(Object.values(state).flat().filter(c => c.user_id !== user.id))
29 })
30 .on('broadcast', { event: 'block_edit' }, msg => {
31 const payload = msg.payload as BlockEditEvent
32 if (payload.user_id !== user.id) setIncomingEdit(payload)
33 })
34 .subscribe(async status => {
35 if (status === 'SUBSCRIBED')
36 await channel.track({ user_id: user.id, email: user.email ?? '', block_id: null, color })
37 })
38
39 return () => { supabase.removeChannel(channel); channelRef.current = null }
40 }, [documentId, user?.id])
41
42 const trackBlock = useCallback(async (blockId: string | null) => {
43 if (!channelRef.current || !user?.id) return
44 await channelRef.current.track({
45 user_id: user.id, email: user.email ?? '', block_id: blockId, color: getUserColor(user.id),
46 })
47 }, [user?.id])
48
49 const broadcastEdit = useCallback((event: BlockEditEvent) => {
50 channelRef.current?.send({ type: 'broadcast', event: 'block_edit', payload: event })
51 }, [])
52
53 const getBlockCollaborators = useCallback(
54 (blockId: string) => collaborators.filter(c => c.block_id === blockId),
55 [collaborators]
56 )
57
58 return { collaborators, incomingEdit, trackBlock, broadcastEdit, getBlockCollaborators }
59}

Customization ideas

Document version history

Populate document_snapshots every 10 version increments via a Postgres trigger. Build a version history sidebar showing snapshot timestamps and the user who triggered each save. Clicking a snapshot shows a read-only preview in a Sheet. Add a 'Restore this version' Button that copies the snapshot's blocks array back to the current document.

Comments and suggestions on blocks

Add a comments table (document_id, block_id, user_id, body, resolved, created_at). Show a comment icon Badge on blocks with open comments. Clicking opens a Comment thread Sheet. Add a 'Suggestion mode' toggle that creates a suggestion row instead of directly editing the block, allowing the document owner to accept or reject changes.

Export to PDF or Markdown

Add an Export Button that sends the blocks array to an Edge Function. The function converts blocks to Markdown (heading1 → #, paragraph → plain text, code → code fences) or generates a PDF using a headless browser approach. Return the generated file URL from Supabase Storage for download.

Document templates

Create a templates table with pre-filled blocks arrays for common document types: meeting notes, product spec, project brief. When creating a new document, show a template picker Dialog. Selecting a template pre-populates the blocks column. Templates are read-only; new documents get their own mutable copy.

Inline image uploads

Support image-type blocks that store a file_url pointing to Supabase Storage. In the block toolbar, add an image upload Button. On file selection, upload to the documents storage bucket and create an image block with the public URL. Render image blocks as full-width img tags with resize handles for width adjustment.

Common pitfalls

Pitfall: Using postgres_changes instead of Broadcast for live typing updates

How to avoid: Use Broadcast for real-time typing previews (zero database writes). Save to the database only on blur (when the user clicks away from a block). This is the same pattern used by Google Docs — stream locally, persist periodically.

Pitfall: Not passing the presence key to supabase.channel()

How to avoid: Always pass the user ID as the presence key. This makes each user's Presence entry idempotent — re-subscribing with the same key replaces the existing entry instead of creating a new one.

Pitfall: Applying incoming Broadcast edits directly to the Supabase-persisted state

How to avoid: Apply Broadcast events only to local React state. The authoritative save to Supabase happens only on blur from the editing user. Other users see the changes in real time via Broadcast, but only the author's client writes to the database.

Pitfall: Storing the full document as a single text blob instead of a blocks array

How to avoid: Store documents as a JSONB blocks array where each block has a unique ID, type, content, and version counter. This enables per-block locking, per-block version checks, and efficient partial updates.

Best practices

  • Use Broadcast for live typing previews and Presence for cursor tracking. Both are zero-database-write operations, keeping your Supabase write budget for actual content saves.
  • Pass { config: { presence: { key: user.id } } } to supabase.channel() to make Presence idempotent across React re-mounts and StrictMode double-invocations.
  • Implement a Controlled Sync pattern: maintain local blocks state for immediate editing feedback, and sync to Supabase on blur with a version check. Never write on every keystroke.
  • Assign deterministic cursor colors based on user ID hash modulo the color palette. This prevents color flickering on reconnection and gives each user a consistent identity in the document.
  • Add a version counter to each block and use it for optimistic concurrency checks on save. A failed version check means a conflict — show a resolution dialog rather than silently overwriting.
  • Index document_members(user_id) and document_members(document_id) separately. These two access patterns are both common and need dedicated indexes.
  • Store document snapshots periodically (every 10 versions via a trigger) rather than storing every edit. Snapshots give you a usable version history without the storage cost of event sourcing.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a collaborative document editor using Supabase Realtime. My documents are stored as JSONB block arrays. When two users edit different blocks simultaneously, everything works fine. When they edit the same block and both save on blur, I detect the conflict via a version counter mismatch. Help me design the conflict resolution UI. Should I show a side-by-side diff of the two text versions? What library can I use to generate a text diff in a React component? Show me a minimal ConflictDialog component that displays the diff and lets the user pick a version.

Lovable Prompt

Add a floating document toolbar that appears when the user selects text inside a paragraph block. The toolbar shows formatting Buttons: Bold, Italic, Link, and block type conversion (convert paragraph to heading1 or heading2). Use the browser's Selection API to detect text selection and position the toolbar above the selection. Applying a format wraps the selected text in a markdown-style marker and calls broadcastEdit with the updated content.

Build Prompt

In Supabase, create a trigger that fires on UPDATE to documents when version increments by a multiple of 10. The trigger inserts a row into document_snapshots: document_id, blocks (copy of the current blocks column), version (copy of current version), saved_by (auth.uid()), saved_at (now()). Use a condition: IF (NEW.version % 10 = 0) THEN INSERT. This creates automatic version history without any application code changes.

Frequently asked questions

Is this approach as powerful as Google Docs real-time editing?

No — this uses block-level soft locking rather than true operational transform (OT) or CRDTs. Two users can edit the same block simultaneously and the last save wins. For most document collaboration use cases (async editing with occasional overlap), this is sufficient and much simpler to build. For true character-level concurrent editing, integrate a dedicated library like Yjs or Automerge, which can be stored in a Supabase JSONB column.

How do I handle the case where a user closes the tab mid-edit without blurring the block?

Add a beforeunload event listener that calls the save function if there is unsaved local state. The browser gives a brief window to run synchronous code on beforeunload. For a more robust approach, use the Beacon API: navigator.sendBeacon('/api/save', JSON.stringify(blocks)) which fires a non-blocking POST request even on tab close. Create a Route Handler for this endpoint that saves to Supabase.

Can I add rich text formatting (bold, italic, links) to paragraph blocks?

Yes. Store block content as markdown-flavored text and render it with a lightweight markdown-to-HTML renderer like marked or micromark. For the editing experience, parse the selection and wrap selected text in markdown markers on toolbar button clicks. Alternatively, store content as Prosemirror JSON and integrate Tiptap as the editor — Tiptap has a Supabase collaboration extension that handles conflict resolution.

How does Presence handle users with multiple open tabs on the same document?

Each browser tab creates a separate Supabase Realtime connection. If you pass the user ID as the presence key, the second tab replaces the first tab's Presence entry — the user appears only once in the collaborators list. Without the presence key configuration, each tab gets a random key and the user appears multiple times. Always configure { config: { presence: { key: user.id } } } on the channel.

How do I restrict document access so only invited members can view it?

The RLS policy on documents restricts SELECT to rows where auth.uid() has a matching row in document_members. A user who is not a member gets an empty result, even if they know the document ID. The document sharing Dialog is the only way to add members. For public sharing (anyone with the link can view), add an is_public boolean to documents and extend the RLS SELECT policy: is_public = true OR EXISTS (SELECT 1 FROM document_members WHERE ...).

What happens to the Broadcast stream when a user reconnects after losing internet?

Broadcast events are not replayed on reconnection — they are ephemeral. When the user reconnects, their Supabase client re-subscribes to the channel. Any block edits that happened during the outage are not received. To handle this, fetch the full document from Supabase on reconnection (listen for the channel status changing back to 'SUBSCRIBED') to get the latest saved state. The user may have missed intermediate Broadcast events but will have the latest persisted version.

Can I build this so the document URL is shareable and works for unauthenticated viewers?

Yes. Add an is_public column to documents. Update the RLS SELECT policy: (is_public = true) OR (auth.uid() IN (SELECT user_id FROM document_members WHERE document_id = id)). Public documents are readable without auth. For unauthenticated viewers, they cannot edit (the UPDATE policy still requires membership). The editor renders in view-only mode when no auth session is present.

Can RapidDev help integrate Yjs for true character-level concurrent editing?

Yes. RapidDev builds advanced Lovable apps including document editors with Yjs-based CRDT collaboration, awareness extensions, and Supabase persistence providers. Reach out if you need real-time character-level editing beyond what block-level locking provides.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.