If you've worked on a React Native app that has both development and production environments, you probably know how quickly environment management becomes annoying.
At the beginning, it usually looks simple:
Development → API A
Production → API B
Then the app grows.
Suddenly, you have different:
- API endpoints
- Firebase projects
- Branch environments
- Payment environments
- Analytics configuration
- Push notification configuration
- Android package names
- iOS bundle IDs
- Native configuration
And switching between environments starts looking something like this:
Change API URL
Change Firebase file
Change package name
Change Branch config
Change payment config
Build
Remember to change everything back
I wanted to get rid of that workflow.
My goal was simple:
npm run android:dev
npm run android:prod
npm run ios:dev
npm run ios:prod
The command should decide the environment.
I shouldn't have to edit source files before every build.
This is how I approached it.
What I Wanted to Achieve
Before writing any configuration, I defined the workflow I wanted.
For Android:
npm run android:dev
npm run android:prod
For iOS:
npm run ios:dev
npm run ios:prod
And for release builds:
npm run build:apk:dev
npm run build:apk:prod
npm run build:aab:prod
The important part is that these commands should automatically select the correct:
API
Firebase
Branch
Payment environment
App ID
App name
Native configuration
without changing application code.
The Approach
I ended up using three pieces:
1. react-native-config
For environment variables.
2. Android Product Flavors
For creating separate dev and prod Android applications.
3. iOS Schemes + Build Configurations
For doing the equivalent thing on iOS.
The overall idea looks like this:
.env.dev
│
▼
Development
│
┌─────────┴─────────┐
│ │
Android iOS
dev flavor Dev scheme
│ │
▼ ▼
Dev App Dev App
.env.prod
│
▼
Production
│
┌─────────┴─────────┐
│ │
Android iOS
prod flavor Prod scheme
│ │
▼ ▼
Prod App Prod App
Let's go through it step by step.
1. Start With .env Files
The first thing I did was separate environment-specific values.
.env.dev
.env.prod
For example:
# .env.dev
APP_ENV=dev
API_ENDPOINT=https://api.dev.example.com
JUSPAY_ENVIRONMENT=sandbox
BRANCH_TEST_MODE=true
MOENGAGE_APP_ID=dev-app-id
And:
# .env.prod
APP_ENV=prod
API_ENDPOINT=https://api.example.com
JUSPAY_ENVIRONMENT=production
BRANCH_TEST_MODE=false
MOENGAGE_APP_ID=prod-app-id
The actual values will obviously be different for your application.
The important thing is that the application code doesn't need to know which file to use.
The build process will take care of that.
2. Why react-native-config?
I used react-native-config because some of the configuration was needed not only in JavaScript, but also in native Android and iOS code.
In JavaScript:
import Config from 'react-native-config';
console.log(Config.API_ENDPOINT);
On Android:
BuildConfig.API_ENDPOINT
On iOS:
[RNCConfig envFor:@"API_ENDPOINT"];
This gives me one source of environment configuration that can be consumed from different parts of the application.
One thing to know upfront:
.env values are baked in at BUILD time
There is no file reading at runtime. If you change a .env value, you rebuild the app.
3. Android Product Flavors
Android was the easier part because Gradle has a concept called Product Flavors.
I created two flavors:
dev
prod
For example:
android {
flavorDimensions "environment"
productFlavors {
dev {
dimension "environment"
applicationId "com.example.app.dev"
}
prod {
dimension "environment"
applicationId "com.example.app"
}
}
}
Now Android knows that these are two different applications.
That also means I can install both on the same device:
MyApp Dev
MyApp
This is extremely useful during development.
I don't have to uninstall the production version just to test the development version.
4. Understanding Android Variants
This was one of the things that initially confused me.
Android doesn't just have:
dev
prod
because each flavor can be combined with a build type.
So we get:
devDebug
devRelease
prodDebug
prodRelease
Think of it like:
Flavor + Build Type = Variant
For example:
dev + debug = devDebug
prod + release = prodRelease
Once you understand this, commands such as:
./gradlew assembleProdRelease
become much easier to understand.
5. Connect Android Flavors to .env Files
Now we need to tell react-native-config which environment file belongs to which flavor.
In android/app/build.gradle:
project.ext.envConfigFiles = [
devdebug: ".env.dev",
devrelease: ".env.dev",
proddebug: ".env.prod",
prodrelease: ".env.prod",
]
apply from: project(':react-native-config')
.projectDir.getPath() + "/dotenv.gradle"
Now the mapping is:
devDebug → .env.dev
devRelease → .env.dev
prodDebug → .env.prod
prodRelease → .env.prod
This is the part that removes a lot of manual work.
When I build devDebug, the development environment is automatically selected.
When I build prodRelease, the production environment is automatically selected.
6. Different App Names and Package IDs
I also wanted the two applications to be visually distinguishable.
For example:
MyApp Dev
MyApp
and:
com.example.app.dev
com.example.app
You can configure the app name per flavor:
productFlavors {
dev {
dimension "environment"
applicationId "com.example.app.dev"
resValue "string", "app_name", "MyApp Dev"
}
prod {
dimension "environment"
applicationId "com.example.app"
resValue "string", "app_name", "MyApp"
}
}
One small thing to remember here:
If app_name already exists in strings.xml, remove it from there.
strings.xml app_name + flavor resValue app_name
↓
duplicate resource build error
Now I can immediately tell which application I'm opening.
This sounds like a small thing, but when both apps are installed on a device, it makes a big difference.
7. Separate Firebase for Dev and Prod
This was another important part.
I didn't want the development application sending Crashlytics, Analytics, or other Firebase data to the production Firebase project.
Instead of manually replacing google-services.json, I used Android's flavor-specific directories.
The structure looks like:
android/app/src/
├── main/
│
├── dev/
│ └── google-services.json
│
└── prod/
└── google-services.json
Now:
devDebug
↓
src/dev/google-services.json
and:
prodRelease
↓
src/prod/google-services.json
Gradle handles the selection based on the flavor.
This was a big improvement because Firebase configuration stopped being something I had to remember to change manually.
8. Flavor-Specific Android Manifest
The same approach works for other Android-specific configuration.
For example, suppose Branch has a test mode for development and live mode for production.
I can have:
android/app/src/dev/AndroidManifest.xml
android/app/src/prod/AndroidManifest.xml
The development manifest can contain:
<meta-data
android:name="io.branch.sdk.TestMode"
android:value="true" />
while production can contain:
<meta-data
android:name="io.branch.sdk.TestMode"
android:value="false" />
Now:
Dev build → test environment
Prod build → live environment
Again, no manual switching.
9. One react-native-config Issue I Ran Into
This was one of those issues that took some time to figure out.
If your Android namespace and applicationId don't match, react-native-config can have trouble finding the generated BuildConfig class.
In that situation, you may need:
resValue "string", "build_config_package", "com.example.app"
The important distinction is:
applicationId
↓
Identifies the Android application
namespace
↓
Defines where Android generates classes
If these don't line up with what react-native-config expects, you can end up with something like:
console.log(Config);
returning missing or empty values.
And then you may start seeing errors that look completely unrelated, such as API/network failures.
So if react-native-config suddenly appears not to work on Android, this is worth checking.
One More Issue I Ran Into With release builds:
ProGuard can strip BuildConfig fields, because react-native-config reads them through reflection.
Add this to android/app/proguard-rules.pro:
-keep class com.example.app.BuildConfig { *; }
Use your namespace here, not the dev applicationId.
Without it, debug builds work and release builds mysteriously get empty values.
10. Tell React Native Which Variant Is Debuggable
Another small configuration is required because React Native needs to know which flavor should connect to Metro.
For example:
react {
debuggableVariants = ["devDebug"]
autolinkLibrariesWithApp()
}
Now React Native knows:
devDebug → development build → use Metro
while release builds can bundle the JavaScript normally.
This becomes especially important once you introduce custom flavors.
If you skip this, devDebug bundles the JavaScript instead of connecting to Metro — and you lose fast refresh without any error message.
11. iOS Works Differently
Android gives us Product Flavors.
iOS doesn't have exactly the same concept.
For iOS, I used:
- Build Configurations
- Schemes
- Build Settings
The idea is still the same:
Dev
Prod
but the implementation is different.
For example:
Debug
Release
Dev.Debug
Dev.Release
Then I created two schemes:
MyApp
MyApp-Dev
The production scheme uses:
Debug
Release
while the development scheme uses:
Dev.Debug
Dev.Release
So:
MyApp-Dev
↓
Dev.Debug
↓
Development environment
and:
MyApp
↓
Release
↓
Production environment
To create the configurations in Xcode:
Project → Info tab → Configurations
↓
Duplicate "Debug" → "Dev.Debug"
Duplicate "Release" → "Dev.Release"
And mark both schemes as Shared, so your teammates and CI can see them too.
12. Different iOS Bundle IDs
Just like Android has different applicationIds, iOS can have different bundle IDs.
For example:
Production:
com.example.app
Development:
com.example.app.dev
Set PRODUCT_BUNDLE_IDENTIFIER per configuration in Build Settings:
Debug / Release → com.example.app
Dev.Debug / Dev.Release → com.example.app.dev
This means both applications can exist on the same iPhone:
MyApp
MyApp Dev
That makes testing production and development side by side much easier.
For the app name, add a user-defined build setting APP_DISPLAY_NAME per configuration, and reference it in Info.plist:
<key>CFBundleDisplayName</key>
<string>$(APP_DISPLAY_NAME)</string>
13. Selecting the .env File on iOS
This part required a slightly different approach.
With Android, we mapped flavors directly to .env files.
For iOS, I used a Scheme Pre-action.
For the development scheme:
cp "${PROJECT_DIR}/../.env.dev" \
"${PROJECT_DIR}/../.env"
For production:
cp "${PROJECT_DIR}/../.env.prod" \
"${PROJECT_DIR}/../.env"
So the flow becomes:
MyApp-Dev
↓
.env.dev
↓
.env
and:
MyApp
↓
.env.prod
↓
.env
Then react-native-config reads .env.
This is one of the main differences between my Android and iOS setup.
One easy-to-miss detail:
In the pre-action, set "Provide build settings from" to your app target.
Not set → ${PROJECT_DIR} is empty → copy silently fails → stale .env
14. Separate Firebase Configuration on iOS
The same environment separation is needed for Firebase on iOS.
Instead of having one GoogleService-Info.plist, I kept separate files for each environment.
For example:
Firebase/
├── Dev/
│ └── GoogleService-Info.plist
│
└── Prod/
└── GoogleService-Info.plist
Then the Xcode build process copies the appropriate file into the application bundle.
I did that with a small Build Phase run script (placed before any Firebase build phases):
if [[ "${CONFIGURATION}" == *"Dev"* ]]; then
cp "${SRCROOT}/MyApp/Firebase/Dev/GoogleService-Info.plist" \
"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist"
else
cp "${SRCROOT}/MyApp/Firebase/Prod/GoogleService-Info.plist" \
"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist"
fi
Conceptually:
Dev scheme
↓
Dev GoogleService-Info.plist
↓
Dev Firebase project
and:
Prod scheme
↓
Prod GoogleService-Info.plist
↓
Prod Firebase project
This keeps development and production data separated.
One warning from experience:
Both plist files must be real files from the Firebase console.
A placeholder file with dummy values crashes the app on launch, inside [FIRApp configure].
Also update your Podfile so CocoaPods knows about the new configurations:
project 'MyApp.xcodeproj', {
'Debug' => :debug,
'Release' => :release,
'Dev.Debug' => :debug,
'Dev.Release' => :release,
}
Then run pod install.
15. Using the Configuration From JavaScript
Once all of this is configured, application code becomes much cleaner.
Instead of:
const API_URL =
isDevelopment
? "https://api.dev.example.com"
: "https://api.example.com";
I can simply do:
import Config from 'react-native-config';
export const CONFIG = {
API_ENDPOINT: Config.API_ENDPOINT,
JUSPAY_ENVIRONMENT: Config.JUSPAY_ENVIRONMENT,
BRANCH_TEST_MODE: Config.BRANCH_TEST_MODE,
MOENGAGE_APP_ID: Config.MOENGAGE_APP_ID,
};
The application doesn't need to decide whether it is dev or prod.
The build already made that decision.
That's the part I like most about this setup.
One small thing to remember:
Every .env value arrives in JavaScript as a string.
BRANCH_TEST_MODE=false
↓
Config.BRANCH_TEST_MODE === "false" (a truthy string!)
So convert booleans explicitly:
const isTestMode = Config.BRANCH_TEST_MODE === 'true';
16. Native Code Can Use the Same Values
The same configuration can be consumed by native code.
For Android:
val appId = BuildConfig.MOENGAGE_APP_ID
For iOS:
NSString *appId =
[RNCConfig envFor:@"MOENGAGE_APP_ID"];
Careful with the class name on iOS:
RNCConfig → correct
ReactNativeConfig → wrong (it's the module name, not the class)
The wrong name still compiles (Objective-C is forgiving), but fails at runtime. This one cost me an afternoon.
This is useful when initializing native SDKs.
For example:
Firebase
Branch
MoEngage
Payment SDKs
Push notifications
can all have environment-specific configuration.
17. Hide the Complexity Behind npm Scripts
At this point, the underlying setup had become fairly complicated.
But I didn't want every developer to remember Gradle variants and Xcode scheme names.
So I created simple npm scripts.
For example:
{
"scripts": {
"android:dev":
"react-native run-android --mode=devDebug --appId=com.example.app.dev",
"android:prod":
"react-native run-android --mode=prodDebug --appId=com.example.app",
"ios:dev":
"react-native run-ios --scheme MyApp-Dev",
"ios:prod":
"react-native run-ios --scheme MyApp",
"build:apk:dev":
"cd android && ./gradlew assembleDevRelease",
"build:apk:prod":
"cd android && ./gradlew assembleProdRelease",
"build:aab:prod":
"cd android && ./gradlew bundleProdRelease"
}
}
Two flags worth explaining:
--mode
↓
The Gradle variant to build (devDebug, prodDebug, ...)
Newer React Native CLI uses --mode, not the old --variant
--appId
↓
Which app to LAUNCH after installing
Without it, the dev app installs but never opens
Now developers only need to remember:
npm run android:dev
instead of:
./gradlew assembleDevDebug
And:
npm run build:aab:prod
instead of remembering the exact Gradle task.
It's a small abstraction, but it makes the workflow much nicer.
18. What Actually Happens When I Run a Dev Build?
This is the part that helped me understand the whole setup.
When I run:
npm run android:dev
the flow is roughly:
npm run android:dev
↓
devDebug
↓
.env.dev
↓
react-native-config
↓
┌──────┴───────┐
│ │
JavaScript Android
│ │
└──────┬───────┘
↓
Dev App
↓
┌──────┼───────────┐
│ │ │
API Firebase Branch
I don't have to change anything manually.
That's the real benefit.
19. What About Metro?
One question that came up while setting this up was:
Do I need separate Metro servers for dev and prod?
No.
Metro is mainly responsible for bundling JavaScript.
The environment is selected by the application build.
So conceptually:
Metro
│
┌────────┴────────┐
│ │
Dev App Prod App
The important thing is that the native build determines which environment configuration gets injected.
You can start Metro once and build dev, then prod, then dev again against the same instance.
20. When I Clear the Metro Cache
Sometimes after switching branches or changing environment configuration, things can look stale.
In those situations, I use:
npx react-native start --reset-cache
or keep a script such as:
{
"scripts": {
"start:clean":
"react-native start --reset-cache"
}
}
It's particularly useful when:
- environment values appear stale
- you switched branches
- Metro is behaving strangely
- module resolution starts acting unexpectedly
But remember: changing an environment variable doesn't magically change an already-built native application.
Usually, you need to rebuild the app.
21. Building Release APKs
For a development release APK:
npm run build:apk:dev
For a production release APK:
npm run build:apk:prod
And for a Play Store bundle:
npm run build:aab:prod
Under the hood, these map to Gradle tasks such as:
./gradlew assembleDevRelease
./gradlew assembleProdRelease
./gradlew bundleProdRelease
The important thing is that the correct flavor is part of the build command.
So:
assembleDevRelease
↓
.env.dev
↓
Dev application
while:
bundleProdRelease
↓
.env.prod
↓
Production application
22. How I Verify the Environment
One thing I learned while debugging this setup:
Don't assume the environment is correct. Verify it.
During development, I sometimes temporarily log:
console.warn('ENV:', Config.APP_ENV);
console.warn('API:', Config.API_ENDPOINT);
console.warn(
'Payment:',
Config.JUSPAY_ENVIRONMENT
);
For a dev build, I should see something like:
ENV: dev
API: https://api.dev.example.com
Payment: sandbox
For production:
ENV: prod
API: https://api.example.com
Payment: production
This is much faster than trying to figure out from an API error which environment the app is actually using.
And on Android, you can confirm both apps are installed side by side:
adb shell pm list packages | grep example
# expected: com.example.app.dev AND com.example.app
23. Adding a New Environment Variable
Once the setup is working, adding another variable is straightforward.
Suppose we need:
NEW_API_KEY
Add it to both files:
# .env.dev
NEW_API_KEY=dev-value
# .env.prod
NEW_API_KEY=prod-value
Then:
import Config from 'react-native-config';
const apiKey = Config.NEW_API_KEY;
If native code needs it, expose/use it there as well.
The important thing is to keep the variable name consistent:
.env.dev
↓
.env.prod
↓
JavaScript
↓
Native code
And rebuild — remember, values are baked in at build time.
24. What My Final Workflow Looks Like
After putting all of this together, my daily workflow becomes very simple.
Working on development
npm run android:dev
or:
npm run ios:dev
Testing the production environment locally
npm run android:prod
or:
npm run ios:prod
Creating a development APK
npm run build:apk:dev
Creating a production APK
npm run build:apk:prod
Creating a production AAB
npm run build:aab:prod
That's the workflow I was trying to achieve from the beginning.
25. What I Like About This Approach
The biggest improvement isn't actually the commands.
It's the fact that the environment selection became predictable.
Before:
Developer
↓
Remember what needs to change
↓
Change several files
↓
Build
↓
Hope nothing was missed
After:
Developer
↓
Choose dev or prod
↓
Build system selects configuration
↓
Correct app is built
That removes a whole category of mistakes.
26. A Few Things I Learned
Don't treat .env as the entire environment configuration
Some configuration lives outside JavaScript.
Firebase files, Android manifests, bundle IDs, package IDs, and native SDK configuration may also need to be environment-specific.
So think about the whole build, not just API URLs.
Give dev and prod different app IDs
This lets you install both applications on the same device.
It's one of the most useful parts of having flavors/schemes.
Keep Firebase projects separate
Your development app shouldn't accidentally send analytics, Crashlytics, or other data to production.
Make the commands boring
A good build system should make the common workflow boring:
npm run android:dev
is much easier to remember than a collection of Gradle and Xcode commands.
Verify before debugging
If an API call suddenly starts failing, first check:
Which environment am I running?
Which API endpoint am I using?
Which Firebase project am I using?
It can save a surprising amount of time.
Fail toward the safe value
For anything payment-related, add a fallback:
environment: Config.JUSPAY_ENVIRONMENT || 'production'
If a variable is ever missing, defaulting to production is safer than crashing — or worse, silently using sandbox in a real build.
Final Setup
The final architecture is basically:
React Native
│
┌────────────┴────────────┐
│ │
Dev Prod
│ │
.env.dev .env.prod
│ │
┌────────┴────────┐ ┌────────┴────────┐
│ │ │ │
Android iOS Android iOS
dev flavor Dev scheme prod flavor Prod scheme
│ │ │ │
▼ ▼ ▼ ▼
Dev App Dev App Prod App Prod App
And the developer experience becomes:
npm run android:dev
npm run android:prod
npm run ios:dev
npm run ios:prod
The key idea is simple:
Don't make developers manually switch environments. Make the build system do it.
Once you have this foundation, adding another environment like qa or staging becomes a matter of adding the corresponding flavor/scheme and configuration rather than rewriting application logic.
Top comments (0)