React Native Environment Setup: Managing Dev, Prod, and Staging Builds with Android Flavors and iOS Schemes
DEV Community

React Native Environment Setup: Managing Dev, Prod, and Staging Builds with Android Flavors and iOS Schemes

React Native Environment Setup: Managing Dev, Prod, and Staging Builds with Android Flavors and iOS Schemes

During my React Native development journey, I faced several issues managing dev and prod environments - API URLs, Firebase configuration, Android flavors, iOS schemes, app IDs, and release builds. Here's how I solved them and made environment-specific builds easier to manage. 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, and native configuration. 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:

Android: npm run android:dev, npm run android:prod
iOS: npm run ios:dev, npm run ios:prod
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, and native configuration without changing application code.

The Three-Piece 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 dev   β”‚                 β”‚ β”‚
                   β”‚ Dev scheme    β”‚                 β”‚ β”‚
                   β”‚ β–Ό β–Ό Dev App   β”‚                 β”‚ β”‚
.env.prod          β”‚              β”‚                 β”‚ β”‚
                    β”‚ Android prod |                 β”‚ β”‚
                    β”‚ Prod scheme  β”‚                 β”‚ β”‚
                    β”‚ β–Ό β–Ό 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 into .env.dev and .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.for("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:

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 and MyApp. This is extremely useful during development. I don't have to uninstall the production version just to test the development version.

Understanding Android Variants

This was one of the things that initially confused me. Android doesn't just have dev and 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.

4. 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')

The mapping is now:

Variant .env File
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.

5. Different App Names and Package IDs

I also wanted the two applications to be visually distinguishable. For example:

  • MyApp Dev
  • 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: If app_name already exists in strings.xml, remove it from there. Otherwise, you'll get a build error.

6. 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

So 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.

7. 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.

8. One react-native-config Issue

I ran into this issue 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.

9. One More Issue with Release Builds

Another 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. Telling 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

iOS gives us Product Flavors. However, for iOS, I used Build Configurations, Schemes, and Build Settings.

The idea is still Dev vs Prod, but the implementation is different. For example:

  • Debug β†’ Dev.Debug
  • Release β†’ Dev.Release

So:

  • MyApp-Dev ↓ Dev.Debug ↓ Development environment
  • MyApp ↓ Release ↓ Production environment

To create the configurations in Xcode:

  1. Project β†’ Info tab β†’ Configurations
  2. Duplicate "Debug" β†’ "Dev.Debug"
  3. 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 and MyApp-Dev. It 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:

  • Development scheme: Copy .env.dev β†’ .env
  • Production scheme: Copy .env.prod β†’ .env

So the flow becomes:

  • MyApp-Dev ↓ .env.dev ↓ .env
  • MyApp ↓ .env.prod ↓ .env

Then react-native-config reads .env.

One easy-to-miss detail: In the pre-action, set "Provide build settings from" to your app target. If unset, ${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:

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.

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:

target '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. So BRANCH_TEST_MODE=false becomes Config.BRANCH_TEST_MODE === "false" (a truthy string!). Therefore, 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 for: @"MOENGAGE_APP_ID"]

Careful with the class name on iOS: ReactNativeConfig β†’ incorrect (it's the module name, not the class). The wrong name still compiles (Objective-C is forgiving), but fails at runtime.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.