Build a document editor in FlutterFlow by integrating flutter_quill as a Custom Widget for rich text formatting (bold, italic, headings, lists), storing content as Quill Delta JSON in Firestore rather than HTML, adding an auto-save debounce timer that writes after 600 ms of inactivity, and building a document list page with Firestore prefix search.
Rich Text Editing in FlutterFlow with flutter_quill
FlutterFlow's built-in TextField supports plain text only — no formatting, no headings, no lists. For any document editor, you need flutter_quill, an open-source rich-text package for Flutter that powers formatting toolbars, serialises content as Delta JSON, and handles complex text operations safely. The most common mistake when building document editors is storing rich text as an HTML string. HTML strings are vulnerable to XSS injection, are difficult to parse back into a Quill document, and lose formatting on round-trips. Quill Delta JSON is a well-defined, lossless format specifically designed for rich text. Store it as a Firestore String field. This tutorial builds a complete single-user document editor from scratch.
Prerequisites
- FlutterFlow Pro plan with code export enabled
- Firebase project with Firestore enabled
- flutter_quill package added to pubspec.yaml (^9.0.0)
- Basic understanding of FlutterFlow Custom Widgets
- Flutter/Dart SDK installed for testing the exported project
Step-by-step guide
Set up the Firestore documents collection
Set up the Firestore documents collection
Open Firebase console and create a documents collection. Each document needs: title (String), content_delta (String — serialised Quill Delta JSON), owner_uid (String), word_count (Integer — updated on each save), created_at (Timestamp), and updated_at (Timestamp). In FlutterFlow, open Firestore and create the same collection schema via the GUI. The content_delta field stores the entire document as a JSON string — start with an empty delta: '[{"insert":"\n"}]'. This single newline is the minimum valid Quill document. Setting word_count as a separate field lets you display it in the document list without loading the full delta string. Add a Firestore composite index on owner_uid (ascending) + updated_at (descending) for efficient per-user document listing.
Expected result: Firestore shows a documents collection with the correct field types and a test document with the empty delta string.
Create the QuillEditorWidget custom widget
Create the QuillEditorWidget custom widget
Export your FlutterFlow project and add flutter_quill: ^9.0.0 to pubspec.yaml. In FlutterFlow's Custom Code panel, create a Custom Widget named QuillEditorWidget. It accepts two parameters: initialDelta (String) and a void Function(String, int) callback named onChanged — the two values are the new delta JSON string and the current word count. The widget initialises a QuillController from the initial delta on first load. It renders a QuillSimpleToolbar above a QuillEditor in an Expanded layout. The controller's change listener fires onChanged with the serialised delta and the plain-text word count (calculated by splitting the document's toPlainText() output on whitespace). Place this widget on the editor page using the Custom Widget node in the canvas, setting the initialDelta to the Firestore content_delta field value.
1import 'dart:convert';2import 'package:flutter/material.dart';3import 'package:flutter_quill/flutter_quill.dart' as quill;45class QuillEditorWidget extends StatefulWidget {6 final String initialDelta;7 final void Function(String delta, int wordCount) onChanged;8 const QuillEditorWidget({9 super.key,10 required this.initialDelta,11 required this.onChanged,12 });13 @override14 State<QuillEditorWidget> createState() => _QuillEditorWidgetState();15}1617class _QuillEditorWidgetState extends State<QuillEditorWidget> {18 late quill.QuillController _controller;19 @override20 void initState() {21 super.initState();22 final ops = widget.initialDelta.isNotEmpty23 ? jsonDecode(widget.initialDelta) as List24 : [{'insert': '\n'}];25 _controller = quill.QuillController(26 document: quill.Document.fromJson(ops),27 selection: const TextSelection.collapsed(offset: 0),28 );29 _controller.addListener(() {30 final delta = jsonEncode(_controller.document.toDelta().toJson());31 final plain = _controller.document.toPlainText();32 final words = plain.trim().isEmpty33 ? 034 : plain.trim().split(RegExp(r'\s+')).length;35 widget.onChanged(delta, words);36 });37 }38 @override39 Widget build(BuildContext context) {40 return Column(children: [41 quill.QuillSimpleToolbar(controller: _controller),42 Expanded(child: quill.QuillEditor.basic(controller: _controller)),43 ]);44 }45 @override46 void dispose() { _controller.dispose(); super.dispose(); }47}Expected result: The custom widget appears on the canvas and renders a formatting toolbar above an editable text area.
Implement auto-save with debounce
Implement auto-save with debounce
Create a Custom Action named autoSaveDocument that takes documentId (String), deltaJson (String), and wordCount (Integer). The action uses a module-level Map<String, Timer> to debounce writes per document — cancels any pending timer and starts a new 600 ms timer that writes to Firestore when it fires. The Firestore update sets content_delta, word_count, and updated_at. In the FlutterFlow Action Flow for the QuillEditorWidget's onChanged callback: set page state variables currentDelta and currentWordCount, then call autoSaveDocument. A small saving/saved indicator in the top-right corner of the editor page (an Icon that toggles between a spinning indicator and a checkmark based on a page state isSaving boolean) gives users confidence their work is being saved.
1import 'dart:async';2import 'package:cloud_firestore/cloud_firestore.dart';34final Map<String, Timer> _saveTimers = {};56Future<void> autoSaveDocument(7 String documentId,8 String deltaJson,9 int wordCount,10) async {11 _saveTimers[documentId]?.cancel();12 _saveTimers[documentId] = Timer(const Duration(milliseconds: 600), () async {13 _saveTimers.remove(documentId);14 await FirebaseFirestore.instance15 .collection('documents')16 .doc(documentId)17 .update({18 'content_delta': deltaJson,19 'word_count': wordCount,20 'updated_at': FieldValue.serverTimestamp(),21 });22 });23}Expected result: Typing in the editor shows a saving indicator; the Firestore console shows updated_at advancing every time the user pauses for 600 ms.
Add a title field with real-time update
Add a title field with real-time update
At the top of the editor page, add a TextField widget for the document title. Set its initial value to the Firestore document's title field, loaded via a Document Query on the page. In the TextField's On Text Change action, debounce a Firestore update to the title field — use a separate Custom Action updateDocumentTitle(String documentId, String title) with its own 500 ms debounce timer. This keeps the title and content saves independent. Display the auto-updated title in a Text widget at the top of the document list page using a Firestore stream query so the list updates immediately when the title changes.
Expected result: Editing the title in the editor page causes the document list to update the title in near real time.
Build a searchable document list page
Build a searchable document list page
Create a DocumentsPage with a ListView bound to a Firestore query on the documents collection filtered by owner_uid equals Current User UID, ordered by updated_at descending. Each list item shows the document title, word_count, and a relative time string (using a Custom Function formatRelativeTime). Add a TextField search bar at the top. When its value changes, run a Firestore query with title >= searchText and title <= searchText + '\uf8ff' to implement prefix search. Show the matching results in the ListView. Add a FAB that creates a new document in Firestore with the empty delta, then navigates to the editor page passing the new document ID. Include a delete confirmation bottom sheet accessible by long-pressing a list item.
Expected result: The document list shows all user documents sorted by last edit time; the search bar filters by title prefix; tapping creates or opens a document.
Complete working example
1import 'dart:async';2import 'dart:convert';3import 'package:cloud_firestore/cloud_firestore.dart';4import 'package:firebase_auth/firebase_auth.dart';56final Map<String, Timer> _saveTimers = {};7final Map<String, Timer> _titleTimers = {};89/// Auto-saves document content with a 600ms debounce.10Future<void> autoSaveDocument(11 String documentId,12 String deltaJson,13 int wordCount,14) async {15 _saveTimers[documentId]?.cancel();16 _saveTimers[documentId] = Timer(const Duration(milliseconds: 600), () async {17 _saveTimers.remove(documentId);18 try {19 await FirebaseFirestore.instance20 .collection('documents')21 .doc(documentId)22 .update({23 'content_delta': deltaJson,24 'word_count': wordCount,25 'updated_at': FieldValue.serverTimestamp(),26 });27 } catch (e) {28 // Silent retry on next change — do not surface transient network errors29 }30 });31}3233/// Auto-saves document title with a 500ms debounce.34Future<void> updateDocumentTitle(String documentId, String title) async {35 _titleTimers[documentId]?.cancel();36 _titleTimers[documentId] = Timer(const Duration(milliseconds: 500), () async {37 _titleTimers.remove(documentId);38 await FirebaseFirestore.instance39 .collection('documents')40 .doc(documentId)41 .update({42 'title': title.trim().isEmpty ? 'Untitled' : title.trim(),43 'updated_at': FieldValue.serverTimestamp(),44 });45 });46}4748/// Creates a new document and returns its Firestore ID.49Future<String> createDocument() async {50 final uid = FirebaseAuth.instance.currentUser?.uid;51 if (uid == null) return '';52 const emptyDelta = '[{"insert":"\\n"}]';53 final ref = await FirebaseFirestore.instance.collection('documents').add({54 'title': 'Untitled',55 'content_delta': emptyDelta,56 'owner_uid': uid,57 'word_count': 0,58 'created_at': FieldValue.serverTimestamp(),59 'updated_at': FieldValue.serverTimestamp(),60 });61 return ref.id;62}6364/// Deletes a document permanently.65Future<void> deleteDocument(String documentId) async {66 await FirebaseFirestore.instance67 .collection('documents')68 .doc(documentId)69 .delete();70}Common mistakes when creating a Document Editor in FlutterFlow
Why it's a problem: Storing rich text as an HTML string instead of Quill Delta JSON
How to avoid: Store content_delta as the serialised output of controller.document.toDelta().toJson() — a JSON array. Restore it with Document.fromJson(jsonDecode(deltaString) as List).
Why it's a problem: Saving the document on every keypress without debouncing
How to avoid: Use a 500-800 ms debounce timer that resets on every change and only fires after the user pauses. This reduces writes to 1-3 per minute for a typical typing session.
Why it's a problem: Not showing a save status indicator to the user
How to avoid: Show a 'Saving...' state when the user types and a 'Saved' checkmark when the Firestore write completes. The visual indicator uses a page state isSaving boolean toggled before and after the Firestore write.
Why it's a problem: Loading the entire document delta on the document list page
How to avoid: Store word_count and title as separate top-level Firestore fields updated on each save. The list query reads only these fields, not the full delta.
Best practices
- Store document content as Quill Delta JSON — never as HTML or plain text.
- Always debounce Firestore writes to at least 500 ms to stay within the per-document write rate limit.
- Show a visible save status indicator so users trust their content is persisted.
- Maintain separate word_count and title fields for efficient document list queries.
- Use Firebase Security Rules to restrict document read/write to the owner_uid.
- Initialise the QuillController with the existing delta from Firestore, not an empty document, on each page load.
- Add a character/word limit guard in the auto-save action to prevent oversized document writes.
- Test the editor on low-memory devices — the QuillController holds the full document in memory, and very long documents can cause performance issues on older phones.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I am building a document editor in FlutterFlow using flutter_quill. The document content is stored as Quill Delta JSON in a Firestore String field called content_delta. Write a Dart Custom Widget that: initialises a QuillController from the stored delta JSON string, provides a formatting toolbar with bold/italic/headings/lists, fires an onChanged callback with the updated delta JSON and the current word count, and properly disposes the controller when the widget is removed.
In FlutterFlow I have a QuillEditorWidget custom widget on my editor page. Its onChanged callback receives a delta string and word count. Build the complete Action Flow for this callback that: sets page state variable currentDelta and currentWordCount, sets isSaving to true, calls the autoSaveDocument custom action, and sets isSaving back to false after the action completes. Also show how to display the isSaving state as a 'Saving...' text label in the app bar.
Frequently asked questions
Can I build a document editor without exporting the FlutterFlow project?
Partially. You can build the document list, navigation, and Firestore data management entirely in the visual builder. However, the QuillEditorWidget itself must be a Custom Widget written in Dart, which requires adding the flutter_quill package. This package addition works best in the exported project workflow. FlutterFlow's Add Dependency feature may work for simple package adds, but flutter_quill requires additional platform configuration.
What is Quill Delta JSON and why is it better than HTML for storage?
Quill Delta is a minimal, JSON-based format that describes a document as a list of insert, retain, and delete operations. Unlike HTML, Delta is safe to store in databases without sanitisation, losslessly round-trips through the Quill editor, supports custom attributes (comments, tracked changes), and is easy to parse programmatically. HTML from rich text editors often contains inconsistent markup that breaks on re-import.
How do I export a document to PDF from the editor?
Flutter does not have a built-in PDF export. Use the printing package (pub.dev/packages/printing), which can render a Flutter widget tree to PDF. Convert the Quill document to a list of formatted text spans, render them in a PDF page layout using printing's PdfPageFormat, and either save to device storage or share via the share_plus package. This requires a Custom Action with the printing and share_plus packages.
Does flutter_quill support images inside documents?
Yes. flutter_quill supports image embeds via the ImageEmbedBuilder. Images are stored as URLs or base64 strings in the Delta JSON. For a production editor, upload images to Firebase Storage first, then insert the download URL into the Delta. Avoid base64 storage in Firestore — a single image can exceed Firestore's 1MB document size limit.
How do I implement undo and redo in the editor?
flutter_quill's QuillController includes built-in undo and redo support via the QuillHistory class, which is automatically maintained as the user types. Add two IconButtons to the toolbar — one calling controller.undo() and one calling controller.redo(). Disable the undo button when controller.document.history.hasUndo is false and the redo button when hasRedo is false.
What is the maximum document size I can store in Firestore?
Firestore's maximum document size is 1MB (1,048,576 bytes). A Quill Delta for a 5,000-word document is typically 20-50KB, well within limits. For very long documents (50,000+ words), consider splitting the document into page-sized chunks in a subcollection, similar to the blocks approach described in the collaborative editor tutorial.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation