
Introduction to Firebase Firestore in Flutter
Firebase Firestore is a flexible, scalable NoSQL cloud database for mobile, web, and server development. In this comprehensive guide, you’ll learn how to integrate Firebase Firestore into your Flutter project – from initial setup to production-ready patterns including type-safe models, repository patterns, and real-time data synchronization.
Whether you’re building a to-do app or a full-blown chat system, Firestore provides powerful real-time capabilities with offline support out of the box.
Step 1: Create a Firebase Project
Start by setting up your Firebase project:
- Go to Firebase Console
- Click Add project and follow the setup wizard
- Once created, click Add app and select Flutter
The Firebase CLI simplifies setup significantly:
# Install Firebase CLI
npm install -g firebase-tools
# Login to Firebase
firebase login
# Install FlutterFire CLI
dart pub global activate flutterfire_cli
# Configure your Flutter project
flutterfire configure
This automatically generates the firebase_options.dart file with your project configuration.
Step 2: Add Firebase Dependencies
Add the required packages to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
firebase_core: ^2.24.2
cloud_firestore: ^4.13.6
firebase_auth: ^4.16.0 # Often used with Firestore
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1
dev_dependencies:
build_runner: ^2.4.7
freezed: ^2.4.5
json_serializable: ^6.7.1
Initialize Firebase in your main.dart:
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
Type-Safe Models with Freezed
Define type-safe models that work seamlessly with Firestore:
// lib/models/user_model.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_model.freezed.dart';
part 'user_model.g.dart';
@freezed
class UserModel with _$UserModel {
const UserModel._();
const factory UserModel({
required String id,
required String email,
required String name,
String? photoUrl,
@Default([]) List<String> roles,
@TimestampConverter() required DateTime createdAt,
@TimestampConverter() DateTime? updatedAt,
}) = _UserModel;
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
factory UserModel.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return UserModel.fromJson({
'id': doc.id,
...data,
});
}
Map<String, dynamic> toFirestore() {
final json = toJson();
json.remove('id'); // Don't store ID in document data
return json;
}
}
// Custom converter for Firestore Timestamps
class TimestampConverter implements JsonConverter<DateTime, dynamic> {
const TimestampConverter();
@override
DateTime fromJson(dynamic value) {
if (value is Timestamp) {
return value.toDate();
}
if (value is String) {
return DateTime.parse(value);
}
throw ArgumentError('Invalid timestamp value: $value');
}
@override
dynamic toJson(DateTime date) => Timestamp.fromDate(date);
}
// lib/models/task_model.dart
@freezed
class TaskModel with _$TaskModel {
const TaskModel._();
const factory TaskModel({
required String id,
required String userId,
required String title,
String? description,
@Default(false) bool completed,
@Default(TaskPriority.medium) TaskPriority priority,
DateTime? dueDate,
@TimestampConverter() required DateTime createdAt,
}) = _TaskModel;
factory TaskModel.fromJson(Map<String, dynamic> json) =>
_$TaskModelFromJson(json);
factory TaskModel.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return TaskModel.fromJson({
'id': doc.id,
...data,
});
}
Map<String, dynamic> toFirestore() {
final json = toJson();
json.remove('id');
return json;
}
bool get isOverdue =>
dueDate != null && !completed && dueDate!.isBefore(DateTime.now());
}
enum TaskPriority { low, medium, high, urgent }
Run build_runner to generate the serialization code:
dart run build_runner build --delete-conflicting-outputs
Repository Pattern for Clean Architecture
Implement a repository pattern to abstract Firestore operations:
// lib/repositories/base_repository.dart
import 'package:cloud_firestore/cloud_firestore.dart';
abstract class BaseRepository<T> {
final FirebaseFirestore _firestore;
final String collectionPath;
BaseRepository(this._firestore, this.collectionPath);
CollectionReference<Map<String, dynamic>> get collection =>
_firestore.collection(collectionPath);
T fromFirestore(DocumentSnapshot doc);
Map<String, dynamic> toFirestore(T item);
Future<T?> getById(String id) async {
final doc = await collection.doc(id).get();
if (!doc.exists) return null;
return fromFirestore(doc);
}
Future<List<T>> getAll() async {
final snapshot = await collection.get();
return snapshot.docs.map(fromFirestore).toList();
}
Stream<List<T>> watchAll() {
return collection.snapshots().map(
(snapshot) => snapshot.docs.map(fromFirestore).toList(),
);
}
Stream<T?> watchById(String id) {
return collection.doc(id).snapshots().map(
(doc) => doc.exists ? fromFirestore(doc) : null,
);
}
Future<String> create(T item, {String? id}) async {
final data = toFirestore(item);
if (id != null) {
await collection.doc(id).set(data);
return id;
}
final doc = await collection.add(data);
return doc.id;
}
Future<void> update(String id, Map<String, dynamic> data) async {
await collection.doc(id).update(data);
}
Future<void> delete(String id) async {
await collection.doc(id).delete();
}
}
// lib/repositories/task_repository.dart
class TaskRepository extends BaseRepository<TaskModel> {
TaskRepository(FirebaseFirestore firestore) : super(firestore, 'tasks');
@override
TaskModel fromFirestore(DocumentSnapshot doc) => TaskModel.fromFirestore(doc);
@override
Map<String, dynamic> toFirestore(TaskModel item) => item.toFirestore();
// User-specific queries
Stream<List<TaskModel>> watchUserTasks(String userId) {
return collection
.where('userId', isEqualTo: userId)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) => snapshot.docs.map(fromFirestore).toList());
}
Stream<List<TaskModel>> watchUserIncompleteTasks(String userId) {
return collection
.where('userId', isEqualTo: userId)
.where('completed', isEqualTo: false)
.orderBy('dueDate')
.snapshots()
.map((snapshot) => snapshot.docs.map(fromFirestore).toList());
}
Stream<List<TaskModel>> watchTasksByPriority(String userId, TaskPriority priority) {
return collection
.where('userId', isEqualTo: userId)
.where('priority', isEqualTo: priority.name)
.where('completed', isEqualTo: false)
.snapshots()
.map((snapshot) => snapshot.docs.map(fromFirestore).toList());
}
Future<void> toggleComplete(String taskId) async {
final doc = await collection.doc(taskId).get();
if (doc.exists) {
final current = doc.data()?['completed'] as bool? ?? false;
await collection.doc(taskId).update({'completed': !current});
}
}
Future<void> updatePriority(String taskId, TaskPriority priority) async {
await collection.doc(taskId).update({'priority': priority.name});
}
// Batch operations
Future<void> markAllComplete(String userId) async {
final batch = FirebaseFirestore.instance.batch();
final tasks = await collection
.where('userId', isEqualTo: userId)
.where('completed', isEqualTo: false)
.get();
for (final doc in tasks.docs) {
batch.update(doc.reference, {'completed': true});
}
await batch.commit();
}
Future<void> deleteCompletedTasks(String userId) async {
final batch = FirebaseFirestore.instance.batch();
final tasks = await collection
.where('userId', isEqualTo: userId)
.where('completed', isEqualTo: true)
.get();
for (final doc in tasks.docs) {
batch.delete(doc.reference);
}
await batch.commit();
}
}
Riverpod Integration
Integrate the repository with Riverpod for reactive state management:
// lib/providers/firestore_providers.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../repositories/task_repository.dart';
import '../models/task_model.dart';
// Firestore instance
final firestoreProvider = Provider((ref) => FirebaseFirestore.instance);
// Repository providers
final taskRepositoryProvider = Provider((ref) {
return TaskRepository(ref.read(firestoreProvider));
});
// Current user ID (from your auth provider)
final currentUserIdProvider = Provider<String?>((ref) {
// Get from your auth state
return ref.watch(authProvider).user?.id;
});
// Stream of user's tasks
final userTasksProvider = StreamProvider<List<TaskModel>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return Stream.value([]);
return ref.read(taskRepositoryProvider).watchUserTasks(userId);
});
// Stream of incomplete tasks
final incompleteTasksProvider = StreamProvider<List<TaskModel>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return Stream.value([]);
return ref.read(taskRepositoryProvider).watchUserIncompleteTasks(userId);
});
// Tasks filtered by priority
final tasksByPriorityProvider =
StreamProvider.family<List<TaskModel>, TaskPriority>((ref, priority) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return Stream.value([]);
return ref.read(taskRepositoryProvider).watchTasksByPriority(userId, priority);
});
// Task actions notifier
@riverpod
class TaskActions extends _$TaskActions {
@override
FutureOr<void> build() {}
Future<void> createTask({
required String title,
String? description,
TaskPriority priority = TaskPriority.medium,
DateTime? dueDate,
}) async {
final userId = ref.read(currentUserIdProvider);
if (userId == null) throw Exception('Not authenticated');
final task = TaskModel(
id: '', // Will be set by Firestore
userId: userId,
title: title,
description: description,
priority: priority,
dueDate: dueDate,
createdAt: DateTime.now(),
);
await ref.read(taskRepositoryProvider).create(task);
}
Future<void> toggleComplete(String taskId) async {
await ref.read(taskRepositoryProvider).toggleComplete(taskId);
}
Future<void> deleteTask(String taskId) async {
await ref.read(taskRepositoryProvider).delete(taskId);
}
Future<void> updateTask(String taskId, {
String? title,
String? description,
TaskPriority? priority,
DateTime? dueDate,
}) async {
final updates = <String, dynamic>{};
if (title != null) updates['title'] = title;
if (description != null) updates['description'] = description;
if (priority != null) updates['priority'] = priority.name;
if (dueDate != null) updates['dueDate'] = Timestamp.fromDate(dueDate);
await ref.read(taskRepositoryProvider).update(taskId, updates);
}
}
UI Implementation
Build the UI with real-time updates:
// lib/screens/tasks_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/firestore_providers.dart';
import '../models/task_model.dart';
class TasksScreen extends ConsumerWidget {
const TasksScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(userTasksProvider);
return Scaffold(
appBar: AppBar(
title: const Text('My Tasks'),
actions: [
PopupMenuButton<String>(
onSelected: (value) async {
if (value == 'mark_all') {
final userId = ref.read(currentUserIdProvider);
if (userId != null) {
await ref.read(taskRepositoryProvider).markAllComplete(userId);
}
} else if (value == 'delete_completed') {
final userId = ref.read(currentUserIdProvider);
if (userId != null) {
await ref.read(taskRepositoryProvider).deleteCompletedTasks(userId);
}
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'mark_all',
child: Text('Mark all complete'),
),
const PopupMenuItem(
value: 'delete_completed',
child: Text('Delete completed'),
),
],
),
],
),
body: tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.invalidate(userTasksProvider),
child: const Text('Retry'),
),
],
),
),
data: (tasks) {
if (tasks.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.task_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No tasks yet'),
Text('Tap + to create your first task'),
],
),
);
}
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) => TaskTile(task: tasks[index]),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showCreateTaskDialog(context, ref),
child: const Icon(Icons.add),
),
);
}
void _showCreateTaskDialog(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const CreateTaskSheet(),
);
}
}
class TaskTile extends ConsumerWidget {
final TaskModel task;
const TaskTile({required this.task, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Dismissible(
key: Key(task.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (_) {
ref.read(taskActionsProvider.notifier).deleteTask(task.id);
},
child: ListTile(
leading: Checkbox(
value: task.completed,
onChanged: (_) {
ref.read(taskActionsProvider.notifier).toggleComplete(task.id);
},
),
title: Text(
task.title,
style: TextStyle(
decoration: task.completed ? TextDecoration.lineThrough : null,
color: task.completed ? Colors.grey : null,
),
),
subtitle: task.description != null
? Text(
task.description!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildPriorityIndicator(task.priority),
if (task.isOverdue)
const Padding(
padding: EdgeInsets.only(left: 8),
child: Icon(Icons.warning, color: Colors.orange, size: 20),
),
],
),
onTap: () => _showTaskDetails(context, ref),
),
);
}
Widget _buildPriorityIndicator(TaskPriority priority) {
final color = switch (priority) {
TaskPriority.low => Colors.green,
TaskPriority.medium => Colors.blue,
TaskPriority.high => Colors.orange,
TaskPriority.urgent => Colors.red,
};
return Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
);
}
void _showTaskDetails(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
builder: (context) => TaskDetailsSheet(task: task),
);
}
}
class CreateTaskSheet extends ConsumerStatefulWidget {
const CreateTaskSheet({super.key});
@override
ConsumerState<CreateTaskSheet> createState() => _CreateTaskSheetState();
}
class _CreateTaskSheetState extends ConsumerState<CreateTaskSheet> {
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
TaskPriority _priority = TaskPriority.medium;
DateTime? _dueDate;
bool _isLoading = false;
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'New Task',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
autofocus: true,
),
const SizedBox(height: 12),
TextField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 12),
DropdownButtonFormField<TaskPriority>(
value: _priority,
decoration: const InputDecoration(
labelText: 'Priority',
border: OutlineInputBorder(),
),
items: TaskPriority.values.map((p) {
return DropdownMenuItem(value: p, child: Text(p.name));
}).toList(),
onChanged: (value) {
if (value != null) setState(() => _priority = value);
},
),
const SizedBox(height: 12),
ListTile(
title: Text(_dueDate == null
? 'Set due date'
: 'Due: ${_dueDate!.toString().split(' ')[0]}'),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (date != null) setState(() => _dueDate = date);
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _createTask,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create Task'),
),
const SizedBox(height: 16),
],
),
);
}
Future<void> _createTask() async {
if (_titleController.text.trim().isEmpty) return;
setState(() => _isLoading = true);
try {
await ref.read(taskActionsProvider.notifier).createTask(
title: _titleController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
priority: _priority,
dueDate: _dueDate,
);
if (mounted) Navigator.pop(context);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}
Firestore Security Rules
Secure your data with proper rules:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// User documents
match /users/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
allow create: if request.auth != null && request.auth.uid == userId;
allow update: if request.auth != null && request.auth.uid == userId;
allow delete: if false; // Users can't delete their own profile
}
// Task documents
match /tasks/{taskId} {
allow read: if request.auth != null &&
resource.data.userId == request.auth.uid;
allow create: if request.auth != null &&
request.resource.data.userId == request.auth.uid &&
request.resource.data.title is string &&
request.resource.data.title.size() > 0 &&
request.resource.data.title.size() <= 200;
allow update: if request.auth != null &&
resource.data.userId == request.auth.uid &&
request.resource.data.userId == resource.data.userId;
allow delete: if request.auth != null &&
resource.data.userId == request.auth.uid;
}
}
}
Common Mistakes to Avoid
1. Not creating indexes for compound queries
// This query requires a composite index
collection
.where('userId', isEqualTo: userId)
.where('completed', isEqualTo: false)
.orderBy('dueDate')
.get();
// Check Firestore console for index creation links in error messages
2. Not handling offline data
// Enable offline persistence (enabled by default on mobile)
FirebaseFirestore.instance.settings = const Settings(
persistenceEnabled: true,
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);
3. Forgetting to dispose stream subscriptions
// Wrong: Memory leak
@override
void initState() {
super.initState();
FirebaseFirestore.instance.collection('tasks').snapshots().listen((snapshot) {
// Handle data
});
}
// Correct: Cancel subscription
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_subscription = FirebaseFirestore.instance
.collection('tasks')
.snapshots()
.listen((snapshot) {
// Handle data
});
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
4. Not using batched writes for multiple operations
// Wrong: Multiple network calls
for (final task in tasks) {
await collection.doc(task.id).update({'completed': true});
}
// Correct: Single batched write
final batch = FirebaseFirestore.instance.batch();
for (final task in tasks) {
batch.update(collection.doc(task.id), {'completed': true});
}
await batch.commit();
Related
- Setting Up a Firebase Project – A Step-by-Step Guide
- Why I Use Riverpod (Not Provider) in 2025
- Flutter Freezed: Immutable Data Classes and Union Types
Final Thoughts
Firebase Firestore provides a powerful, real-time database that scales effortlessly with your Flutter application. By combining type-safe models with Freezed, implementing the repository pattern for clean architecture, and integrating with Riverpod for reactive state management, you can build production-ready applications with minimal boilerplate.
Remember to secure your data with proper Firestore rules, create indexes for complex queries, and leverage offline persistence for a seamless user experience regardless of network connectivity.
3 Comments