Flutter Firebase Integration: Auth, Firestore, and Push Notifications
# Flutter Firebase Integration: Auth, Firestore, and Push Notifications
Firebase can dramatically speed up product delivery when configured correctly. Over the years, I have shipped multiple Flutter apps backed by Firebase, and each project taught me something new about what works at scale and what falls apart under pressure. This article covers the practical side of integrating Firebase into a Flutter project, from authentication flows to Firestore data modeling, push notifications, and crash reporting, along with the hard-won lessons that only show up in production.
Project Setup with FlutterFire CLI
Before writing any Firebase code, you need a clean project setup. The FlutterFire CLI eliminates the manual google-services.json dance entirely:
class=class="code-string">"code-comment">// Install FlutterFire CLI globally
class=class="code-string">"code-comment">// dart pub global activate flutterfire_cli
class=class="code-string">"code-comment">// Then in your project root:
class=class="code-string">"code-comment">// flutterfire configure --project=your-firebase-project-id
class=class="code-string">"code-comment">// In main.dart
import class="code-string">'package:firebase_core/firebase_core.dart';
import class="code-string">'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}In my Firebase-powered apps, I always keep separate Firebase projects for development and production. Mixing environments leads to corrupted analytics, test push notifications reaching real users, and security rules that are too permissive because "we needed it for testing."
Firebase Authentication
Email and Password Auth
The most common starting point. Here is a full registration and login flow with proper error handling:
class AuthService {
final FirebaseAuth _auth = FirebaseAuth.instance;
class=class="code-string">"code-comment">// Register
Future<UserCredential?> register(String email, String password) async {
try {
final credential = await _auth.createUserWithEmailAndPassword(
email: email,
password: password,
);
class=class="code-string">"code-comment">// Send email verification
await credential.user?.sendEmailVerification();
return credential;
} on FirebaseAuthException catch (e) {
switch (e.code) {
case class="code-string">'email-already-in-use':
throw AuthError(class="code-string">'This email is already registered.');
case class="code-string">'weak-password':
throw AuthError(class="code-string">'Password must be at least class="code-number">6 characters.');
case class="code-string">'invalid-email':
throw AuthError(class="code-string">'Please enter a valid email address.');
default:
throw AuthError(class="code-string">'Registration failed. Please try again.');
}
}
}
class=class="code-string">"code-comment">// Login
Future<UserCredential?> login(String email, String password) async {
try {
return await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
} on FirebaseAuthException catch (e) {
switch (e.code) {
case class="code-string">'user-not-found':
case class="code-string">'wrong-password':
throw AuthError(class="code-string">'Invalid email or password.');
case class="code-string">'user-disabled':
throw AuthError(class="code-string">'This account has been disabled.');
default:
throw AuthError(class="code-string">'Login failed. Please try again.');
}
}
}
class=class="code-string">"code-comment">// Auth state stream for reactive UI
Stream<User?> get authStateChanges => _auth.authStateChanges();
class=class="code-string">"code-comment">// Sign out
Future<void> signOut() => _auth.signOut();
}Google Sign-In
Future<UserCredential?> signInWithGoogle() async {
final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn();
if (googleUser == null) return null; class=class="code-string">"code-comment">// User cancelled
final GoogleSignInAuthentication googleAuth =
await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
return await FirebaseAuth.instance.signInWithCredential(credential);
}Creating a User Profile After Registration
In my Firebase-powered apps, I always create a Firestore user document immediately after registration. Relying solely on FirebaseAuth user records is limiting; you will inevitably need custom fields like roles, preferences, or subscription status:
Future<void> createUserProfile(User user) async {
final docRef = FirebaseFirestore.instance.collection(class="code-string">'users').doc(user.uid);
final doc = await docRef.get();
if (!doc.exists) {
await docRef.set({
class="code-string">'email': user.email,
class="code-string">'displayName': user.displayName ?? class="code-string">'',
class="code-string">'photoUrl': user.photoURL ?? class="code-string">'',
class="code-string">'role': class="code-string">'user',
class="code-string">'createdAt': FieldValue.serverTimestamp(),
class="code-string">'lastLoginAt': FieldValue.serverTimestamp(),
});
} else {
await docRef.update({
class="code-string">'lastLoginAt': FieldValue.serverTimestamp(),
});
}
}Cloud Firestore
Data Modeling and CRUD Operations
Firestore is a document database, so you need to think in terms of collections and documents rather than tables and rows. Here is a type-safe approach using model classes:
class Task {
final String id;
final String title;
final String description;
final bool isCompleted;
final DateTime createdAt;
final String userId;
Task({
required this.id,
required this.title,
required this.description,
required this.isCompleted,
required this.createdAt,
required this.userId,
});
factory Task.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return Task(
id: doc.id,
title: data[class="code-string">'title'] ?? class="code-string">'',
description: data[class="code-string">'description'] ?? class="code-string">'',
isCompleted: data[class="code-string">'isCompleted'] ?? false,
createdAt: (data[class="code-string">'createdAt'] as Timestamp).toDate(),
userId: data[class="code-string">'userId'] ?? class="code-string">'',
);
}
Map<String, dynamic> toFirestore() => {
class="code-string">'title': title,
class="code-string">'description': description,
class="code-string">'isCompleted': isCompleted,
class="code-string">'createdAt': Timestamp.fromDate(createdAt),
class="code-string">'userId': userId,
};
}Repository Pattern for Firestore
class TaskRepository {
final _collection = FirebaseFirestore.instance.collection(class="code-string">'tasks');
class=class="code-string">"code-comment">// Create
Future<String> createTask(Task task) async {
final docRef = await _collection.add(task.toFirestore());
return docRef.id;
}
class=class="code-string">"code-comment">// Read (real-time stream)
Stream<List<Task>> watchUserTasks(String userId) {
return _collection
.where(class="code-string">'userId', isEqualTo: userId)
.orderBy(class="code-string">'createdAt', descending: true)
.snapshots()
.map((snapshot) =>
snapshot.docs.map((doc) => Task.fromFirestore(doc)).toList());
}
class=class="code-string">"code-comment">// Update
Future<void> updateTask(String taskId, Map<String, dynamic> updates) {
return _collection.doc(taskId).update(updates);
}
class=class="code-string">"code-comment">// Delete
Future<void> deleteTask(String taskId) {
return _collection.doc(taskId).delete();
}
class=class="code-string">"code-comment">// Batch operations for bulk updates
Future<void> completeAllTasks(String userId) async {
final batch = FirebaseFirestore.instance.batch();
final tasks = await _collection
.where(class="code-string">'userId', isEqualTo: userId)
.where(class="code-string">'isCompleted', isEqualTo: false)
.get();
for (final doc in tasks.docs) {
batch.update(doc.reference, {class="code-string">'isCompleted': true});
}
await batch.commit();
}
}Offline Support
Firestore has built-in offline persistence on mobile, but you need to handle it consciously:
class=class="code-string">"code-comment">// Enable offline persistence with cache size limit
FirebaseFirestore.instance.settings = const Settings(
persistenceEnabled: true,
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);
class=class="code-string">"code-comment">// Check if data comes from cache or server
stream.listen((snapshot) {
for (final change in snapshot.docChanges) {
if (snapshot.metadata.isFromCache) {
class=class="code-string">"code-comment">// Data is from local cache, show indicator to user
}
}
});Firestore Security Rules
This is where most Firebase projects fail. Open security rules are the number one Firebase security mistake, and I have seen production apps with `allow read, write: if true;` rules that expose every user's data to anyone.
rules_version = class="code-string">'class="code-number">2';
service cloud.firestore {
match /databases/{database}/documents {
class=class="code-string">"code-comment">// User profiles: users can only read/write their own
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
&& !request.resource.data.diff(resource.data).affectedKeys()
.hasAny([class="code-string">'role', class="code-string">'createdAt']);
class=class="code-string">"code-comment">// Users cannot change their own role or creation date
}
class=class="code-string">"code-comment">// Tasks: users can only access their own tasks
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.keys().hasAll([class="code-string">'title', class="code-string">'userId', class="code-string">'createdAt']);
allow update: if request.auth != null
&& resource.data.userId == request.auth.uid;
allow delete: if request.auth != null
&& resource.data.userId == request.auth.uid;
}
class=class="code-string">"code-comment">// Admin-only collection
match /admin/{document=**} {
allow read, write: if request.auth != null
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == class="code-string">'admin';
}
}
}In my Firebase-powered apps, I always write security rules tests using the Firebase Emulator Suite before deploying. A broken rule can silently lock users out or silently expose data.
Firebase Cloud Messaging (FCM)
Setup and Token Management
class PushNotificationService {
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
Future<void> initialize() async {
class=class="code-string">"code-comment">// Request permission (required on iOS, good practice on Android class="code-number">13+)
final settings = await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: false,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
await _setupToken();
_configureForegroundHandler();
_configureBackgroundHandler();
}
}
Future<void> _setupToken() async {
class=class="code-string">"code-comment">// Get the token
final token = await _messaging.getToken();
if (token != null) {
await _saveTokenToFirestore(token);
}
class=class="code-string">"code-comment">// Listen for token refresh
_messaging.onTokenRefresh.listen(_saveTokenToFirestore);
}
Future<void> _saveTokenToFirestore(String token) async {
final user = FirebaseAuth.instance.currentUser;
if (user != null) {
await FirebaseFirestore.instance.collection(class="code-string">'users').doc(user.uid).update({
class="code-string">'fcmTokens': FieldValue.arrayUnion([token]),
class="code-string">'lastTokenUpdate': FieldValue.serverTimestamp(),
});
}
}
void _configureForegroundHandler() {
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
class=class="code-string">"code-comment">// Show local notification or in-app banner
if (message.notification != null) {
_showLocalNotification(message);
}
});
}
static Future<void> _configureBackgroundHandler() async {
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
}
}
class=class="code-string">"code-comment">// Must be a top-level function
@pragma(class="code-string">'vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
class=class="code-string">"code-comment">// Handle background message (keep it lightweight)
}Topic-Based Notifications
class=class="code-string">"code-comment">// Subscribe users to topics for targeted messaging
await FirebaseMessaging.instance.subscribeToTopic(class="code-string">'news');
await FirebaseMessaging.instance.subscribeToTopic(class="code-string">'promo_en');
class=class="code-string">"code-comment">// Unsubscribe
await FirebaseMessaging.instance.unsubscribeFromTopic(class="code-string">'promo_en');Crashlytics Integration
Crashlytics is non-negotiable for production apps. Here is the setup I use in every project:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
class=class="code-string">"code-comment">// Pass all uncaught Flutter errors to Crashlytics
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
class=class="code-string">"code-comment">// Pass all uncaught async errors to Crashlytics
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}
class=class="code-string">"code-comment">// Custom logging for non-fatal errors
void logError(dynamic error, StackTrace? stack, {String? reason}) {
FirebaseCrashlytics.instance.recordError(
error,
stack,
reason: reason ?? class="code-string">'Non-fatal error',
);
}
class=class="code-string">"code-comment">// Set user identifier for crash reports
void setUserForCrashReporting(String userId) {
FirebaseCrashlytics.instance.setUserIdentifier(userId);
FirebaseCrashlytics.instance.setCustomKey(class="code-string">'user_role', class="code-string">'premium');
}Common Firebase Mistakes
These are the mistakes I see most often in Firebase projects, including some I have made myself:
1. Open Security Rules
The default test-mode rules expire after 30 days, but many teams forget to replace them. Your entire database is publicly readable and writable. Always write proper rules before your first deploy.
2. No Offline Handling
Firestore operations queue silently when offline. If your app does not communicate this to the user, they may think their data was saved when it is still pending. Always check `snapshot.metadata.hasPendingWrites` and show appropriate UI.
3. Unbounded Queries
Fetching an entire collection without `.limit()` is a ticking time bomb. It works fine with 50 documents and crashes with 50,000. Always paginate:
class=class="code-string">"code-comment">// Paginated query
Query<Map<String, dynamic>> paginatedQuery(DocumentSnapshot? lastDoc) {
var query = _collection.orderBy(class="code-string">'createdAt').limit(class="code-number">20);
if (lastDoc != null) {
query = query.startAfterDocument(lastDoc);
}
return query;
}4. Storing Derived Data Client-Side Only
If you need a "task count" or "unread message count," do not compute it on every read. Use Cloud Functions to maintain counters as documents change. Client-side aggregation does not scale.
5. Ignoring Token Refresh for FCM
FCM tokens rotate without warning. If you save the token once at registration and never update it, push notifications will silently stop working for those users after weeks or months.
6. Not Using Transactions for Concurrent Writes
When multiple users or devices can modify the same document, you need transactions:
Future<void> incrementLikeCount(String postId) {
return FirebaseFirestore.instance.runTransaction((transaction) async {
final postRef = FirebaseFirestore.instance.collection(class="code-string">'posts').doc(postId);
final snapshot = await transaction.get(postRef);
final currentLikes = snapshot.data()?[class="code-string">'likes'] ?? class="code-number">0;
transaction.update(postRef, {class="code-string">'likes': currentLikes + class="code-number">1});
});
}Cost Optimization
Firebase pricing can surprise you if you are not paying attention. Here are the strategies I follow:
Read/Write Optimization
Storage and Bandwidth
Cloud Functions
Monitoring
In my Firebase-powered apps, I always set up billing alerts at 50%, 80%, and 100% of my expected monthly budget. The $0.06 per 100K reads sounds cheap until your app goes viral.
Production Checklist
Before launching a Firebase-backed Flutter app, run through this list:
Conclusion
Firebase is a powerful accelerator for Flutter apps, but it demands architectural discipline from the start. Authentication, Firestore data modeling, push notifications, and crash reporting are the core pillars, and each one has subtle pitfalls that only appear at scale. The difference between a Firebase prototype and a Firebase production app is security rules, offline handling, cost awareness, and proper monitoring.
Contact me for a production-readiness Firebase review.
Related Articles
Flutter Performance Optimization: Complete Guide
Improve your Flutter app performance systematically. Learn rebuild optimization, memory management, lazy loading, and profiling techniques.
Flutter API Integration: Reliable REST Service Architecture
Build reliable API integration in Flutter with repository pattern, error handling, and structured data modeling.
Flutter CI/CD: Automated Build, Test, and Release Pipeline
Design a robust Flutter CI/CD pipeline for reliable delivery with automated build, test, and release steps.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch