For a Flutter volunteer-scheduling app with separate dev and prod Firebase projects, how do you wire FlutterFire per build flavor, and what typically goes wrong?
answer
- two projects, one per flavor
- configure once per project
- per-flavor options file or entry point
- appFlavor is null without --flavor
- stale plist means duplicate-app
basics
~20 sRun flutterfire configure once per Firebase project into per-flavor options files, pick the file at startup by entry point or appFlavor, and bundle each flavor's own google-services.json and GoogleService-Info.plist. Mismatches send dev data to prod or throw [core/duplicate-app].
solid answer
~40 sEach flavor gets its own application ID and bundle ID registered in its own Firebase project. You run `flutterfire configure` once per project, writing separate files such as `firebase_options_dev.dart` and `firebase_options_prod.dart`, and `main` chooses one — through per-flavor entry points or a switch on `appFlavor` from `package:flutter/services.dart`, which is `null` without `--flavor`. Each Android flavor also carries its project's `google-services.json` in its source set, and each iOS scheme bundles the matching `GoogleService-Info.plist`. The classic failures are a single imported options file, so dev testers write into production; a plist from the other project, which creates a native default app that the Dart options then contradict, throwing `[core/duplicate-app]`; and an application ID missing from the picked-up `google-services.json`, which breaks the Android build.
code
dart · 27 linesimport 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'firebase_options_dev.dart' as dev;
import 'firebase_options_prod.dart' as prod;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final FirebaseOptions options = switch (appFlavor) {
'prod' => prod.DefaultFirebaseOptions.currentPlatform,
'dev' => dev.DefaultFirebaseOptions.currentPlatform,
_ => throw StateError('Unknown flavor: $appFlavor'),
};
await Firebase.initializeApp(options: options);
debugPrint('Firebase project: ${Firebase.app().options.projectId}');
runApp(const ShiftsApp());
}
class ShiftsApp extends StatelessWidget {
const ShiftsApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: Scaffold(body: Text('Shifts')));
}
}go deeper
Know that dev and prod builds should point at different Firebase projects, and that each needs its own options file.
Explain how main chooses the options — per-flavor entry points or appFlavor — and why each flavor also needs its own native config files.
Diagnose the three classic failures: dev writes landing in prod, duplicate-app from a stale plist, and an Android build rejecting a missing application ID.
Decide how many Firebase environments the product needs and how releases, test data and access rights map onto them, then make the build refuse ambiguous configurations.
## The scenario A volunteer-scheduling app keeps shift rosters, sign-ups and reminders in Firebase. Testers must be able to create and cancel fake shifts freely, while coordinators rely on the production data being real. The standard answer is **two Firebase projects** — one for development, one for production — with each Flutter build flavor wired to exactly one of them. Creating the projects and registering their apps is Firebase-console work; the Flutter side is about making sure every build talks to the right one. ## The wiring, layer by layer 1. **Distinct app identities per flavor.** The dev flavor gets its own Android application ID and Apple bundle ID (for example a `.dev` suffix), registered as apps in the dev Firebase project; the prod IDs are registered in the prod project. Distinct IDs also let both builds sit on one test phone. 2. **One Dart options file per project.** Run `flutterfire configure` once against each project, pointing the CLI's output path at a per-flavor file — say `lib/firebase_options_dev.dart` and `lib/firebase_options_prod.dart`. Each declares its own `DefaultFirebaseOptions` class. 3. **Select the options at startup.** Either give each flavor its own entry point (`main_dev.dart`, `main_prod.dart`) that imports its own file, or switch on the `appFlavor` constant from `package:flutter/services.dart`, which holds the `--flavor` value the app was built with and is `null` when none was given. 4. **One native config file per flavor.** Each Android flavor carries the `google-services.json` from its project in that flavor's source set, and each iOS scheme bundles the matching `GoogleService-Info.plist`, typically copied in by a build-phase script keyed on the build configuration. ```dart import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'firebase_options_dev.dart' as dev; import 'firebase_options_prod.dart' as prod; Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final FirebaseOptions options = switch (appFlavor) { 'prod' => prod.DefaultFirebaseOptions.currentPlatform, 'dev' => dev.DefaultFirebaseOptions.currentPlatform, _ => throw StateError('Unknown flavor: $appFlavor'), }; await Firebase.initializeApp(options: options); runApp(const ShiftsApp()); } ``` Failing on an unknown or missing flavor is deliberate: a plain `flutter run` without `--flavor` should not quietly land in either project. ## What goes wrong | Symptom | Cause | |---|---| | Dev testers' fake shifts appear in production | `main` imports one options file for every flavor, so all builds initialize against the same project | | `[core/duplicate-app] A Firebase App named "[DEFAULT]" already exists` on one platform only | A native file from the *other* project is bundled: on iOS the plist creates a native default app at plugin registration, and the Dart options' `apiKey` then fails the soft comparison | | Android build fails in the Google Services step | The flavor's application ID has no matching app in the `google-services.json` that was picked up | | Crashes or products misbehaving only in one flavor | Product-level native configuration (a Gradle plugin, a sign-in URL scheme) was set up for one flavor's project but not the other | ## Keeping the native files straight - Keep each native file next to the flavor or scheme that uses it, never a shared copy that a script overwrites in place and someone commits. - Make the iOS copy step fail the build when the expected plist for the configuration is missing, instead of silently leaving the last one. - Regenerate the per-flavor Dart file and the matching native files together, from the same project, in one change. - Review diffs to these files like code: a changed `projectId` in a dev file deserves a question. ## Named secondary apps are a different tool `Firebase.initializeApp(name: 'reporting', options: ...)` creates a **secondary** `FirebaseApp` beside `[DEFAULT]`, and plugins reach it through `instanceFor(app: Firebase.app('reporting'))`. That fits a process that genuinely needs two projects at once — an internal tool comparing environments — not flavor selection. Flavors choose *which* project is the default for the whole build; secondary apps let one build use *several*. ## Checks worth automating - A startup log line or debug banner that prints `Firebase.app().options.projectId`, so a tester can see which project a build talks to. - A CI step that builds every flavor, so a missing or mismatched native file fails before release. - A review rule that each Dart entry point, including any secondary one such as a background handler, selects options the same way `main` does.
- When would you use a named secondary FirebaseApp instead of flavors?When one running process must talk to two projects at once, such as an internal tool comparing dev and prod rosters. You call `Firebase.initializeApp(name: 'prod', options: ...)` and reach it with `instanceFor(app: Firebase.app('prod'))` on each plugin. Flavors instead decide which single project a whole build treats as `[DEFAULT]`.
- How can a tester tell which Firebase project a build is using?Read it from the initialized app: `Firebase.app().options.projectId` is the project the default app was created for. Logging it at startup, or showing it in a debug-only banner, turns a silent cross-environment write into something visible on the first launch.
saying these in an interview costs you the question
- One firebase_options.dart can serve both flavors, because currentPlatform reads the flavor.
- The Dart options override a bundled GoogleService-Info.plist from another project.
- Separate Firebase projects are only needed for separate store listings.
- A named secondary FirebaseApp is the standard way to implement dev and prod flavors.
- appFlavor falls back to 'main' when the app is built without --flavor.