Skip to main content
RapidDev - Software Development Agency
flutterflow-tutorials

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

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.

What you'll learn

  • How to integrate flutter_quill into a FlutterFlow project via custom code
  • How to structure Firestore with a blocks subcollection to prevent write conflicts
  • How to implement block-level locking so two users cannot overwrite each other
  • How to display real-time cursor presence indicators for active collaborators
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner10 min read45-60 minFlutterFlow Pro+ (code export required)March 2026RapidDev Engineering Team
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.

Prerequisites

  • 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
1// custom_widgets/quill_editor_widget.dart
2import 'dart:convert';
3import 'package:flutter/material.dart';
4import 'package:flutter_quill/flutter_quill.dart' as quill;
5
6class QuillEditorWidget extends StatefulWidget {
7 final String deltaJson;
8 final void Function(String) onChanged;
9 const QuillEditorWidget({
10 super.key,
11 required this.deltaJson,
12 required this.onChanged,
13 });
14 @override
15 State<QuillEditorWidget> createState() => _QuillEditorWidgetState();
16}
17
18class _QuillEditorWidgetState extends State<QuillEditorWidget> {
19 late quill.QuillController _controller;
20
21 @override
22 void initState() {
23 super.initState();
24 final doc = widget.deltaJson.isNotEmpty
25 ? quill.Document.fromJson(jsonDecode(widget.deltaJson) as List)
26 : quill.Document();
27 _controller = quill.QuillController(
28 document: doc,
29 selection: const TextSelection.collapsed(offset: 0),
30 );
31 _controller.addListener(() {
32 final json = jsonEncode(_controller.document.toDelta().toJson());
33 widget.onChanged(json);
34 });
35 }
36
37 @override
38 Widget build(BuildContext context) {
39 return Column(
40 children: [
41 quill.QuillSimpleToolbar(controller: _controller),
42 Expanded(
43 child: quill.QuillEditor.basic(controller: _controller),
44 ),
45 ],
46 );
47 }
48
49 @override
50 void dispose() {
51 _controller.dispose();
52 super.dispose();
53 }
54}

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
1// custom_actions/lock_block.dart
2import 'package:cloud_firestore/cloud_firestore.dart';
3import 'package:firebase_auth/firebase_auth.dart';
4
5Future<void> lockBlock(String documentId, int blockIndex) async {
6 final uid = FirebaseAuth.instance.currentUser?.uid;
7 if (uid == null) return;
8 final ref = FirebaseFirestore.instance
9 .collection('documents')
10 .doc(documentId)
11 .collection('blocks')
12 .where('index', isEqualTo: blockIndex)
13 .limit(1);
14 final snap = await ref.get();
15 if (snap.docs.isEmpty) return;
16 final blockDoc = snap.docs.first.reference;
17 final data = snap.docs.first.data();
18 if (data['locked_by'] != null && data['locked_by'] != uid) return;
19 await blockDoc.update({
20 'locked_by': uid,
21 'locked_at': FieldValue.serverTimestamp(),
22 });
23}

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 working example

custom_actions/save_block_delta.dart
1import 'dart:async';
2import 'package:cloud_firestore/cloud_firestore.dart';
3import 'package:firebase_auth/firebase_auth.dart';
4
5// Debounce timer shared across calls for the same block
6final Map<String, Timer> _debounceTimers = {};
7
8Future<void> saveBlockDelta(
9 String documentId,
10 int blockIndex,
11 String deltaJson,
12) async {
13 final uid = FirebaseAuth.instance.currentUser?.uid;
14 if (uid == null) return;
15
16 final key = '$documentId-$blockIndex';
17 _debounceTimers[key]?.cancel();
18
19 _debounceTimers[key] = Timer(const Duration(milliseconds: 600), () async {
20 _debounceTimers.remove(key);
21
22 final blocksRef = FirebaseFirestore.instance
23 .collection('documents')
24 .doc(documentId)
25 .collection('blocks');
26
27 final snap = await blocksRef
28 .where('index', isEqualTo: blockIndex)
29 .limit(1)
30 .get();
31
32 if (snap.docs.isEmpty) {
33 // Create block if it does not exist
34 await blocksRef.add({
35 'index': blockIndex,
36 'delta_json': deltaJson,
37 'locked_by': null,
38 'locked_at': null,
39 });
40 } else {
41 await snap.docs.first.reference.update({
42 'delta_json': deltaJson,
43 'updated_at': FieldValue.serverTimestamp(),
44 });
45 }
46
47 // Update parent document's updated_at
48 await FirebaseFirestore.instance
49 .collection('documents')
50 .doc(documentId)
51 .update({'updated_at': FieldValue.serverTimestamp()});
52 });
53}
54
55Future<void> unlockBlock(String documentId, int blockIndex) async {
56 final uid = FirebaseAuth.instance.currentUser?.uid;
57 if (uid == null) return;
58
59 final snap = await FirebaseFirestore.instance
60 .collection('documents')
61 .doc(documentId)
62 .collection('blocks')
63 .where('index', isEqualTo: blockIndex)
64 .limit(1)
65 .get();
66
67 if (snap.docs.isEmpty) return;
68 final data = snap.docs.first.data();
69 if (data['locked_by'] == uid) {
70 await snap.docs.first.reference.update({
71 'locked_by': null,
72 'locked_at': null,
73 });
74 }
75}

Common mistakes when creating a Real-Time Collaborative Document Editor in FlutterFlow

Why it's a problem: Storing the entire document as one Firestore String field

How to avoid: Split the document into a blocks subcollection. Each block is an independent Firestore document, so concurrent writes to different blocks succeed without conflict.

Why it's a problem: Saving a Quill delta on every keypress without debouncing

How to avoid: Wrap the save call in a 500-800 ms debounce timer that resets on each keystroke and only fires after the user pauses.

Why it's a problem: Forgetting to clear presence entries when users close the app

How to avoid: Use a Firestore TTL policy on the last_seen field (60-second expiry) so entries clean up server-side regardless of client state.

Why it's a problem: Using Flutter's built-in TextField instead of flutter_quill for rich text

How to avoid: 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.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I am building a collaborative document editor in FlutterFlow using flutter_quill and Firestore. My document is split into a blocks subcollection where each block has index, delta_json, locked_by, and locked_at fields. Write a Dart Custom Action that: (1) debounces writes to 600 ms, (2) checks locked_by before writing, (3) updates the parent document's updated_at timestamp. Also explain the best Firestore Security Rules to lock write access to the locked_by UID.

FlutterFlow Prompt

In my FlutterFlow project I have a custom widget QuillEditorWidget with an onChanged callback that returns a delta JSON string. Create the Action Flow sequence that: calls lockBlock when the widget gains focus, calls saveBlockDelta with the new delta on every onChanged event, calls unlockBlock when the widget loses focus, and updates a page state variable isBlockLocked based on a Firestore listener on the locked_by field.

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.

RapidDev

Talk to an Expert

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

Book a free consultation

Need help with your project?

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.