# How to Build a Document Collaboration with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
Create a document collaboration schema in Supabase. Tables:

1. 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

Block structure (stored in blocks array):
{ id: string (nanoid), type: 'paragraph' | 'heading1' | 'heading2' | 'code' | 'image' | 'divider', content: string, metadata: object, version: number }

2. 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)

3. 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')

RLS:
- documents: SELECT for owners and document_members; UPDATE for owners and editors (role in owner, editor)
- document_members: SELECT for members of same document; INSERT/DELETE for owners
- document_invitations: SELECT by email = auth.jwt()->>'email' or document owner

Trigger: on UPDATE to documents, set last_edited_by = auth.uid(), last_edited_at = now()

Index: 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
import { useEffect, useRef, useState, useCallback } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

const CURSOR_COLORS = ['#e63946', '#2a9d8f', '#e9c46a', '#457b9d', '#a8dadc', '#f4a261']

type CollaboratorState = { user_id: string; email: string; block_id: string | null; color: string }
type BlockEditEvent = { block_id: string; content: string; version: number; user_id: string }

export function useDocumentRealtime(documentId: string | null) {
  const { user } = useAuth()
  const [collaborators, setCollaborators] = useState<CollaboratorState[]>([])
  const [incomingEdit, setIncomingEdit] = useState<BlockEditEvent | null>(null)
  const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null)

  useEffect(() => {
    if (!documentId || !user?.id) return

    const color = CURSOR_COLORS[parseInt(user.id.replace(/-/g, '').slice(0, 8), 16) % CURSOR_COLORS.length]

    const channel = supabase.channel(`doc:${documentId}`, { config: { presence: { key: user.id } } })
    channelRef.current = channel

    channel
      .on('presence', { event: 'sync' }, () => {
        type P = { user_id: string; email: string; block_id: string | null; color: string }
        const state = channel.presenceState<P>()
        const list = Object.values(state).flat().filter((c) => c.user_id !== user.id)
        setCollaborators(list)
      })
      .on('broadcast', { event: 'block_edit' }, (msg) => {
        const payload = msg.payload as BlockEditEvent
        if (payload.user_id !== user.id) setIncomingEdit(payload)
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          await channel.track({ user_id: user.id, email: user.email ?? '', block_id: null, color })
        }
      })

    return () => { supabase.removeChannel(channel) }
  }, [documentId, user?.id])

  const trackBlock = useCallback(async (blockId: string | null) => {
    if (!channelRef.current || !user?.id) return
    const color = CURSOR_COLORS[parseInt(user.id.replace(/-/g, '').slice(0, 8), 16) % CURSOR_COLORS.length]
    await channelRef.current.track({ user_id: user.id, email: user.email ?? '', block_id: blockId, color })
  }, [user?.id])

  const broadcastEdit = useCallback((event: BlockEditEvent) => {
    channelRef.current?.send({ type: 'broadcast', event: 'block_edit', payload: event })
  }, [])

  return { collaborators, incomingEdit, trackBlock, broadcastEdit }
}
```

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

```
Build the document editor at src/components/DocumentEditor.tsx.

Requirements:
- Fetch document from Supabase on load, parse blocks from the JSONB column
- Render each block based on its type:
  - paragraph: a ContentEditable div or Textarea
  - heading1: a large ContentEditable div (text-3xl font-bold)
  - heading2: a medium ContentEditable div (text-xl font-semibold)
  - code: a Textarea with font-mono bg-muted
  - divider: an hr element (not editable)
- Each block wrapper shows collaborator badges (Avatar with initials, colored border) for users whose block_id matches this block's id
- Clicking a block: call trackBlock(block.id) to update Presence
- On input change: call broadcastEdit({ block_id, content: newContent, version: block.version, user_id }) immediately (no debounce — for feel)
- On blur: save the updated block to Supabase using UPDATE documents SET blocks = new_blocks_array, version = version + 1 WHERE id = documentId
- 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)
- 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.
- Add a '+' button between blocks that opens a small menu (Command) to insert a new block of any type
- 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.

```
Build a document sharing system.

Requirements:

1. Add a 'Share' Button in the document header. Clicking opens a shadcn/ui Dialog.

2. Sharing Dialog:
   - Show current members as a list: Avatar, email, role Badge (owner/editor/viewer), and 'Remove' Button for non-owners
   - Invite input: email text Input + role Select (Editor/Viewer) + 'Send Invite' Button
   - On invite: INSERT into document_invitations, then invoke 'send-doc-invite' Edge Function
   - Copy link Button: copies a shareable URL https://yourapp.com/doc/{documentId} to clipboard

3. Edge Function supabase/functions/send-doc-invite/index.ts:
   - Accept POST: { invitation_id: string }
   - Fetch invitation, document title, and inviter's email from Supabase
   - 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}

4. Accept invitation page at src/pages/JoinDocument.tsx:
   - Read token from URL query params
   - 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
   - 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.

```
Add block-level conflict resolution to the document editor.

