React Native

How to Implement Multi-Flavors (Dev, Staging, Prod) in a React Native + Expo Project

 

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 environment
  • eas.json: EAS build profiles for each flavor
  • gradle files: Product flavors and build variants on Android
  • Xcode targets + .xcconfig files for iOS
  • .env support (if needed, though we kept it inside app.config.ts via process.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.

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.

<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.jsonapp.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 and scheme per profile for complete separation.

Leave a Reply