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
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
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.
1Create a document collaboration schema in Supabase. Tables:231. 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_at45Block structure (stored in blocks array):6{ id: string (nanoid), type: 'paragraph' | 'heading1' | 'heading2' | 'code' | 'image' | 'divider', content: string, metadata: object, version: number }782. 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)9103. 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')1112RLS: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 owners15- document_invitations: SELECT by email = auth.jwt()->>'email' or document owner1617Trigger: on UPDATE to documents, set last_edited_by = auth.uid(), last_edited_at = now()1819Index: 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.
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.
1// src/hooks/useDocumentRealtime.ts2import { useEffect, useRef, useState, useCallback } from 'react'3import { supabase } from '@/lib/supabase'4import { useAuth } from '@/hooks/useAuth'56const CURSOR_COLORS = ['#e63946', '#2a9d8f', '#e9c46a', '#457b9d', '#a8dadc', '#f4a261']78type CollaboratorState = { user_id: string; email: string; block_id: string | null; color: string }9type BlockEditEvent = { block_id: string; content: string; version: number; user_id: string }1011export 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)1617 useEffect(() => {18 if (!documentId || !user?.id) return1920 const color = CURSOR_COLORS[parseInt(user.id.replace(/-/g, '').slice(0, 8), 16) % CURSOR_COLORS.length]2122 const channel = supabase.channel(`doc:${documentId}`, { config: { presence: { key: user.id } } })23 channelRef.current = channel2425 channel26 .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 BlockEditEvent34 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 })4142 return () => { supabase.removeChannel(channel) }43 }, [documentId, user?.id])4445 const trackBlock = useCallback(async (blockId: string | null) => {46 if (!channelRef.current || !user?.id) return47 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])5051 const broadcastEdit = useCallback((event: BlockEditEvent) => {52 channelRef.current?.send({ type: 'broadcast', event: 'block_edit', payload: event })53 }, [])5455 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.
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.
1Build the document editor at src/components/DocumentEditor.tsx.23Requirements:4- Fetch document from Supabase on load, parse blocks from the JSONB column5- Render each block based on its type:6 - paragraph: a ContentEditable div or Textarea7 - 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-muted10 - 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 id12- Clicking a block: call trackBlock(block.id) to update Presence13- 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 = documentId15- 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 type18- 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.
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.
1Build a document sharing system.23Requirements:451. Add a 'Share' Button in the document header. Clicking opens a shadcn/ui Dialog.672. Sharing Dialog:8 - Show current members as a list: Avatar, email, role Badge (owner/editor/viewer), and 'Remove' Button for non-owners9 - Invite input: email text Input + role Select (Editor/Viewer) + 'Send Invite' Button10 - On invite: INSERT into document_invitations, then invoke 'send-doc-invite' Edge Function11 - Copy link Button: copies a shareable URL https://yourapp.com/doc/{documentId} to clipboard12133. 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 Supabase16 - 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}17184. Accept invitation page at src/pages/JoinDocument.tsx:19 - Read token from URL query params20 - 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 document21 - If user is not logged in: redirect to signup/login with the token preserved in the URL, then complete on returnPro 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.
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.
1Add block-level conflict resolution to the document editor.23Requirements:451. When saving a block on blur, include a version check in the UPDATE:6 UPDATE documents7 SET blocks = new_blocks_array, version = version + 18 WHERE id = documentId AND (blocks->block_index->>'version')::int = expected_version9 Use supabase.from('documents').update() and check if data is null (update matched 0 rows = conflict)10112. If the update returns 0 rows (conflict detected):12 a. Fetch the latest document from Supabase13 b. Find the conflicting block in the latest version14 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 needed17183. 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.19204. 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
1import { useEffect, useRef, useState, useCallback } from 'react'2import { supabase } from '@/lib/supabase'3import { useAuth } from '@/hooks/useAuth'45const CURSOR_COLORS = ['#e63946', '#2a9d8f', '#e9c46a', '#457b9d', '#f4a261', '#a8dadc']67export 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 }910const getUserColor = (userId: string) =>11 CURSOR_COLORS[parseInt(userId.replace(/-/g, '').slice(0, 8), 16) % CURSOR_COLORS.length]1213export 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)1819 useEffect(() => {20 if (!documentId || !user?.id) return21 const color = getUserColor(user.id)22 const channel = supabase.channel(`doc:${documentId}`, { config: { presence: { key: user.id } } })23 channelRef.current = channel2425 channel26 .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 BlockEditEvent32 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 })3839 return () => { supabase.removeChannel(channel); channelRef.current = null }40 }, [documentId, user?.id])4142 const trackBlock = useCallback(async (blockId: string | null) => {43 if (!channelRef.current || !user?.id) return44 await channelRef.current.track({45 user_id: user.id, email: user.email ?? '', block_id: blockId, color: getUserColor(user.id),46 })47 }, [user?.id])4849 const broadcastEdit = useCallback((event: BlockEditEvent) => {50 channelRef.current?.send({ type: 'broadcast', event: 'block_edit', payload: event })51 }, [])5253 const getBlockCollaborators = useCallback(54 (blockId: string) => collaborators.filter(c => c.block_id === blockId),55 [collaborators]56 )5758 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation