Environment-aware API configuration in Flutter
By John · 25 September 2024
Hard-coding API base URLs is one of the most common mistakes in Flutter apps. The URL needs to change between local dev, staging, and production — and it should do so automatically based on how the app is built, without code changes.
The wrong way
// WRONG: URL is hard-coded — you'll forget to change it
const apiUrl = 'https://api.example.com';
// Or worse:
const apiUrl = 'http://localhost:3000'; // Ships to production
Environment config via --dart-define
# Dev
flutter run \
--dart-define=API_BASE_URL=http://localhost:3000 \
--dart-define=ENVIRONMENT=dev
# Staging
flutter run \
--dart-define=API_BASE_URL=https://api.staging.example.com \
--dart-define=ENVIRONMENT=staging
# Production
flutter build appbundle \
--dart-define=API_BASE_URL=https://api.example.com \
--dart-define=ENVIRONMENT=production
class AppConfig {
static const apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:3000', // Local dev default
);
static const environment = String.fromEnvironment(
'ENVIRONMENT',
defaultValue: 'dev',
);
static bool get isProduction => environment == 'production';
static bool get isStaging => environment == 'staging';
static bool get isDev => environment == 'dev';
}
Using a JSON config file
For more config values, use --dart-define-from-file:
config/dev.json:
{
"API_BASE_URL": "http://localhost:3000",
"ENVIRONMENT": "dev",
"FEATURE_FLAGS_API": "https://flags.staging.example.com",
"SENTRY_DSN": ""
}
config/production.json:
{
"API_BASE_URL": "https://api.example.com",
"ENVIRONMENT": "production",
"FEATURE_FLAGS_API": "https://flags.example.com",
"SENTRY_DSN": "https://[email protected]/xxx"
}
flutter run --dart-define-from-file=config/dev.json
flutter build appbundle --dart-define-from-file=config/production.json
// Add config/*.json to .gitignore for files with real secrets
// Keep config/example.json in source control as a template
Configuring Dio with the base URL
@riverpod
Dio dio(DioRef ref) {
return Dio(
BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
),
)
..interceptors.addAll([
if (!AppConfig.isProduction) LogInterceptor(responseBody: true),
AuthInterceptor(ref),
]);
}
Multiple environments in one run config (IDE)
VS Code .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "Dev",
"request": "launch",
"type": "dart",
"args": ["--dart-define-from-file=config/dev.json"]
},
{
"name": "Staging",
"request": "launch",
"type": "dart",
"args": ["--dart-define-from-file=config/staging.json"]
}
]
}
Conditional features per environment
// Show debug overlay only in dev
if (!AppConfig.isProduction)
CheckerboardRasterCacheImagesCheckerboard(), // or your debug widget
// Enable verbose logging in non-production
if (AppConfig.isDev || AppConfig.isStaging)
Logger.level = Level.verbose;
else
Logger.level = Level.warning;
// Show a banner in staging
if (AppConfig.isStaging)
MaterialApp(
builder: (context, child) => Banner(
message: 'STAGING',
location: BannerLocation.topEnd,
child: child!,
),
);
The environment in native code
For iOS and Android configs that also change per environment (e.g., different GoogleService-Info.plist, different Firebase app IDs), use Flutter flavors in addition to --dart-define. --dart-define alone doesn't affect google-services.json or GoogleService-Info.plist.
For Firebase specifically:
# Swap the Firebase config file per flavor
flutter run --flavor dev -t lib/main_dev.dart
flutter run --flavor production -t lib/main.dart
Common pitfalls
Forgetting to update CI build commands. A CI pipeline that doesn't pass --dart-define=ENVIRONMENT=production will produce a production binary that points to localhost:3000. Always verify the build command in CI matches what you'd run locally for production.
Using runtime conditionals instead of compile-time. if (kDebugMode) is a runtime check — the production binary still contains the debug branches (they're just not executed). const String.fromEnvironment(...) is stripped at compile time, keeping the binary clean.
Serving the staging URL to production users. Use separate CI jobs for staging and production builds, with different secrets configured. Never deploy the same binary to both environments.
Sign in to like, dislike, or report.