Skip to content
ExponaBLOG
FLUTTER · DAY 078 / 100

Flavors and environments: a Flutter guide for developers

Dev, staging and production builds.

Expona Admin6 min read

Building a mobile app means managing multiple deployment targets — and you can't afford to mix up dev, staging, and production by accident. Flavors and environments in Flutter let you maintain separate app identities, configurations, and backend services for each stage of your deployment pipeline. By the end of this article, you'll know how to structure your project so that developers, QA testers, and end users always know exactly which build they're running.

Flavors — Separate app IDs, icons and names per environment

A flavor in Flutter is a named build variant that lets you customize your app's identity without touching code. Each flavor gets its own bundle ID (iOS), application ID (Android), icon, display name, and even splash screen. This matters because you need dev, staging, and production versions coexisting on the same device during testing.

I configure flavors at the platform level rather than trying to manage everything in Dart. Here's how I structure it:

Android setup: Open android/app/build.gradle and define flavor dimensions and product flavors:

dart
flavorDimensions "env"

productFlavors {
    dev {
        dimension "env"
        applicationId "com.mycompany.myapp.dev"
        versionName "1.0.0-dev"
    }
    staging {
        dimension "env"
        applicationId "com.mycompany.myapp.staging"
        versionName "1.0.0-staging"
    }
    prod {
        dimension "env"
        applicationId "com.mycompany.myapp"
        versionName "1.0.0"
    }
}

iOS setup: I use Xcode build schemes and configurations. Create a new scheme for each environment — DevApp, StagingApp, ProdApp — and modify the bundle identifier in each scheme's build settings. This is more manual than Android but gives you finer control.

Next, replace the app icon for each flavor. On Android, place flavor-specific assets in android/app/src/dev/res, android/app/src/staging/res, etc. On iOS, create separate asset catalogs for each flavor and assign them in the build settings.

For the app display name, use the app_name string resource on Android (android/app/src/dev/res/values/strings.xml) and Info.plist overrides on iOS. This way, if someone sees "MyApp Dev" on their home screen, they instantly know it's not production.

Config per flavor — API URLs and keys per build

Each environment needs different backend URLs, API keys, and feature flags. I use a Dart configuration class that reads from platform-specific constants at startup.

First, create a config file in your Dart code:

dart
enum Flavor { dev, staging, prod }

class Config {
  static late Flavor flavor;
  static late String apiUrl;
  static late String apiKey;
  static late bool enableCrashlytics;

  static void setup(Flavor flv) {
    flavor = flv;
    switch (flv) {
      case Flavor.dev:
        apiUrl = 'https://api-dev.mycompany.com';
        apiKey = 'dev-key-12345';
        enableCrashlytics = false;
        break;
      case Flavor.staging:
        apiUrl = 'https://api-staging.mycompany.com';
        apiKey = 'staging-key-67890';
        enableCrashlytics = true;
        break;
      case Flavor.prod:
        apiUrl = 'https://api.mycompany.com';
        apiKey = 'prod-key-secret';
        enableCrashlytics = true;
        break;
    }
  }
}

Now call this from main.dart. On Android and iOS, you can pass the flavor as a constant from the platform layer. I use method channels to detect which flavor is running:

dart
void main() {
  final flavor = _detectFlavor();
  Config.setup(flavor);
  runApp(const MyApp());
}

Flavor _detectFlavor() {
  const platform = MethodChannel('com.mycompany.myapp/flavor');
  try {
    final result = platform.invokeMethodSync('getFlavor');
    if (result == 'dev') return Flavor.dev;
    if (result == 'staging') return Flavor.staging;
    return Flavor.prod;
  } catch (e) {
    return Flavor.prod; // Safe default
  }
}

On the Android side (MainActivity.kt), expose the flavor:

dart
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.mycompany.myapp/flavor")
    .setMethodCallHandler { call, result ->
        if (call.method == "getFlavor") {
            result.success(BuildConfig.FLAVOR)
        }
    }

