Maintaining different environments for development, staging, and production is essential in any large-scale mobile app. In this post, I’ll walk you through how I implemented multi environment (flavor) support in a React Native app built with Expo + EAS — covering both Android and iOS — using separate configurations, build schemes, and runtime environment handling.
️ Overview
We wanted three independent variants of our app:
- Development (for internal testing)
- Staging (for UAT and QA)
- Production (live on the stores)
Each variant had its own:
- Bundle IDs / Package Names
- App names
- App icons (optional)
- Deep links
- Environment-specific variables (API endpoints, tokens, etc.)
⚙️ Project Structure
We used the following files to organize the environment-based configuration:
app.config.ts
: Dynamic Expo config per environmenteas.json
: EAS build profiles for each flavorgradle
files: Product flavors and build variants on Android- Xcode targets +
.xcconfig
files for iOS .env
support (if needed, though we kept it insideapp.config.ts
viaprocess.env
)
Dynamic app.config.ts
We used a custom app.config.ts
that reads an ENVIRONMENT
variable and configures Expo accordingly.
import { ExpoConfig, ConfigContext } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => {
const env = process.env.ENVIRONMENT || 'development';
const flavorSettings = {
development: {
name: 'App Dev',
slug: 'app-dev',
androidPackage: 'com.example.dev',
iosBundleId: 'com.example.dev',
},
staging: {
name: 'App Staging',
slug: 'app-staging',
androidPackage: 'com.example.staging',
iosBundleId: 'com.example.staging',
},
production: {
name: 'App',
slug: 'app',
androidPackage: 'com.example',
iosBundleId: 'com.example',
},
};
const flavor = flavorSettings[env];
return {
...config,
name: flavor.name,
slug: flavor.slug,
android: {
package: flavor.androidPackage,
},
ios: {
bundleIdentifier: flavor.iosBundleId,
},
extra: {
ENVIRONMENT: env,
},
};
};
EAS Build Profiles (eas.json
)
We then set up three build profiles in eas.json
, each passing a custom ENVIRONMENT
and custom gradle commands / Xcode schemes:
{
"build": {
"development": {
"extends": "base",
"env": {
"ENVIRONMENT": "development"
},
"android": {
"gradleCommand": ":app:bundleDevelopmentRelease"
},
"ios": {
"scheme": "app-dev"
}
},
"staging": {
"extends": "base",
"env": {
"ENVIRONMENT": "staging"
},
"android": {
"gradleCommand": ":app:bundleStagingRelease"
},
"ios": {
"scheme": "app-staging"
}
},
"production": {
"extends": "base",
"env": {
"ENVIRONMENT": "production"
},
"android": {
"gradleCommand": ":app:bundleProductionRelease"
},
"ios": {
"scheme": "app"
}
}
}
}
You can also include environment-specific variables like base URLs, API keys, or toggles in the env
block.
Android: Product Flavors
We used product flavors in android/app/build.gradle
:
flavorDimensions "default" productFlavors { development { dimension "default" applicationId "com.example.dev" versionNameSuffix "-dev" resValue "string", "app_name", "App Dev" } staging { dimension "default" applicationId "com.example.staging" versionNameSuffix "-staging" resValue "string", "app_name", "App Staging" } production { dimension "default" applicationId "com.example" resValue "string", "app_name", "App" } }
Each flavor has its own package name, app name, and can have separate resources like strings.xml
.
Android: Manifest-Specific Configurations
To support deep linking and intent filters specific to each flavor, we used different AndroidManifest.xml
files per flavor.
Create these under:
android/app/src/development/AndroidManifest.xml android/app/src/staging/AndroidManifest.xml android/app/src/production/AndroidManifest.xml
In each manifest file, configure your deep links or intent filters like so:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <application> <activity android:name=".MainActivity"> <!-- Deep link intent specific to this flavor --> <intent-filter android:autoVerify="true"> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <!-- Replace with scheme relevant to the flavor --> <data android:scheme="yourapp.staging" /> </intent-filter> <!-- Optional: If your app reacts to download completion --> <intent-filter> <action android:name="android.intent.action.DOWNLOAD_COMPLETE" /> </intent-filter> </activity> </application> </manifest>
This allows each flavor to respond to different schemes or hostnames.
iOS: Multiple Targets + .xcconfig
To replicate Android-style flavoring on iOS, we created multiple targets in Xcode — one per environment (Development, Staging, Production). Each target allows unique bundle identifiers, app schemes, icons, and configs.
How to Create Targets in Xcode
- Open your project in Xcode (
.xcworkspace
). - In the project navigator, right-click your main app target → Duplicate.
- Rename the duplicate to match the environment, like
MyAppDev
,MyAppStaging
. - Select the new target and:
- Update its Bundle Identifier in
Build Settings → Packaging
. - Assign a custom Display Name in its
Info.plist
if needed.
5. For each new target:
- Create
.xcconfig
files likeDev.debug.xcconfig
,Dev.release.xcconfig
, etc. - Link them via
Build Settings → Configuration
.
For each target:
- Defined unique Bundle ID
- Assigned unique
APP_SCHEME
- Linked separate
.xcconfig
files (for Debug and Release)
Info.plist
, we referenced a build-time variable:<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLSchemes</key> <array> <string>$(APP_SCHEME)</string> </array> </dict> </array>
In Xcode → Build Settings (per target), we added:
APP_SCHEME = app.dev APP_SCHEME = app.staging APP_SCHEME = app
This way, you don’t need different Info.plist
files — just plug in the correct runtime values.
Scripts in package.json
We added custom scripts to streamline build and run tasks:
"android:dev": "ENVIRONMENT=development expo run:android --variant developmentDebug",
"android:staging": "ENVIRONMENT=staging expo run:android --variant stagingDebug",
"android:prod": "ENVIRONMENT=production expo run:android --variant productionDebug",
"ios:dev": "ENVIRONMENT=development expo run:ios --scheme app-dev",
"ios:staging": "ENVIRONMENT=staging expo run:ios --scheme app-staging",
"ios:prod": "ENVIRONMENT=production expo run:ios --scheme app",
You can run:
npm run android:staging npm run ios:prod
Triggering Builds and Submissions via Scripts
Once your eas.json
, app.config.ts
, native Android/iOS configurations, and environment variables are set up, you can leverage powerful build and submit scripts directly from your package.json
.
These scripts make it super easy to automate builds and uploads for each environment without manual intervention.
️ Build Commands
"build:dev:android": "eas build --platform android --profile development",
"build:staging:android": "eas build --platform android --profile staging",
"build:prod:android": "eas build --platform android --profile production",
"build:dev:ios": "eas build --platform ios --profile development",
"build:staging:ios": "eas build --platform ios --profile staging",
"build:prod:ios": "eas build --platform ios --profile production"
Submit Commands
"submit:dev:android": "eas submit --platform android --profile development",
"submit:staging:android": "eas submit --platform android --profile staging",
"submit:prod:android": "eas submit --platform android --profile production",
"submit:dev:ios": "eas submit --platform ios --profile development",
"submit:staging:ios": "eas submit --platform ios --profile staging",
"submit:prod:ios": "eas submit --platform ios --profile production"
You can run these commands like so:
npm run build:staging:android npm run submit:prod:ios
This setup streamlines your deployment workflow and ensures consistency across environments.
Final Thoughts
With this setup:
- You isolate environments without code duplication.
- Each flavor gets its own deep links, configurations, and runtime settings.
- You gain full control over the build and release lifecycle for each variant.
This setup has significantly streamlined our CI/CD, testing, and release pipelines — making the app more maintainable and scalable.
✅ Quick Tips
- ❗ Always keep a single
Info.plist
and use build variables. - Place variant-specific AndroidManifests inside respective
src/{flavor}
folders. - ️ Use
.xcconfig
files to manage per-target variables on iOS. - Use EAS Build’s
gradleCommand
andscheme
per profile for complete separation.