Requirements:

1. When saving a block on blur, include a version check in the UPDATE:
   UPDATE documents
   SET blocks = new_blocks_array, version = version + 1
   WHERE id = documentId AND (blocks->block_index->>'version')::int = expected_version
   Use supabase.from('documents').update() and check if data is null (update matched 0 rows = conflict)

2. If the update returns 0 rows (conflict detected):
   a. Fetch the latest document from Supabase
   b. Find the conflicting block in the latest version
   c. Open a ConflictDialog showing two columns: 'Your version' (left) and 'Current version from {editor name}' (right)
   d. Two Buttons: 'Keep mine' (overwrites current with user's content, increments version) and 'Keep theirs' (discards user's edit, updates local state)
   e. No 'Merge' option — for text blocks, ask the user to manually merge the content if needed

3. 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.

4. 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 example

File: `src/hooks/useDocumentRealtime.ts`

```typescript
import { useEffect, useRef, useState, useCallback } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

const CURSOR_COLORS = ['#e63946', '#2a9d8f', '#e9c46a', '#457b9d', '#f4a261', '#a8dadc']

export type CollaboratorState = { user_id: string; email: string; block_id: string | null; color: string }
export type BlockEditEvent = { block_id: string; content: string; version: number; user_id: string }

const getUserColor = (userId: string) =>
  CURSOR_COLORS[parseInt(userId.replace(/-/g, '').slice(0, 8), 16) % CURSOR_COLORS.length]

export function useDocumentRealtime(documentId: string | null) {
  const { user } = useAuth()
  const [collaborators, setCollaborators] = useState<CollaboratorState[]>([])
  const [incomingEdit, setIncomingEdit] = useState<BlockEditEvent | null>(null)
  const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null)

  useEffect(() => {
    if (!documentId || !user?.id) return
    const color = getUserColor(user.id)
    const channel = supabase.channel(`doc:${documentId}`, { config: { presence: { key: user.id } } })
    channelRef.current = channel

    channel
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState<CollaboratorState>()
        setCollaborators(Object.values(state).flat().filter(c => c.user_id !== user.id))
      })
      .on('broadcast', { event: 'block_edit' }, msg => {
        const payload = msg.payload as BlockEditEvent
        if (payload.user_id !== user.id) setIncomingEdit(payload)
      })
      .subscribe(async status => {
        if (status === 'SUBSCRIBED')
          await channel.track({ user_id: user.id, email: user.email ?? '', block_id: null, color })
      })

    return () => { supabase.removeChannel(channel); channelRef.current = null }
  }, [documentId, user?.id])

  const trackBlock = useCallback(async (blockId: string | null) => {
    if (!channelRef.current || !user?.id) return
    await channelRef.current.track({
      user_id: user.id, email: user.email ?? '', block_id: blockId, color: getUserColor(user.id),
    })
  }, [user?.id])

  const broadcastEdit = useCallback((event: BlockEditEvent) => {
    channelRef.current?.send({ type: 'broadcast', event: 'block_edit', payload: event })
  }, [])

  const getBlockCollaborators = useCallback(
    (blockId: string) => collaborators.filter(c => c.block_id === blockId),
    [collaborators]
  )

  return { collaborators, incomingEdit, trackBlock, broadcastEdit, getBlockCollaborators }
}
```

## Common mistakes

- **Using postgres_changes instead of Broadcast for live typing updates** — If you save every keystroke to the database and use postgres_changes to stream edits, you create a high-frequency write load (10+ writes per second per active user) that exhausts Supabase Free plan row limits and adds significant latency. Fix: 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.
- **Not passing the presence key to supabase.channel()** — Without { config: { presence: { key: user.id } } }, Supabase generates a random string as the Presence key each time the channel subscribes. React StrictMode's double-mount behavior causes two different keys for the same user, creating duplicate collaborator entries in the Presence state. Fix: 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.
- **Applying incoming Broadcast edits directly to the Supabase-persisted state** — If both users apply each other's Broadcast events to the database, you get write storms — each edit triggers a round-trip, which fires another event, which triggers another write. Fix: 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.
- **Storing the full document as a single text blob instead of a blocks array** — A single text column makes block-level locking impossible, makes version diffing expensive, and forces you to diff the entire document on every save to detect what changed. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/document-collaboration
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/document-collaboration