Now your entire app uses Config.apiUrl and Config.apiKey, and the right values are baked in at build time. No secrets in your code, and no mixing up environments.

Firebase per flavor — Separate projects for dev and prod

Firebase requires separate projects for each environment. If you point all flavors to the same Firebase project, analytics, Crashlytics data, and user authentication get tangled together, making it impossible to debug issues in staging without polluting production metrics.

Create three Firebase projects: myapp-dev, myapp-staging, myapp-prod. Download the configuration files:

Android: Download google-services.json for each project and place them in the flavor-specific directories:

  • android/app/src/dev/google-services.json
  • android/app/src/staging/google-services.json
  • android/app/src/prod/google-services.json

Gradle automatically picks the right file based on the build variant.

iOS: The process is messier. Rename each GoogleService-Info.plist file to include the scheme name — e.g., GoogleService-Info-Dev.plist — then use a build phase script to copy the right one to the bundle:

dart
cp "${PROJECT_DIR}/GoogleService-Info-${CONFIGURATION}.plist" \
   "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_FOLDER_PATH}/GoogleService-Info.plist"

In your Flutter code, initialize Firebase once at startup, and it will use the correct credentials automatically:

dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(const MyApp());
}

With separate Firebase projects, your dev team can test push notifications without affecting production users, and staging analytics stay completely isolated from production dashboards.

Clear labeling — Users and testers should know which build they have

This is simple but critical. Always display the current flavor and version somewhere in your app, usually in a debug drawer or settings page. I add a debug banner to dev and staging builds that shows flavor, API environment, and build number:

dart
Widget _buildDebugBanner() {
  if (Config.flavor == Flavor.prod) {
    return const SizedBox.shrink();
  }
  
  return Positioned(
    top: 0,
    right: 0,
    child: Container(
      color: Config.flavor == Flavor.dev ? Colors.red : Colors.orange,
      padding: const EdgeInsets.all(8),
      child: Text(
        '${Config.flavor.name.toUpperCase()}\n${Config.apiUrl}',
        style: const TextStyle(color: Colors.white, fontSize: 10),
        textAlign: TextAlign.center,
      ),
    ),
  );
}

Add this widget to your root scaffold. Now if a tester screenshots an issue, you immediately know which environment they were in.

I also put flavor info in the app's title or in a developer settings screen. If your app has a profile page, show the flavor, version, and build number there. This saves hours of debugging when someone reports a bug and you're unsure which backend they hit.

Common mistakes

Mixing environments in Firebase: Pointing all flavors to the same Firebase project is tempting because it's one less setup step. Don't do it. You'll regret it when a staging crash floods your production Crashlytics dashboard.

Hardcoding secrets in flavor configs: Never put API keys or tokens in your Config class. Use platform-specific build configs (BuildConfig on Android, Info.plist on iOS) and read them at runtime, or better yet, fetch them from a secure backend after authentication.

Forgetting to update flavor names in release checklists: It's easy to build a staging APK and accidentally upload it to production if your CI/CD process doesn't validate the bundle ID. Always check that the app ID, icon, and display name match your target environment before uploading.

Not testing flavor switching locally: Before shipping a new flavor setup, build all three variants on a physical device and verify that icons, names, and API URLs are correct. This catches mistakes at zero cost.

Key takeaways

  • Flavors let you maintain separate app IDs, icons, and names for dev, staging, and production, so multiple builds can coexist on one device.
  • Use a Config class that reads flavor-specific settings at startup — API URLs, keys, and feature flags — avoiding runtime guessing.
  • Create separate Firebase projects for each environment to keep analytics, Crashlytics, and authentication completely isolated.
  • Always display the current flavor somewhere in the UI (dev banner, settings screen) so testers and developers never confuse environments.
  • Validate flavor settings in CI/CD to prevent accidentally shipping a staging build to production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles