Mastering Flutter Flavors, the modern way
I am Software Engineer(Mobile) who is particularly enthusiastic about leveraging modern technologies to create user-friendly and scalable applications. I thrive in collaborative environments and enjoy working with diverse teams to deliver exceptional software solutions. Follow along as I share my knowledge, insights, and experiences on Hashnode. I look forward to connecting with fellow developers, learning from the community, and contributing to the growth of the tech industry.
Handling multiple environments (Development, Staging, Production) is a cornerstone of professional mobile development. Historically, Flutter developers relied on Android product flavors and separate Xcode schemes. Those approaches are often hard to maintain and tend to leak configuration logic into native code. In this tutorial, we use a clean, modern architecture that leverages the --dart-define-from-file flag so all environment variables live in simple JSON files. A single source of truth dynamically configures Dart at build time, and the same JSON can be used to drive native configuration with a small build step.
1. The configuration layer
Put environment-specific JSON files in a config/ directory instead of hardcoding values:
config/dev.json
{
"FLAVOR": "dev",
"BASE_URL": "https://api.example-backend.com/v1",
"APP_ID_SUFFIX": ".dev",
"APP_NAME": "Project Name (Dev)"
}
config/prod.json
{
"FLAVOR": "prod",
"BASE_URL": "https://api.example-backend.com/v1",
"APP_ID_SUFFIX": "",
"APP_NAME": "Project Name"
}
These JSON files are the single source of truth. During build/run you inject them into the Dart compiler:
Run on device/emulator: flutter run --dart-define-from-file=config/dev.json
Build an APK or IPA: flutter build apk --dart-define-from-file=config/prod.json flutter build ipa --dart-define-from-file=config/prod.json
2. Automate injection in VS Code
Update launch.json so the IDE automatically passes the config file when you hit Run/Debug:
.vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Flutter (dev)",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": ["--dart-define-from-file=config/dev.json"]
},
{
"name": "Flutter (prod)",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": ["--dart-define-from-file=config/prod.json"]
}
]
}
3. Accessing values in Dart
Values provided via dart-defines are compiled into the binary. You don't need packages like flutter_dotenv; you can access them synchronously with const String.fromEnvironment. A recommended pattern is to define a single JSON dart-define key (e.g., ENV) and decode it at startup into a typed config object.
Create lib/environment_config.dart:
import 'dart:convert';
class EnvironmentConfig {
final String flavor;
final String baseUrl;
final String appIdSuffix;
final String appName;
EnvironmentConfig._({
required this.flavor,
required this.baseUrl,
required this.appIdSuffix,
required this.appName,
});
factory EnvironmentConfig.fromJson(Map<String, dynamic> json) {
return EnvironmentConfig._(
flavor: json['FLAVOR'] as String? ?? 'dev',
baseUrl: json['BASE_URL'] as String? ?? '',
appIdSuffix: json['APP_ID_SUFFIX'] as String? ?? '',
appName: json['APP_NAME'] as String? ?? '',
);
}
static final EnvironmentConfig instance = _load();
static EnvironmentConfig _load() {
// The ENV dart-define will hold the JSON content when using --dart-define-from-file
final envJson = const String.fromEnvironment('ENV');
if (envJson.isEmpty) {
// Fallback: provide defaults or throw in CI/dev depending on your preference
return EnvironmentConfig.fromJson({
'FLAVOR': 'dev',
'BASE_URL': 'https://api.example-backend.com/v1',
'APP_ID_SUFFIX': '.dev',
'APP_NAME': 'Project Name (Dev)'
});
}
final Map<String, dynamic> data = jsonDecode(envJson) as Map<String, dynamic>;
return EnvironmentConfig.fromJson(data);
}
}
How to use it in main.dart:
import 'package:flutter/material.dart';
import 'environment_config.dart';
void main() {
final config = EnvironmentConfig.instance;
runApp(MyApp(config: config));
}
class MyApp extends StatelessWidget {
final EnvironmentConfig config;
const MyApp({required this.config, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: config.appName,
home: Scaffold(
appBar: AppBar(title: Text(config.appName)),
body: Center(child: Text('Base URL: ${config.baseUrl}')),
),
);
}
}
Notes:
const String.fromEnvironment reads compile-time values added via --dart-define or --dart-define-from-file.
Using a single ENV JSON dart-define keeps your code clean and centralizes the configurationization.
4. Keeping native configuration in sync
--dart-define-from-file only affects Dart compilation. To keep Android and iOS native metadata (bundle identifier, display name, etc.) in sync with the same JSON, add a small build step that transforms the JSON into native files before invoking the Flutter build. Options:
A simple Node/Dart/shell script that reads config/*.json and writes:
android/gradle.properties or a generated file consumed by build.gradle (e.g., app/config.properties)
ios/Runner/Info.plist values (use PlistBuddy or use a template + replace)
CI pipelines: run the same script during the pipeline, then run flutter build.
Example (pseudo-shell script):
# generate-native-config.sh <config-file>
CONFIG_FILE="$1"
# create gradle properties
jq -r '.APP_ID_SUFFIX as \(s | "APP_ID_SUFFIX=\(\)s)"' "$CONFIG_FILE" > android/app/config.properties
# update iOS Info.plist using plutil / defaults / xmlstarlet / a templating step
# ... tailor to your project
In your Android app/build.gradle you can load the generated config.properties and use manifestPlaceholders or applicationIdSuffix:
def configProps = new Properties()
file("app/config.properties").withInputStream { configProps.load(it) }
def appIdSuffix = configProps.getProperty("APP_ID_SUFFIX", "")
android {
defaultConfig {
applicationIdSuffix appIdSuffix
}
}
This way, the same JSON drives both Dart and native behavior while keeping the source of truth in config/*.json.
5. Build & CI recommendations
Keep secrets out of these JSON files. Build-time config should hold non-sensitive settings (endpoints, feature flags). Use secrets managers for API keys.
For CI, commit the script and ensure secrets are injected by the CI environment (not checked into source).
Validate config JSON in CI to avoid malformed builds (jsonlint or a simple Dart/Node script).
Conclusion
Using --dart-define-from-file to feed environment JSON into Dart gives you a clean, testable, and compile-time-configured application without relying on runtime environment libraries. Pair that with a small native sync script, and you have a single, maintainable source of truth for Dart, Android, and iOS configuration.


