Build a Flutter app with Firebase
Build a Flutter task app with Firebase Authentication and Cloud Firestore. This practical guide covers architecture choices, working code, security rules, local testing, and production readiness.
What you will build—and why this stack fits
To build a flutter app with firebase, you need more than a database connection: you need an authentication flow, a defensible data model, tested access controls, and a release process. This MyDiscussions tutorial builds a small task manager that demonstrates those foundations using Flutter, Firebase Authentication, and Cloud Firestore.
The app lets a user start an anonymous session, create private tasks, view live updates, and mark tasks complete. Anonymous authentication keeps the example focused, but it is not a substitute for recoverable accounts in a production product.
For decision-makers, the main benefit is reduced backend infrastructure work. For practitioners, the challenge is understanding Firebase’s client-driven architecture: the application connects directly to managed services, and security rules—not hidden API keys—enforce access.
Decide whether Flutter and Firebase fit your product
Flutter provides a shared Dart codebase for supported mobile, web, and desktop targets. Firebase supplies managed services, although plugin support varies by platform and product.
This combination is particularly useful for mobile-first products with user accounts, document-oriented data, and real-time interfaces.
| Decision criterion | Flutter and Firebase fit | Consider an alternative when |
|---|---|---|
| Data structure | Tasks, profiles, messages, and other document-based records | Complex joins and relational reporting dominate |
| Delivery speed | A small team needs managed authentication and storage | Existing backend services already solve these problems |
| Live updates | Screens benefit from database listeners | Most data is static or suited to cached HTTP responses |
| Backend control | Managed SDKs and services are acceptable | You require database portability or infrastructure-level control |
| Cost predictability | Access patterns and listener scope are controlled | Large fan-outs or frequent reads are difficult to constrain |
Trade-off: Firebase removes considerable operational work, but its data model, rules, and SDK behavior become part of your architecture. Migrating later generally requires application changes.
A PostgreSQL-backed option such as Supabase may better suit relational workloads. A custom API on Google Cloud Run offers more control over server logic, at the cost of additional development and operations.
For this tutorial, target Android first. Add iOS with macOS and Xcode, then validate each additional platform independently.
Step 1: Prepare the Flutter project and Firebase tools
Install the Flutter SDK, an editor such as Android Studio or Visual Studio Code, and the platform tooling for your target. Confirm the local environment:
```bash
flutter doctor
flutter create mydiscussions_tasks
cd mydiscussions_tasks
```
Install the Firebase CLI using a supported method; with a compatible Node.js installation, npm is convenient:
```bash
npm install -g firebase-tools
firebase login
dart pub global activate flutterfire_cli
```
Ensure Dart’s global package executable directory is on your shell’s PATH.
In the Firebase console, create a development project. Keep development and production in separate Firebase projects so test users, rules changes, and experimental data cannot affect customers.
Add the SDK packages and configure the target platforms:
```bash
flutter pub add firebase_core firebase_auth cloud_firestore
flutterfire configure
```
Select the development project and the platforms you intend to run. FlutterFire generates lib/firebase_options.dart and registers the corresponding Firebase applications.
Follow the official Firebase setup guide for Flutter if platform prerequisites differ from your environment.
Important: Firebase client configuration identifies the project; it is not a privileged server credential. Never bundle service account private keys or Admin SDK credentials in the app.
Step 2: Enable authentication and create Firestore
In the Firebase console:
- Open Authentication → Sign-in method and enable Anonymous.
- Open Firestore Database and create a database.
- Start with restrictive rules rather than publicly accessible test rules.
- Choose a database location based on users, compliance requirements, and nearby backend services.
Treat the location choice as an architectural decision: an existing Firestore database’s location cannot simply be edited later.
Use this document structure:
```text
users/{uid}/tasks/{taskId}
title: string
done: boolean
createdAt: timestamp
```
Nesting tasks beneath the authenticated user makes ownership straightforward. The client does not need to submit an ownerId, and rules can compare the path’s user ID with the authenticated identity.
This structure suits private task lists. Shared workspaces would need a different authorization model, such as workspace documents and membership records. Do not extend private-user rules into collaboration by merely adding a client-controlled sharing flag.
Step 3: Initialize Firebase and establish a session
Replace lib/main.dart with the following startup and authentication flow. The next step adds TaskPage in the same file.
```dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.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 MaterialApp(home: AuthGate()));
}
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<User?>(
stream: FirebaseAuth.instance.authStateChanges(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Scaffold(
body: Center(child: Text('Authentication unavailable.')),
);
}
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final user = snapshot.data;
return user == null
? const SignInPage()
: TaskPage(uid: user.uid);
},
);
}
}
class SignInPage extends StatefulWidget {
const SignInPage({super.key});
@override
State<SignInPage> createState() => _SignInPageState();
}
class _SignInPageState extends State<SignInPage> {
bool busy = false;
String? error;
Future<void> start() async {
setState(() {
busy = true;
error = null;
});
try {
await FirebaseAuth.instance.signInAnonymously();
} on FirebaseAuthException {
if (mounted) {
setState(() => error = 'Could not sign in. Try again.');
}
} finally {
if (mounted) setState(() => busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: busy ? null : start,
child: Text(busy ? 'Connecting…' : 'Start demo'),
),
if (error != null) Text(error!),
],
),
),
);
}
}
```
The authentication stream restores an existing session when available and changes screens when the user state changes. Starting authentication from a button also avoids triggering repeated sign-in requests during widget rebuilds.
Before launch, offer an account upgrade through credential linking. Linking a supported credential to the current anonymous user preserves the user ID and therefore access to existing tasks. Signing into an unrelated account does not automatically migrate those documents.
Step 4: Add task creation and real-time updates
Append this widget to main.dart:
```dart
class TaskPage extends StatefulWidget {
const TaskPage({super.key, required this.uid});
final String uid;
@override
State<TaskPage> createState() => _TaskPageState();
}
class _TaskPageState extends State<TaskPage> {
final input = TextEditingController();
bool saving = false;
CollectionReference<Map<String, dynamic>> get tasks =>
FirebaseFirestore.instance
.collection('users')
.doc(widget.uid)
.collection('tasks');
late final Stream<QuerySnapshot<Map<String, dynamic>>> updates =
tasks.orderBy('createdAt', descending: true).limit(50).snapshots();
void showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
Future<void> addTask() async {
final title = input.text.trim();
if (title.isEmpty || title.length > 200 || saving) return;
setState(() => saving = true);
try {
await tasks.add({
'title': title,
'done': false,
'createdAt': FieldValue.serverTimestamp(),
});
if (mounted) input.clear();
} on FirebaseException {
showError('Task was not confirmed. Check your connection.');
} finally {
if (mounted) setState(() => saving = false);
}
}
Future<void> setDone(
DocumentReference<Map<String, dynamic>> reference,
bool value,
) async {
try {
await reference.update({'done': value});
} on FirebaseException {
showError('Could not update this task.');
}
}
@override
void dispose() {
input.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('MyDiscussions Tasks')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: input,
maxLength: 200,
decoration:
const InputDecoration(labelText: 'New task'),
),
),
IconButton(
onPressed: saving ? null : addTask,
icon: const Icon(Icons.add),
),
],
),
),
Expanded(
child: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
stream: updates,
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Center(
child: Text('Tasks unavailable. Try again later.'),
);
}
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
}
final docs = snapshot.data!.docs;
if (docs.isEmpty) {
return const Center(child: Text('Add your first task.'));
}
return ListView.builder(
itemCount: docs.length,
itemBuilder: (context, index) {
final doc = docs[index];
final data = doc.data();
return CheckboxListTile(
title: Text(data['title'] as String),
value: data['done'] as bool,
onChanged: (value) {
if (value != null) setDone(doc.reference, value);
},
);
},
);
},
),
),
],
),
);
}
}
```
The listener retrieves at most 50 tasks rather than an entire growing collection. Older tasks require pagination, which this demo intentionally omits.
Firestore can display local changes before the server confirms them. A successful-looking checkbox is therefore not proof of a committed write. Production interfaces should distinguish pending changes from confirmed ones using snapshot metadata.
Similarly, offline write futures may remain pending until connectivity returns. This example keeps the add button disabled while awaiting confirmation; an offline-first product should instead model queued operations explicitly.
Step 5: Enforce ownership and validate documents
Client validation improves usability but cannot protect the database. Anyone can modify a client or call Firebase APIs outside your interface.
Initialize the local Firestore configuration:
```bash
firebase init firestore
```
Select the existing development project. Place these rules in firestore.rules:
```text
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function owns(uid) {
return request.auth != null && request.auth.uid == uid;
}
function validTask(data) {
return data.keys().hasAll(['title', 'done', 'createdAt'])
&& data.keys().hasOnly(['title', 'done', 'createdAt'])
&& data.title is string
&& data.title.size() > 0
&& data.title.size() <= 200
&& data.done is bool
&& data.createdAt is timestamp;
}
match /users/{uid}/tasks/{taskId} {
allow read: if owns(uid);
allow create: if owns(uid)
&& validTask(request.resource.data)
&& request.resource.data.done == false
&& request.resource.data.createdAt == request.time;
allow update: if owns(uid)
&& validTask(request.resource.data)
&& request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['done']);
allow delete: if owns(uid);
}
}
}
```
These rules permit only the owner to access tasks, require a fixed schema, and restrict updates to the completion flag. The creation timestamp must resolve to the server request time.
Read the official Cloud Firestore security rules documentation before adapting this model.
Rules are not filters. Queries must be compatible with the authorization model; Firestore will not retrieve every user’s tasks and silently remove unauthorized results.
Privileged server SDKs bypass these rules and use IAM permissions. Any future Cloud Functions or Cloud Run backend must perform its own authorization.
Step 6: Test locally before deploying rules
Configure the Firebase Local Emulator Suite:
```bash
firebase init emulators
firebase emulators:start --only auth,firestore
```
Choose Authentication and Firestore during initialization. For local Flutter testing, insert emulator connections after Firebase initialization and before runApp:
```dart
const useEmulators = bool.fromEnvironment('USE_EMULATORS');
if (useEmulators) {
const host = String.fromEnvironment(
'EMULATOR_HOST',
defaultValue: 'localhost',
);
await FirebaseAuth.instance.useAuthEmulator(host, 9099);
FirebaseFirestore.instance.useFirestoreEmulator(host, 8080);
}
```
For the standard Android emulator, the host machine is typically reachable through 10.0.2.2:
```bash
flutter run \
--dart-define=USE_EMULATORS=true \
--dart-define=EMULATOR_HOST=10.0.2.2
```
Physical devices need a reachable development-machine address and appropriate firewall settings. Keep these connections out of production builds.
Automate rule tests with @firebase/rules-unit-testing, rather than relying only on manual UI checks:
- An unauthenticated client cannot read or write tasks.
- User A cannot access User B’s task path.
- Unknown fields, invalid types, and oversized titles are rejected.
- Clients cannot change titles or timestamps after creation.
- The owner can toggle completion and delete a task.
Also run flutter analyze and flutter test. Add integration tests for session restoration, task creation, permission failures, and reconnect behavior.
After tests pass, verify the selected project and deploy:
```bash
firebase deploy --only firestore:rules
```
Step 7: Prepare costs, observability, and release controls
Firestore costs depend on operations, storage, location, and network usage—not just the number of installed apps. Review the official Firebase pricing page against your expected workload.
Estimate the important drivers:
- Active sessions and task-list openings.
- Documents returned per query.
- Updates received by active listeners.
- Reconnection patterns and pagination.
- Additional services, including functions and file storage.
Budget alerts are useful notifications, not a guaranteed spending cap. Add Firebase App Check where supported to reduce unauthorized client traffic, while retaining authentication and security rules.
For operational visibility, consider Firebase Crashlytics on supported targets and platform-appropriate error reporting elsewhere. Log diagnostic context without task contents, tokens, or unnecessary personal information.
Release readiness should include separate environment configuration, account recovery, data deletion procedures, and tested schema changes. Deploy indexes alongside rules when new queries require them.
Firebase Hosting can serve a Flutter web build. Android and iOS apps still require signed platform builds and distribution through stores or testing channels.
Common mistakes to avoid
- Leaving test-mode rules enabled: Authentication alone does not secure Firestore.
- Using anonymous accounts indefinitely: Clearing local app data or losing a device can leave users without a recovery path.
- Listening to unlimited collections: Start with bounded queries and add cursor-based pagination.
- Treating cached data as fresh server data: Expose pending or offline status when it affects user decisions.
- Putting privileged actions in the client: Billing changes, administrator workflows, and cross-user operations belong in trusted backend logic.
- Assuming all platforms behave identically: Verify plugin support, persistence behavior, authentication setup, and release configuration per target.
Once this foundation is reliable, introduce a repository layer and state management such as Riverpod or Bloc when application complexity justifies it. For related deployment and mobile development guides, browse more Tutorials topics.
Frequently asked questions
Can I build a Flutter app with Firebase without a custom backend?
Yes. Authentication, Firestore, and security rules can support many client-driven applications. Use trusted backend services when operations require secrets, privileged access, external payment processing, or validation that cannot safely reside in client code.
Is Firebase free for a Flutter prototype?
A small prototype may fit available no-cost quotas. However, service availability, billing requirements, and quotas vary. Check the products you enable and estimate realistic access patterns before treating the architecture as cost-free.
Does this Flutter app work offline?
Firestore supports local caching and queued writes, with persistence behavior varying by platform and configuration. Previously loaded tasks may remain available, but initial authentication and uncached data require connectivity. This demo still needs explicit pending-write UI for a polished offline experience.
Should I use Firestore or Realtime Database?
Firestore suits this task model because it offers document collections, query capabilities, and granular access rules. Realtime Database uses a JSON tree and has useful native presence capabilities. Choose based on query needs, synchronization patterns, data structure, and billing—not simply which product is newer.
Ask the community and get answers from practitioners.