Build a database-driven chat in FlutterFlow using Firestore's subcollection structure: conversations/{id}/messages/{msgId}. Enable real-time by turning off Single Time Query on the messages Backend Query. Messages ordered by timestamp descending with a reversed ListView. Add read receipts by updating lastReadAt on the conversation document when the user opens the chat. Add unread count by comparing lastReadAt with conversation.lastMessageTime.
Chat data model: why subcollections beat arrays for messages
The wrong Firestore structure makes chat slow and unreliable. Storing messages as an array inside a conversation document hits Firestore's 1MB document limit after a few hundred messages and prevents efficient pagination. The correct structure uses subcollections: conversations/{conversationId}/messages/{messageId}. Each message is an independent document with its own ID, timestamp, sender, and content. You query and paginate the messages subcollection separately from the conversation metadata. This tutorial focuses on the database integration mechanics — the data model, real-time listening, message ordering, pagination, read receipts, and typing indicators — that make a chat feel responsive and reliable at any scale.
Prerequisites
- FlutterFlow project with Firebase Firestore and Authentication enabled
- Basic familiarity with Backend Queries and Action Flows in FlutterFlow
- Firebase Blaze plan for production apps (Firestore reads/writes scale beyond Spark free tier limits quickly in chat)
Step-by-step guide
Design the Firestore chat data model
Design the Firestore chat data model
Create two collections in FlutterFlow's Firestore panel: conversations and a messages subcollection. The conversations collection documents contain: conversationId (auto-generated), participants (Array of String UIDs), participantNames (Map: {uid: displayName} for fast display), lastMessage (String: preview of last message), lastMessageTime (Timestamp), lastMessageSenderId (String UID), unreadCount (Map: {uid: Integer} — per-participant unread counts), createdAt (Timestamp). The messages subcollection under each conversation has: messageId (auto-generated), senderId (String UID), senderName (String: copied from user profile at send time), text (String), mediaUrl (String, nullable), mediaType (String: 'text', 'image', 'file'), timestamp (Timestamp), readBy (Array of String UIDs). This flat, denormalized design avoids expensive JOINs and makes reads fast.
Expected result: Conversations and messages Firestore collections are configured with the correct fields and types.
Build the conversations list page with unread counts
Build the conversations list page with unread counts
Create a Conversations page. Add a Backend Query on the page fetching the conversations collection filtered by: participants arrayContains Current User UID, ordered by lastMessageTime descending. Single Time Query: OFF (real-time updates when new messages arrive). Add a ListView bound to the query results. Each conversation list item shows: an Avatar (CircleImage), the participant name (from participantNames map, showing the other participant's name), a preview of lastMessage, a formatted relative time from lastMessageTime (use a Custom Function to format Timestamp as 'just now', '5m ago', '2h ago'), and an unread badge (Container with red background and bold text showing the unread count from unreadCount[currentUserUid]). Conditionally show the unread badge only when unreadCount[currentUserUid] > 0.
Expected result: The conversations list shows all user conversations in reverse chronological order with live unread message counts.
Set up the real-time messages view with correct ordering
Set up the real-time messages view with correct ordering
Create a ChatDetail page with a Page Parameter named conversationId (String). Add a Backend Query on the page for the messages subcollection at path conversations/{conversationId}/messages, ordered by timestamp DESCENDING, limit 50. Single Time Query: OFF for real-time. The descending order puts the newest message at the top of the query result, but you want it at the bottom of the screen. Add a ListView to the page. In the ListView settings, check the Reverse Scroll option — this flips the list so index 0 (newest message) appears at the bottom and older messages scroll upward. Each message item is a Row: the sender's avatar (small, 32px), a message bubble Container (your message: right-aligned, blue background; other's message: left-aligned, grey background — use Conditional Widget to swap alignment based on senderId == CurrentUser.uid).
Expected result: The chat view shows messages in correct order with newest at the bottom, updating in real-time as new messages arrive.
Send messages and update the conversation's lastMessage metadata
Send messages and update the conversation's lastMessage metadata
Add a message input row at the bottom of the ChatDetail page: a TextField (Page State variable: messageText) and a Send IconButton. The Send button's action flow: (1) Add Document to conversations/{conversationId}/messages with {senderId: currentUser.uid, senderName: currentUser.displayName, text: messageText, timestamp: FieldValue.serverTimestamp(), mediaType: 'text', readBy: [currentUser.uid]}. (2) Update Document on conversations/{conversationId} with {lastMessage: messageText, lastMessageTime: FieldValue.serverTimestamp(), lastMessageSenderId: currentUser.uid}. (3) Clear the messageText Page State variable. Use FieldValue.serverTimestamp() via a Custom Function or set timestamp to Current Date Time for the timestamp field — server timestamp is preferred for consistency across time zones.
Expected result: Messages appear instantly in the chat view and the conversation list preview updates with the latest message.
Implement read receipts and typing indicator
Implement read receipts and typing indicator
Read receipts: when the user opens a ChatDetail page, in the On Page Load action, call Update Document on conversations/{conversationId} setting lastReadAt_{currentUserUid}: serverTimestamp(). To show read status on your sent messages, check if the other participant's UID is in the message's readBy array. Typing indicator: add a typingUserId (String, nullable) field to the conversation document. When the user starts typing in the TextField (On TextField Change), call Update Document setting typingUserId to the current user's UID. Use a debounce Custom Action — after 2 seconds of no typing, set typingUserId to null. In the chat UI, show a 'User is typing...' text Container above the input row when typingUserId is not null AND typingUserId != currentUser.uid. The typing indicator updates in real-time via the conversation document's real-time listener.
Expected result: The chat shows read receipts on sent messages and a live typing indicator when the other user is composing a message.
Complete working example
1Firestore Chat Data Model23conversations/{conversationId}4 participants: String[] // [uid1, uid2]5 participantNames: Map // {uid1: 'Alice', uid2: 'Bob'}6 lastMessage: String7 lastMessageTime: Timestamp8 lastMessageSenderId: String9 typingUserId: String? // for typing indicator10 createdAt: Timestamp1112conversations/{conversationId}/messages/{messageId}13 senderId: String14 senderName: String // denormalized for fast display15 text: String16 mediaUrl: String? // for image messages17 mediaType: 'text' | 'image' | 'file'18 timestamp: Timestamp19 readBy: String[] // UIDs who have read this message2021QUERIES:22 Conversation list:23 WHERE participants arrayContains currentUid24 ORDER BY lastMessageTime DESC25 Single Time Query: OFF2627 Messages:28 COLLECTION: conversations/{id}/messages29 ORDER BY timestamp DESC30 LIMIT 5031 Single Time Query: OFF3233FLUTTERFLOW KEY SETTINGS:34 ListView: Reverse Scroll = true35 (newest message anchors to bottom)3637SEND MESSAGE ACTION FLOW:38 1. Add Document → messages subcollection39 2. Update Document → conversation.lastMessage40 3. Clear messageText Page State41 4. Scroll ListView to bottomCommon mistakes
Why it's a problem: Storing messages as an Array inside the conversation document instead of a subcollection
How to avoid: Use the subcollection structure: conversations/{id}/messages/{msgId}. Each message is its own document. You can paginate with limit and startAfterDocument, query specific date ranges, and the conversation document stays small regardless of message count.
Why it's a problem: Using Single Time Query: ON (default) for the messages Backend Query
How to avoid: Set Single Time Query to OFF on the messages Backend Query. This creates a real-time Firestore listener that pushes new messages to the app within 1-2 seconds of them being written.
Why it's a problem: Ordering messages ascending and not reversing the ListView
How to avoid: Order the messages Backend Query by timestamp DESCENDING. In the ListView settings, enable the Reverse Scroll option. This combination makes new messages appear at the bottom of the screen automatically.
Best practices
- Denormalize sender name and avatar into each message document — this avoids a JOIN to the users collection for every message displayed in the ListView
- Implement infinite scroll pagination for older messages — load the initial 50 messages and load more when the user scrolls to the top of the chat history
- Use Firestore's FieldValue.serverTimestamp() for message timestamps, not the device's local time — prevents time ordering issues when devices have incorrect clocks
- Cap typing indicator updates at one Firestore write per 2 seconds using debounce logic — typing indicators without debounce burn Firestore write quota
- Store the other participant's push token in the conversation document and trigger a Cloud Function on new message writes to send a push notification
- Add a message delivery status by updating a status field (sent, delivered, read) — update to delivered when the recipient opens the conversation
- Use Firestore security rules to enforce that senderId in the message must match the writing user's UID to prevent message spoofing
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I am building a FlutterFlow chat app using Firestore. Write me the Firestore security rules for a chat system with this data model: conversations collection with participants array field, and messages subcollection under each conversation. Rules should: (1) allow users to read conversations where their UID is in the participants array, (2) allow users to read messages only in conversations they are participants of, (3) allow users to create messages only in their own conversations with senderId matching their UID, (4) prevent users from editing or deleting messages.
Build the chat message list page in FlutterFlow. The page should have: a Backend Query on the messages subcollection ordered by timestamp descending with Single Time Query OFF, a reversed ListView bound to the query results, message bubbles aligned right for messages from the current user and left for others (with different background colors), and a bottom input row with a TextField and Send button. When Send is tapped, add the message document to the subcollection and update the parent conversation's lastMessage field.
Frequently asked questions
How do I load older messages as the user scrolls up?
Implement cursor-based pagination for the messages subcollection. Store the oldest loaded message's DocumentSnapshot in a Page State variable named oldestMessageSnapshot. Add an InfiniteScroll or a Load Earlier Messages button. When triggered, run the messages Backend Query again with the same order and limit but add a startAfterDocument parameter bound to oldestMessageSnapshot. Prepend the new results to your existing messages list in App State and update oldestMessageSnapshot to the oldest document in the new batch.
How do I send image messages in the chat?
Add a media attachment button (image picker icon) next to the text input. On tap, open the device image picker via a Custom Action. Upload the selected image to Firebase Storage at chatMedia/{conversationId}/{timestamp}_{filename}. After successful upload, get the download URL. Then send a message document with mediaUrl set to the download URL, mediaType set to 'image', and text set to an empty string or a placeholder like 'Photo'. In the chat ListView, render image messages with an Image widget (showing the mediaUrl) instead of a Text widget.
Can I build a group chat with this same data model?
Yes, with minor modifications. The participants array supports any number of UIDs — just add all group member UIDs. participantNames becomes a map with all members. For group unread counts, maintain a lastReadAt map keyed by each participant's UID and compute per-user unread counts by counting messages with timestamps newer than lastReadAt[uid]. The messages subcollection pattern handles group chats identically to 1-to-1 chats.
How do I send push notifications for new messages?
Store each user's FCM (Firebase Cloud Messaging) push token in their user profile document. Create a Firestore trigger Cloud Function that fires on new document creation in conversations/{conversationId}/messages/{messageId}. In the function, read the other participants' FCM tokens and send a push notification via admin.messaging().sendMulticast(). Include the message text, sender name, and conversationId in the notification payload so the app navigates directly to the correct conversation when the notification is tapped.
How do I delete messages and handle message reactions?
Message deletion: add a long-press action on message bubbles that shows an action sheet (Delete). The Delete action updates the message document setting text to 'This message was deleted' and deleted: true, rather than actually deleting the document — this preserves message threading for replies and prevents gaps in the conversation history. Message reactions: add a reactions field as a Map on each message document ({emoji: [uid1, uid2]}). Tapping a reaction emoji updates the reactions field using FieldValue.arrayUnion and FieldValue.arrayRemove for toggle behavior.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation