# How to Create a Real-Time Collaborative Document Editor in FlutterFlow

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 45-60 min
- Compatibility: FlutterFlow Pro+ (code export required)
- Last updated: March 2026

## TL;DR

Build a collaborative rich-text editor in FlutterFlow by using flutter_quill for formatting, splitting documents into a Firestore blocks subcollection so each block syncs independently, adding block-level locking to prevent write conflicts, and displaying presence indicators so users see who is editing in real time.

## Google Docs-Style Collaboration in Flutter

FlutterFlow's visual editor is excellent for layout and CRUD screens, but collaborative editing requires custom Dart code integrated into the project. The key insight is that storing an entire document as a single Firestore string field creates constant write conflicts the moment two users type simultaneously. Instead, this tutorial splits documents into a blocks subcollection — each paragraph or heading is its own document — so Firestore can merge concurrent writes at the block level. flutter_quill provides rich-text formatting (bold, italic, headings, lists) and serialises content as Delta JSON, which is safe to store and round-trip without data loss. A presence subcollection tracks which user is editing which block, powering the cursor indicators.

## Before you start

- FlutterFlow Pro plan with code export enabled
- Firebase project with Firestore enabled
- Basic familiarity with FlutterFlow Custom Actions
- Flutter/Dart SDK installed locally for testing exported code
- flutter_quill package added to pubspec.yaml (^9.0.0 or latest)

## Step-by-step guide

### 1. Design the Firestore data model for documents and blocks

Open Firestore in the Firebase console and create a documents collection. Each document contains: title (String), owner_uid (String), created_at (Timestamp), and updated_at (Timestamp). Under each document create a blocks subcollection where each block has: index (Integer for ordering), delta_json (String — serialised Quill Delta), locked_by (String, nullable — UID of active editor), locked_at (Timestamp, nullable). Also create a presence subcollection on each document with one entry per active user: uid (String), display_name (String), block_index (Integer), last_seen (Timestamp). In FlutterFlow, open Firestore and replicate this structure using the GUI. Having blocks as a subcollection means a Firestore listener on one block will not fire for changes in unrelated blocks, drastically reducing listener cost.

**Expected result:** Firestore console shows documents collection with blocks and presence subcollections visible under each document.

### 2. Add flutter_quill as a custom package in code export

Export your FlutterFlow project (Project Settings > Code Export > Download). Open the downloaded project in VS Code or Android Studio. In pubspec.yaml, add flutter_quill: ^9.0.0 under dependencies. Run flutter pub get. Back in FlutterFlow, navigate to Custom Code > Custom Widgets and create a widget named QuillEditorWidget. This widget wraps QuillEditor and QuillToolbar from the flutter_quill package, accepts a deltaJson String parameter and an onChanged callback, and returns the updated Delta JSON string whenever the user edits. This approach keeps the visual canvas clean while embedding the rich-text engine inside a bounded custom widget.

```
// custom_widgets/quill_editor_widget.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;

class QuillEditorWidget extends StatefulWidget {
  final String deltaJson;
  final void Function(String) onChanged;
  const QuillEditorWidget({
    super.key,
    required this.deltaJson,
    required this.onChanged,
  });
  @override
  State<QuillEditorWidget> createState() => _QuillEditorWidgetState();
}

class _QuillEditorWidgetState extends State<QuillEditorWidget> {
  late quill.QuillController _controller;

  @override
  void initState() {
    super.initState();
    final doc = widget.deltaJson.isNotEmpty
        ? quill.Document.fromJson(jsonDecode(widget.deltaJson) as List)
        : quill.Document();
    _controller = quill.QuillController(
      document: doc,
      selection: const TextSelection.collapsed(offset: 0),
    );
    _controller.addListener(() {
      final json = jsonEncode(_controller.document.toDelta().toJson());
      widget.onChanged(json);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        quill.QuillSimpleToolbar(controller: _controller),
        Expanded(
          child: quill.QuillEditor.basic(controller: _controller),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}
```

**Expected result:** flutter pub get completes with no errors and the custom widget file compiles successfully.

### 3. Implement block-level locking with a Custom Action

In FlutterFlow, go to Custom Code > Custom Actions and create an action named lockBlock. This action accepts documentId (String) and blockIndex (Integer). It writes the current user's UID and a server timestamp to locked_by and locked_at on the target block document. Add a companion action unlockBlock that clears those fields. In your page's Action Flow, call lockBlock when the user taps a block to focus it, and unlockBlock on focus loss or when the user navigates away. Use a separate Custom Action listenToBlockLock that sets up a Firestore snapshot listener — if locked_by is a different UID, set a page-level boolean isBlockLocked to true, which the widget tree can use to show a locked indicator and disable the QuillEditorWidget.

```
// custom_actions/lock_block.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

Future<void> lockBlock(String documentId, int blockIndex) async {
  final uid = FirebaseAuth.instance.currentUser?.uid;
  if (uid == null) return;
  final ref = FirebaseFirestore.instance
      .collection('documents')
      .doc(documentId)
      .collection('blocks')
      .where('index', isEqualTo: blockIndex)
      .limit(1);
  final snap = await ref.get();
  if (snap.docs.isEmpty) return;
  final blockDoc = snap.docs.first.reference;
  final data = snap.docs.first.data();
  if (data['locked_by'] != null && data['locked_by'] != uid) return;
  await blockDoc.update({
    'locked_by': uid,
    'locked_at': FieldValue.serverTimestamp(),
  });
}
```

**Expected result:** Tapping a block sets locked_by in Firestore; a second user sees a lock icon overlay on that block.

### 4. Sync block edits to Firestore with debounce

Create a Custom Action named saveBlockDelta that takes documentId (String), blockIndex (Integer), and deltaJson (String). Inside, use a Dart Timer with a 600 ms debounce — cancel any pending timer and start a new one on every call. When the timer fires, write deltaJson to the matching block's delta_json field in Firestore. Wire this action to the QuillEditorWidget's onChanged callback in the Action Flow editor. The debounce prevents a Firestore write on every keypress (which would hit rate limits and cost money), while still syncing frequently enough that collaborators see near-real-time updates. Other clients listening to the blocks subcollection will receive the updated delta and re-render their QuillEditorWidget with the new content.

**Expected result:** Typing in the editor triggers a Firestore write after 600 ms of inactivity; the Firebase console shows the delta_json field updating.

### 5. Build the presence indicator UI

Create a Custom Action updatePresence that takes documentId (String), blockIndex (Integer), and displayName (String). It upserts a document keyed by the current UID in the presence subcollection with block_index, display_name, and last_seen set to server timestamp. Call this action whenever the focused block changes. In FlutterFlow's widget tree, add a StreamBuilder (via Custom Widget) that listens to the presence subcollection and filters out the current user's own entry. For each active presence entry, render a small colored avatar badge at the top of the corresponding block. Use a deterministic color from the UID hash so each collaborator always gets the same color. Add a Cloud Function or client-side cleanup to remove presence entries where last_seen is older than 60 seconds.

**Expected result:** When two accounts open the same document, each sees the other's avatar badge next to the block they are currently editing.

### 6. Add document list screen with search

In FlutterFlow, create a DocumentsPage with a ListView bound to the documents Firestore collection filtered by owner_uid equals Current User UID. Add a TextField at the top; when its value changes, run a Firestore query filtered by title >= searchText and title <= searchText + '\uf8ff' (Firestore prefix search pattern). Show each document's title and updated_at timestamp. Add a FloatingActionButton that creates a new document in Firestore with a blank first block, then navigates to the editor page passing the new document ID. Include a long-press context menu on each list item with Rename and Delete options backed by Firestore update and delete calls.

**Expected result:** Documents page shows a filterable list; tapping a document opens the collaborative editor; creating a new document navigates directly to an empty editor.

## Complete code example

File: `custom_actions/save_block_delta.dart`

```dart
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

// Debounce timer shared across calls for the same block
final Map<String, Timer> _debounceTimers = {};

Future<void> saveBlockDelta(
  String documentId,
  int blockIndex,
  String deltaJson,
) async {
  final uid = FirebaseAuth.instance.currentUser?.uid;
  if (uid == null) return;

  final key = '$documentId-$blockIndex';
  _debounceTimers[key]?.cancel();

  _debounceTimers[key] = Timer(const Duration(milliseconds: 600), () async {
    _debounceTimers.remove(key);

    final blocksRef = FirebaseFirestore.instance
        .collection('documents')
        .doc(documentId)
        .collection('blocks');

    final snap = await blocksRef
        .where('index', isEqualTo: blockIndex)
        .limit(1)
        .get();

    if (snap.docs.isEmpty) {
      // Create block if it does not exist
      await blocksRef.add({
        'index': blockIndex,
        'delta_json': deltaJson,
        'locked_by': null,
        'locked_at': null,
      });
    } else {
      await snap.docs.first.reference.update({
        'delta_json': deltaJson,
        'updated_at': FieldValue.serverTimestamp(),
      });
    }

    // Update parent document's updated_at
    await FirebaseFirestore.instance
        .collection('documents')
        .doc(documentId)
        .update({'updated_at': FieldValue.serverTimestamp()});
  });
}

Future<void> unlockBlock(String documentId, int blockIndex) async {
  final uid = FirebaseAuth.instance.currentUser?.uid;
  if (uid == null) return;

  final snap = await FirebaseFirestore.instance
      .collection('documents')
      .doc(documentId)
      .collection('blocks')
      .where('index', isEqualTo: blockIndex)
      .limit(1)
      .get();

  if (snap.docs.isEmpty) return;
  final data = snap.docs.first.data();
  if (data['locked_by'] == uid) {
    await snap.docs.first.reference.update({
      'locked_by': null,
      'locked_at': null,
    });
  }
}
```

## Common mistakes

- **Storing the entire document as one Firestore String field** — When two users type simultaneously, Firestore's last-write-wins merge means one user silently loses all their changes. There is no conflict resolution at all. Fix: Split the document into a blocks subcollection. Each block is an independent Firestore document, so concurrent writes to different blocks succeed without conflict.
- **Saving a Quill delta on every keypress without debouncing** — A fast typist can generate 10 Firestore writes per second. At Firestore's $0.18 per 100K writes, a single active user can cost dollars per hour and trigger rate-limit errors. Fix: Wrap the save call in a 500-800 ms debounce timer that resets on each keystroke and only fires after the user pauses.
- **Forgetting to clear presence entries when users close the app** — If the app crashes or the user force-quits, the onDispose cleanup never runs. Stale presence entries then show ghost collaborators to everyone else. Fix: Use a Firestore TTL policy on the last_seen field (60-second expiry) so entries clean up server-side regardless of client state.
- **Using Flutter's built-in TextField instead of flutter_quill for rich text** — TextField stores plain strings. Bold, italic, headings, and lists cannot be serialised or restored, and the document loses all formatting on reload. Fix: Use flutter_quill and store content as Delta JSON, which is a lossless format designed for operational transformation.

## Best practices

- Store document content as Quill Delta JSON — never as HTML or plain text — to enable lossless round-trips and future OT support.
- Use a blocks subcollection rather than a monolithic string to enable block-granularity Firestore listeners and conflict-free concurrent editing.
- Debounce all Firestore writes to 500-800 ms to balance real-time feel against write costs and rate limits.
- Implement block-level locking with a locked_by UID and locked_at timestamp so the UI can warn users before they overwrite each other.
- Clean up presence entries server-side with a Firestore TTL policy rather than relying solely on client-side cleanup.
- Scope Firestore listeners to only the blocks currently visible in the viewport to avoid unbounded listener costs on long documents.
- Use Firebase Security Rules to ensure only collaborators with explicit permission can write to a document's blocks subcollection.
- Test offline behaviour by toggling airplane mode — FlutterFlow apps with Firestore have offline persistence enabled by default, and queued writes should sync on reconnect.

## Frequently asked questions

### Does flutter_quill support operational transformation to merge conflicting edits automatically?

No, flutter_quill does not include a built-in OT or CRDT engine. Block-level locking (covered in this tutorial) prevents conflicts by ensuring only one user edits a block at a time. For true simultaneous character-level merging you would need a library like Yjs or Automerge integrated as a custom Dart package, which is significantly more complex.

### Can I use this approach without exporting code from FlutterFlow?

The QuillEditorWidget requires a custom widget defined in Dart code, which needs the code export workflow. You can scaffold the rest of the UI (document list, navigation, Firestore queries) in the visual builder, but the editor widget itself must be a custom code component.

### How many blocks can a single document have before Firestore performance degrades?

Firestore handles subcollections with thousands of documents efficiently. For typical documents under 500 blocks, real-time listeners are fast. For very long documents, scope your listeners to the visible viewport (blocks with index between firstVisible and lastVisible) to avoid downloading the entire document on open.

### What happens if a user loses connection while holding a block lock?

The lock persists in Firestore until either the app reconnects and calls unlockBlock, or the server-side TTL on locked_at expires. Set locked_at TTL to 30-60 seconds so locks auto-release even when a client disconnects without cleanup.

### Can I add comments or annotations to specific text ranges like Google Docs?

Yes. Quill Delta supports custom attributes on text ranges. Store a comments map in the block document keyed by a comment ID, with start_index and length fields. Apply a custom Quill embed or inline attribute to highlight the range, and show the comment thread in a side panel when the user taps the highlighted text.

### Is FlutterFlow the right tool for building a full collaborative editor product?

FlutterFlow is a good starting point for an MVP collaborative editor. For production at scale, you will likely need to export the project and maintain it as a standard Flutter codebase, integrating a dedicated CRDT library for conflict resolution and a WebSocket-based sync layer for lower latency than Firestore listeners.

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-to-create-a-real-time-collaborative-document-editor-in-flutterflow
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-to-create-a-real-time-collaborative-document-editor-in-flutterflow
