# Flutter Flavors Explained: How to Implement and Why You Should

You've built your Flutter app, and everything is looking great. Then reality sets in: you need a development build to test against a staging API, a QA build for testers, and a production build for real users — and you don't want to hand-edit a config file and hope you remember to change it back before shipping.

That's what flavors solve: separate, buildable configurations of the same codebase, switched with a flag instead of a manual edit. The part most guides skip is that "flavor" actually means two connected things — a native build variant (which Android/iOS artifact gets compiled, with its own app ID, name, and icon) and a Dart-level configuration (which API URL, which feature flags) — and the two have to be wired together explicitly, or you end up with a dev-labeled app icon that's quietly still hitting your production API. This guide builds both halves and connects them.

## What flavors actually give you

Same codebase, different builds: a dev build that talks to a test API and says "Dev" in its name and icon, a staging build your QA team installs, and a production build with the real API and the real logo — all three installable on the same device at once, because they have distinct application IDs.

## Step 1: Android — product flavors in Gradle

This is the half most guides gesture at without showing. In `android/app/build.gradle` (or `build.gradle.kts` if you've migrated to Kotlin DSL), inside the `android` block:

```groovy
android {
    // ...existing config...

    flavorDimensions "environment"

    productFlavors {
        dev {
            dimension "environment"
            applicationIdSuffix ".dev"
            resValue "string", "app_name", "MyApp Dev"
        }
        staging {
            dimension "environment"
            applicationIdSuffix ".staging"
            resValue "string", "app_name", "MyApp Staging"
        }
        prod {
            dimension "environment"
            resValue "string", "app_name", "MyApp"
        }
    }
}
```

`applicationIdSuffix` is what makes `dev` and `staging` installable side by side with `prod` on the same device — they become genuinely different packages (`com.yourcompany.app.dev` instead of `com.yourcompany.app`), not just different-looking builds of the same one. The `resValue` lines generate a string resource per flavor, which is why `AndroidManifest.xml` should reference the app name indirectly:

```xml
<application android:label="@string/app_name" ...>
```

instead of a hardcoded string — that one line is what lets three different flavor builds show three different names without three different manifests.

## Step 2: iOS — build configurations and schemes

iOS doesn't have a Gradle-style flavor system; it uses build configurations and schemes, so the setup is more manual but follows the same idea.

In Xcode, open `Runner.xcworkspace`, select the **Runner** project, and duplicate each existing configuration (Debug, Release, Profile) once per flavor, giving you `Debug-dev`, `Release-dev`, `Debug-staging`, `Release-staging`, and so on. For each new configuration, create a matching `.xcconfig` file under `ios/Flutter/` — for example, `Flutter/Dev.xcconfig`:

```plaintext
#include "Generated.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.app.dev
PRODUCT_NAME = MyApp Dev
```

and `Flutter/Prod.xcconfig`:

```plaintext
#include "Generated.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = com.yourcompany.app
PRODUCT_NAME = MyApp
```

Assign each `.xcconfig` file to its matching build configuration under the project's **Info** tab. Then create a **Scheme** per flavor (Product → Scheme → Manage Schemes → duplicate `Runner` as `dev`, `staging`, `prod`), pointing each scheme's Run/Build/Archive actions at the matching configuration. This is what makes `flutter run --flavor dev` resolve to something concrete on the iOS side — without a scheme named `dev`, the flag has nothing to find.

## Step 3: entry points that actually receive the flavor

This is the piece that connects native and Dart, and it's the part the naive version of this pattern (a `const` string passed as a plain function argument) doesn't do — that approach can select a different `main_*.dart` file, but it never confirms which *native* flavor was actually built, so Dart and the native layer can silently disagree.

```dart
// lib/main_dev.dart
import 'flavor_config.dart';
import 'main.dart' as app;

void main() {
  FlavorConfig.initialize(Flavor.dev);
  app.main();
}
```

```dart
// lib/main_prod.dart
import 'flavor_config.dart';
import 'main.dart' as app;

void main() {
  FlavorConfig.initialize(Flavor.prod);
  app.main();
}
```

```dart
// lib/flavor_config.dart
enum Flavor { dev, staging, prod }

class FlavorConfig {
  FlavorConfig._(this.flavor, this.apiUrl, this.appName);

  final Flavor flavor;
  final String apiUrl;
  final String appName;

  static late FlavorConfig instance;

  static void initialize(Flavor flavor) {
    instance = switch (flavor) {
      Flavor.dev => FlavorConfig._(flavor, 'https://api.dev.myapp.com', 'MyApp Dev'),
      Flavor.staging => FlavorConfig._(flavor, 'https://api.staging.myapp.com', 'MyApp Staging'),
      Flavor.prod => FlavorConfig._(flavor, 'https://api.myapp.com', 'MyApp'),
    };
  }
}
```

```dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'flavor_config.dart';

void main() {
  runApp(MyApp(config: FlavorConfig.instance));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key, required this.config});
  final FlavorConfig config;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: config.appName,
      home: Scaffold(
        appBar: AppBar(title: Text(config.appName)),
        body: Center(child: Text('API: ${config.apiUrl}')),
      ),
    );
  }
}
```

`FlavorConfig` now holds everything Dart-side code needs — API URL, display name, feature flags if you add them — set once at startup by whichever entry point ran.

## Step 4: run and build with both halves connected

The flag that actually ties the Dart entry point to the native build variant is `--flavor`, used alongside `-t` to pick the entry point:

```bash
flutter run --flavor dev -t lib/main_dev.dart
flutter run --flavor staging -t lib/main_staging.dart
flutter run --flavor prod -t lib/main_prod.dart
```

`-t` alone picks which Dart file runs `main()`; `--flavor` alone picks which native product flavor / scheme gets compiled. Omit `--flavor` and Flutter builds the default native variant regardless of which Dart entry point you specified — which is exactly the silent-mismatch failure mode this two-flag combination exists to prevent. The same pairing carries over to release builds:

```bash
flutter build apk --flavor prod -t lib/main_prod.dart
flutter build ipa --flavor prod -t lib/main_prod.dart
```

## Best practices, from what actually bites teams

**Name every flavor's app visibly different** — "MyApp Dev," "MyApp Staging," distinct icons per flavor — so nobody on your team, especially QA, ever mistakes one running build for another. This matters more than it sounds like it should; "which build is this" is a surprisingly common support question once you have three of them installed on one device.

**Never hardcode secrets in** `FlavorConfig` **or anywhere else in source** — API keys and signing secrets belong in `--dart-define` values injected at build time, or a `.env` file per flavor loaded via `flutter_dotenv`, not committed alongside the flavor definitions themselves.

**Automate the flag combinations in CI** rather than trusting a human to remember `--flavor prod -t lib/main_prod.dart` correctly every release — a CI job with three named build steps (`build-dev`, `build-staging`, `build-prod`) removes the exact class of mistake this whole setup is designed around.

**Consider** `flutter_flavorizr` if you're setting this up across several projects or from scratch — it automates the Gradle and Xcode scaffolding above from a single YAML config, at the cost of some flexibility if your setup needs to deviate from its defaults. Doing it manually once, as above, is worth understanding even if you later automate it, since debugging a flavorizr-generated project without knowing what it generated is much harder than debugging one you built by hand.

## Final thoughts

Flavors save time and prevent a specific, expensive class of mistake — shipping a test build to real users, or a real build talking to a test API — but only when the native build variant and the Dart-level configuration are actually wired together, not just running side by side under the same name. Set it up once with both halves connected, and switching environments becomes a single flag instead of a source of quiet, hard-to-debug mismatches.

* * *

**References**

1.  [Flutter's official flavors documentation](https://docs.flutter.dev/deployment/flavors)
    
2.  [Android Gradle Plugin: configuring product flavors](https://developer.android.com/build/build-variants)
    
3.  [flutter\_flavorizr on pub.dev](https://pub.dev/packages/flutter_flavorizr)
    
4.  [flutter\_dotenv on pub.dev](https://pub.dev/packages/flutter_dotenv)
    
5.  [Flutter: passing arguments to flutter run and build](https://docs.flutter.dev/deployment/flavors#step-4-run-flutter-app)
