Flutter and Firebase: Build a Real-Time Chat Application

FLUTTER • FIREBASE • REAL-TIME DEVELOPMENT

Flutter and Firebase: Build a Real-Time Chat Application

Build a modern messaging app with Firebase Authentication, Cloud Firestore, live message streams, secure access rules, and a polished Flutter chat interface.

Intermediate to Advanced Tutorial

Last updated: October 2026

Real-time messaging is a common feature in social networking apps, customer support platforms, collaboration tools, and business applications. Flutter and Firebase provide a practical way to build this functionality without managing your own messaging server.

In this tutorial, we will build the foundation of a one-to-one chat application. We will use Firebase Authentication to identify users, Cloud Firestore to store conversations and messages, and Firestore snapshot streams to update the chat screen as messages change.

What you will learn
  • Configure Firebase in a Flutter project.
  • Authenticate users with Firebase Authentication.
  • Design a scalable Firestore chat data structure.
  • Send and receive messages in real time.
  • Build responsive message bubbles and a composer.
  • Protect conversations with Firestore Security Rules.
  • Handle loading, errors, timestamps, and pagination.

Table of Contents

  1. How real-time chat works
  2. Application architecture
  3. Firebase and Flutter setup
  4. Firestore database design
  5. Create the message model
  6. Build the chat repository
  7. Build the chat screen
  8. Secure Firestore with rules
  9. Pagination and scalability
  10. Testing and debugging
  11. Production readiness checklist
  12. Frequently asked questions

1. How Does a Real-Time Chat Application Work?

Traditional applications often fetch data only when a user opens a screen or taps a refresh button. With a real-time listener, the application subscribes to changes in a Firestore query. When a new message is added, the listener receives an updated snapshot and Flutter rebuilds the relevant part of the interface.

🔐

Authentication

Identifies the signed-in user and provides a stable Firebase UID.

💬

Cloud Firestore

Stores conversations, messages, and their timestamps.

⚡

Snapshot streams

Deliver updates so the message list can react automatically.

2. Real-Time Chat Architecture

For maintainability, separate the UI, repository, Firebase services, and persistent data. This example uses a lightweight architecture that can later be expanded into feature-first or clean architecture.

Flutter Chat UI
Message bubbles and composer
↕
Chat Repository
Send messages and watch streams
↕
Firebase SDKs
Authentication and Firestore
↕
Firebase Backend
Identity, database, access rules

Important: Firebase Authentication identifies the user, but it does not automatically authorize access to every conversation. Firestore Security Rules must validate membership and permitted operations.

3. Set Up Firebase in Flutter

Step 1: Create the Flutter application

flutter create flutter_firebase_chat
cd flutter_firebase_chat

Step 2: Create a Firebase project

  1. Open the Firebase Console.
  2. Create a Firebase project or select an existing project.
  3. Register your Android, iOS, or web application.
  4. Enable Firebase Authentication and choose a sign-in provider.
  5. Create a Cloud Firestore database.

Step 3: Install the Firebase CLI and FlutterFire CLI

firebase login
dart pub global activate flutterfire_cli

Make sure the Firebase CLI is installed and available in your terminal before running the commands above.

Step 4: Configure FlutterFire

flutter pub add firebase_core
flutter pub add firebase_auth
flutter pub add cloud_firestore

flutterfire configure

The FlutterFire CLI configures the selected platforms and generates lib/firebase_options.dart. Rerun it when adding a platform or certain Firebase products that require updated configuration.

Step 5: Initialize Firebase

Update lib/main.dart:

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  runApp(const ChatApp());
}

class ChatApp extends StatelessWidget {
  const ChatApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Firebase Chat',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF4756C8),
        ),
        useMaterial3: true,
      ),
      home: const Scaffold(
        body: Center(
          child: Text(
            'Firebase Chat is configured',
          ),
        ),
      ),
    );
  }
}

At this point, the app initializes Firebase. Replace the temporary home screen with the authentication flow and chat screen as you build the remaining features.

Official reference: Set up Firebase in a Flutter project.

4. Design the Firestore Database

A simple and extensible design stores each conversation in a chats collection and its messages in a nested messages subcollection.

chats/
  chat_abc123/
    participants: ["uid_A", "uid_B"]
    createdAt: Timestamp

    messages/
      message_001/
        senderId: "uid_A"
        text: "Hello!"
        createdAt: Timestamp

      message_002/
        senderId: "uid_B"
        text: "Hi there!"
        createdAt: Timestamp

Recommended message fields

FieldTypePurpose
senderIdStringIdentifies the sender.
textStringContains the message body.
createdAtTimestampSupports chronological ordering.

The parent chat document also stores participant UIDs. These UIDs are the basis for access control and conversation membership.

Data design tip

Do not store every message in a single growing array inside the chat document. A subcollection scales better for message history, queries, pagination, and independent document updates.

5. Create the Message Model

Create lib/models/chat_message.dart:

import 'package:cloud_firestore/cloud_firestore.dart';

class ChatMessage {
  const ChatMessage({
    required this.id,
    required this.senderId,
    required this.text,
    required this.createdAt,
  });

  final String id;
  final String senderId;
  final String text;
  final DateTime? createdAt;

  factory ChatMessage.fromFirestore(
    DocumentSnapshot<Map<String, dynamic>> doc,
  ) {
    final data = doc.data()!;

    final timestamp = data['createdAt'] as Timestamp?;

    return ChatMessage(
      id: doc.id,
      senderId: data['senderId'] as String,
      text: data['text'] as String,
      createdAt: timestamp?.toDate(),
    );
  }
}

The timestamp is nullable because a newly written document can briefly contain a server-timestamp placeholder before the server confirms the final timestamp.

6. Build the Chat Repository

The repository encapsulates Firestore operations. This keeps widgets focused on presentation and gives you a clear place to add validation, pagination, logging, and tests.

Create lib/repositories/chat_repository.dart:

import 'package:cloud_firestore/cloud_firestore.dart';

import '../models/chat_message.dart';

class ChatRepository {
  ChatRepository(this._firestore);

  final FirebaseFirestore _firestore;

  CollectionReference<Map<String, dynamic>>
      _messages(String chatId) {
    return _firestore
        .collection('chats')
        .doc(chatId)
        .collection('messages');
  }

  Stream<List<ChatMessage>> watchMessages(
    String chatId,
  ) {
    return _messages(chatId)
        .orderBy('createdAt', descending: true)
        .limit(50)
        .snapshots()
        .map((snapshot) {
      return snapshot.docs
          .map(ChatMessage.fromFirestore)
          .toList();
    });
  }

  Future<void> sendMessage({
    required String chatId,
    required String senderId,
    required String text,
  }) async {
    final messageText = text.trim();

    if (messageText.isEmpty) {
      return;
    }

    if (messageText.length > 2000) {
      throw ArgumentError(
        'Messages cannot exceed 2000 characters.',
      );
    }

    await _messages(chatId).add({
      'senderId': senderId,
      'text': messageText,
      'createdAt': FieldValue.serverTimestamp(),
    });
  }
}

Why use a Firestore stream?

The snapshots() method returns a stream. The first event contains the current query results, and later events reflect matching changes. This removes the need to poll the database repeatedly for new messages.

The query orders by timestamp and limits the initial result to 50 messages. A complete pagination implementation should fetch older pages with a cursor rather than loading the entire conversation at once.

Official reference: Listen to real-time updates in Cloud Firestore.

7. Build a Professional Chat Screen

Our screen will show real-time messages, distinguish incoming and outgoing messages, display timestamps, and provide a message composer. It assumes the user has already signed in and the conversation document exists.

Create lib/screens/chat_screen.dart:

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';

import '../models/chat_message.dart';
import '../repositories/chat_repository.dart';

class ChatScreen extends StatefulWidget {
  const ChatScreen({
    super.key,
    required this.chatId,
    required this.otherUserName,
  });

  final String chatId;
  final String otherUserName;

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final _controller = TextEditingController();
  final _scrollController = ScrollController();

  late final ChatRepository _repository;

  bool _sending = false;

  String get _uid =>
      FirebaseAuth.instance.currentUser!.uid;

  @override
  void initState() {
    super.initState();

    _repository = ChatRepository(
      FirebaseFirestore.instance,
    );
  }

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

  Future<void> _sendMessage() async {
    final text = _controller.text.trim();

    if (text.isEmpty || _sending) return;

    if (text.length > 2000) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text(
            'Messages cannot exceed 2000 characters.',
          ),
        ),
      );
      return;
    }

    setState(() => _sending = true);
    _controller.clear();

    try {
      await _repository.sendMessage(
        chatId: widget.chatId,
        senderId: _uid,
        text: text,
      );
    } catch (_) {
      if (!mounted) return;

      _controller.text = text;

      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text(
            'Message could not be sent. Please try again.',
          ),
        ),
      );
    } finally {
      if (mounted) {
        setState(() => _sending = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFF5F7FB),
      appBar: AppBar(
        backgroundColor: Colors.white,
        surfaceTintColor: Colors.white,
        title: Row(
          children: [
            CircleAvatar(
              backgroundColor: const Color(0xFFE1E7FF),
              child: Text(
                widget.otherUserName.isNotEmpty
                    ? widget.otherUserName[0].toUpperCase()
                    : '?',
                style: const TextStyle(
                  color: Color(0xFF4053B8),
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                widget.otherUserName,
                style: const TextStyle(
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
          ],
        ),
      ),
      body: Column(
        children: [
          Expanded(
            child: StreamBuilder<List<ChatMessage>>(
              stream: _repository.watchMessages(
                widget.chatId,
              ),
              builder: (context, snapshot) {
                if (snapshot.hasError) {
                  return const Center(
                    child: Padding(
                      padding: EdgeInsets.all(24),
                      child: Text(
                        'Unable to load messages. '
                        'Check your connection and try again.',
                        textAlign: TextAlign.center,
                      ),
                    ),
                  );
                }

                if (snapshot.connectionState ==
                        ConnectionState.waiting &&
                    !snapshot.hasData) {
                  return const Center(
                    child: CircularProgressIndicator(),
                  );
                }

                final messages =
                    snapshot.data ?? [];

                if (messages.isEmpty) {
                  return const Center(
                    child: Text(
                      'No messages yet. Say hello!',
                    ),
                  );
                }

                return ListView.builder(
                  controller: _scrollController,
                  reverse: true,
                  padding: const EdgeInsets.symmetric(
                    horizontal: 16,
                    vertical: 20,
                  ),
                  itemCount: messages.length,
                  itemBuilder: (context, index) {
                    final message = messages[index];
                    final isMine =
                        message.senderId == _uid;

                    return _MessageBubble(
                      message: message,
                      isMine: isMine,
                    );
                  },
                );
              },
            ),
          ),
          SafeArea(
            top: false,
            child: Container(
              color: Colors.white,
              padding: const EdgeInsets.fromLTRB(
                12, 10, 12, 10,
              ),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.end,
                children: [
                  Expanded(
                    child: TextField(
                      controller: _controller,
                      minLines: 1,
                      maxLines: 5,
                      maxLength: 2000,
                      textCapitalization:
                          TextCapitalization.sentences,
                      decoration: InputDecoration(
                        hintText: 'Write a message...',
                        counterText: '',
                        filled: true,
                        fillColor: const Color(0xFFF1F3F8),
                        contentPadding:
                            const EdgeInsets.symmetric(
                          horizontal: 16,
                          vertical: 12,
                        ),
                        border: OutlineInputBorder(
                          borderRadius:
                              BorderRadius.circular(24),
                          borderSide: BorderSide.none,
                        ),
                      ),
                      onSubmitted: (_) => _sendMessage(),
                    ),
                  ),
                  const SizedBox(width: 8),
                  IconButton.filled(
                    onPressed:
                        _sending ? null : _sendMessage,
                    icon: _sending
                        ? const SizedBox(
                            width: 18,
                            height: 18,
                            child: CircularProgressIndicator(
                              strokeWidth: 2,
                            ),
                          )
                        : const Icon(Icons.send_rounded),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _MessageBubble extends StatelessWidget {
  const _MessageBubble({
    required this.message,
    required this.isMine,
  });

  final ChatMessage message;
  final bool isMine;

  @override
  Widget build(BuildContext context) {
    final bubbleColor = isMine
        ? const Color(0xFF4659C9)
        : Colors.white;

    final textColor =
        isMine ? Colors.white : const Color(0xFF263238);

    final time = message.createdAt;

    final timeLabel = time == null
        ? 'Sending...'
        : '${time.toLocal().hour.toString().padLeft(2, '0')}:'
          '${time.toLocal().minute.toString().padLeft(2, '0')}';

    return Align(
      alignment: isMine
          ? Alignment.centerRight
          : Alignment.centerLeft,
      child: Container(
        constraints: BoxConstraints(
          maxWidth:
              MediaQuery.sizeOf(context).width * 0.78,
        ),
        margin: const EdgeInsets.only(bottom: 12),
        padding: const EdgeInsets.symmetric(
          horizontal: 14,
          vertical: 10,
        ),
        decoration: BoxDecoration(
          color: bubbleColor,
          borderRadius: BorderRadius.only(
            topLeft: const Radius.circular(16),
            topRight: const Radius.circular(16),
            bottomLeft: Radius.circular(
              isMine ? 16 : 4,
            ),
            bottomRight: Radius.circular(
              isMine ? 4 : 16,
            ),
          ),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withValues(alpha: 0.04),
              blurRadius: 5,
              offset: const Offset(0, 2),
            ),
          ],
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            Text(
              message.text,
              style: TextStyle(
                color: textColor,
                fontSize: 15,
                height: 1.4,
              ),
            ),
            const SizedBox(height: 4),
            Text(
              timeLabel,
              style: TextStyle(
                color: textColor.withValues(alpha: 0.75),
                fontSize: 10,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

How to open the screen: Navigate to ChatScreen(chatId: 'chat_abc123', otherUserName: 'Alex') after authentication, using a real conversation ID obtained from your app's conversation flow. The example assumes the chat document has already been created and contains both participants.

Version note: This sample uses Color.withValues(), available in modern Flutter SDKs. If you target an older SDK, use the color-opacity API supported by that SDK.

8. Secure Firestore with Security Rules

Security rules are essential. Hiding a chat screen in Flutter is not sufficient to protect the underlying data. The database must independently verify that the authenticated user belongs to the conversation.

For the schema used in this tutorial, the following is a starting point for authenticated, two-participant conversations. It permits reading chat data for participants and creating messages only when the sender ID matches the authenticated user.

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {

    function isParticipant(chatId) {
      return request.auth != null
        && request.auth.uid in
          get(
            /databases/$(database)/documents/chats/$(chatId)
          ).data.participants;
    }

    match /chats/{chatId} {
      allow read: if request.auth != null
        && request.auth.uid in resource.data.participants;

      // Chat creation is handled by a trusted,
      // separately secured conversation-creation flow.
      allow create, update, delete: if false;

      match /messages/{messageId} {
        allow read: if isParticipant(chatId);

        allow create: if isParticipant(chatId)
          && request.resource.data.keys().hasAll([
            'senderId', 'text', 'createdAt'
          ])
          && request.resource.data.keys().hasOnly([
            'senderId', 'text', 'createdAt'
          ])
          && request.resource.data.senderId
              == request.auth.uid
          && request.resource.data.text is string
          && request.resource.data.text.size() > 0
          && request.resource.data.text.size() <= 2000
          && request.resource.data.createdAt
              == request.time;

        allow update, delete: if false;
      }
    }
  }
}
Important security limitation

These rules deliberately prevent client-side chat creation, updates, and deletion. Your application must provision conversations through a trusted backend or a separately designed and tested rule flow. Do not launch with permissive test rules. Validate all rules in the Firebase Emulator Suite before deploying them.

For group chats, blocking, message edits, moderation, attachments, and read receipts, expand the data model and rules to cover those specific operations. Avoid assuming that basic participant membership is sufficient for every feature.

Official reference: Cloud Firestore Security Rules.

9. Pagination and Scalability

Real applications can contain thousands of messages per conversation. Listening to an unlimited message collection increases bandwidth, memory usage, and read costs.

Recommended message loading strategy
  1. Listen to a limited window of recent messages.
  2. Fetch older messages when the user scrolls upward.
  3. Use document cursors such as startAfterDocument() for pagination.
  4. Deduplicate messages when combining historical pages with live updates.
  5. Keep the conversation summary separate from the message history.

The repository above demonstrates the first step by limiting the live query to 50 messages. It is not a complete pagination implementation. When adding older pages, design the ordering, cursor handling, and listener window together to avoid duplicates or missing messages.

10. Testing and Debugging

Before publishing your application, test both successful and unsuccessful flows.

  • Authentication: Signed-out users cannot access conversations.
  • Live updates: A message sent by one account appears in the other account's chat.
  • Authorization: A user outside a conversation cannot read or write its messages.
  • Network interruption: The UI handles temporary failures without crashing.
  • Input validation: Empty messages and overlong messages are rejected.
  • Ordering: Messages appear in the intended chronological order.
  • Pagination: Older messages load without duplicates.

Use the Firebase Local Emulator Suite to test authentication and Firestore behavior without risking production data. Test security rules explicitly, including requests from nonparticipants.

11. Production Readiness Checklist

☐ Firebase Authentication and account recovery configured
☐ Chat creation and participant membership securely validated
☐ Firestore rules tested against unauthorized access
☐ Pagination and read costs reviewed
☐ Error states, retry behavior, and accessibility tested
☐ App Check and abuse protections evaluated
☐ Monitoring, budget alerts, and production configuration checked
☐ Privacy policy, data retention, and account deletion reviewed

For sensitive conversations, remember that Firebase Authentication and Firestore Security Rules do not automatically provide end-to-end encryption. If end-to-end encryption is a requirement, design and review the cryptographic protocol separately.

12. Frequently Asked Questions

Can Flutter and Firebase support real-time messaging?

Yes. Cloud Firestore snapshot streams let Flutter applications observe changes to matching documents and queries. This is a common approach for real-time messaging.

Should I use Cloud Firestore or Firebase Realtime Database?

Both can support real-time features. Firestore offers document-oriented data modeling and flexible queries, while Realtime Database uses a JSON tree. Choose based on your query patterns, presence requirements, pricing, and architecture.

Does Firebase send push notifications automatically for new chat messages?

No. Real-time Firestore listeners update connected clients, but push notifications require an additional implementation, typically using Firebase Cloud Messaging and a trusted backend such as Cloud Functions.

How can I show online status and typing indicators?

These features need additional state and cleanup logic. Firebase Realtime Database is commonly used for presence because it supports connection-state behavior. Typing indicators should expire or be cleared so stale status does not remain indefinitely.

Can I add images, files, and voice messages?

Yes. Store the media files in Firebase Storage and save metadata and authorized download references in Firestore. Configure Storage Security Rules, file size limits, and content validation before enabling uploads.

Is the sample code a complete production chat application?

No. It demonstrates the core real-time message flow and a basic interface. A production app also needs secure conversation provisioning, authentication screens, pagination, user discovery, push notifications if required, moderation, and operational monitoring.

Conclusion

Flutter and Firebase provide a strong foundation for building real-time messaging applications. With Firebase Authentication, Cloud Firestore streams, a clean repository layer, and carefully designed security rules, developers can create responsive chat experiences without operating their own messaging infrastructure.

Start with authenticated one-to-one conversations, then expand incrementally into pagination, push notifications, media attachments, typing indicators, and read receipts. Treat security rules and access-control tests as core features, not optional finishing steps.

Keep Building with Flutter

Explore the official documentation to deepen your understanding of Firebase, Flutter architecture, and real-time application development.

Explore Firebase for Flutter

Official References

This tutorial is educational and provides a starting architecture. Verify package versions, security rules, billing, and platform requirements before using it in a production application.

Comments