React Native Multi-Environment Setup with EAS Build and APP_VARIANT
Somewhere in your team's history, someone shipped a build pointed at the staging API to a real user. Maybe it was a TestFlight beta, maybe it was an internal QA build that somehow reached a customer device. The chain of events is always the same: a developer made a local change to the API URL, ran eas build, forgot to revert it, and the artifact went out the door configured for the wrong environment. No CI check caught it because the wrong URL is still a valid URL. No type error surfaced it. The build passed every test.
This is the problem the APP_VARIANT pattern in Expo EAS Build actually solves — not "parallel installs on one device," which is the feature most tutorials lead with, but environment as a property of the artifact rather than a property of the developer's local machine at the moment they ran the build command. The parallel install capability is a side effect of solving the real problem: when your bundle identifier, display name, Firebase configuration, and OTA update channel are all derived from a single variable injected at build time, there is no longer a step in your process where a human can forget to swap a value.
The Landscape Before This
For years, React Native teams reached for a grab bag of partial solutions. The most common was react-native-config, which reads per-environment .env files at runtime and surfaces them via a native bridge. It works, and it's framework-agnostic, but it doesn't solve the parallel install problem because bundle identifiers are still hardcoded in AndroidManifest.xml and Info.plist. You can have different API URLs per environment, but both builds share a bundle ID, which means they overwrite each other on device. QA teams developed muscle memory for the uninstall-reinstall cycle.
Android product flavors and iOS schemes are the native-platform solution, giving you genuine build-time branching with separate bundle IDs, signing configurations, and resource directories per environment. If you're already running Fastlane, this is probably what you're using. The trade-off is maintenance cost: adding a new environment means touching build.gradle, the Xcode scheme list, the Fastfile, and the signing configuration. The overhead per new environment is roughly 2–3x higher than the EAS approach, and Expo's managed workflow doesn't support it directly.
The third category — bash scripts and sed one-liners that mutate app.json before each build — is more common than most teams admit. It works until someone runs the build command from a CI branch that doesn't have the mutation script wired up, or until the JSON template drifts out of sync with the actual fields the build system reads.
What all three approaches share is that environment configuration is a property of the invocation context — the developer's machine state, the CI environment, the script that was remembered to run. EAS Build with APP_VARIANT moves that configuration upstream, into the build profile definition itself.
How the APP_VARIANT Pattern Works
The mechanical foundation is straightforward. Starting with EAS CLI 16.19.3 and an Expo account with EAS Build configured, you define three build profiles in eas.json:
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": { "APP_VARIANT": "development" }
},
"preview": {
"distribution": "internal",
"channel": "preview",
"env": { "APP_VARIANT": "staging" }
},
"production": {
"autoIncrement": true,
"channel": "production",
"env": { "APP_VARIANT": "production" }
}
}
}
The development profile enables the Expo development client and uses internal distribution for team sideloading. preview targets an internal distribution channel wired to a specific EAS Update OTA channel named preview. production enables automatic version incrementing and routes to the production OTA channel. Each profile injects APP_VARIANT as a build-time environment variable.
The static app.json is replaced with a dynamic app.config.ts:
import { ExpoConfig, ConfigContext } from "expo/config";
const variant = process.env.APP_VARIANT ?? "development";
const configs = {
development: {
name: "MyApp Dev",
bundleId: "com.mycompany.myapp.dev",
androidPackage: "com.mycompany.myapp.dev",
apiUrl: "https://api.dev.mycompany.com",
googleServicesFile: "./google-services.dev.json",
googleServicesPlist: "./GoogleService-Info.dev.plist",
},
staging: {
name: "MyApp Staging",
bundleId: "com.mycompany.myapp.staging",
androidPackage: "com.mycompany.myapp.staging",
apiUrl: "https://api.staging.mycompany.com",
googleServicesFile: "./google-services.staging.json",
googleServicesPlist: "./GoogleService-Info.staging.plist",
},
production: {
name: "MyApp",
bundleId: "com.mycompany.myapp",
androidPackage: "com.mycompany.myapp",
apiUrl: "https://api.mycompany.com",
googleServicesFile: "./google-services.json",
googleServicesPlist: "./GoogleService-Info.plist",
},
} as const;
const config = configs[variant as keyof typeof configs] ?? configs.development;
export default ({ config: baseConfig }: ConfigContext): ExpoConfig => ({
...baseConfig,
name: config.name,
ios: {
bundleIdentifier: config.bundleId,
googleServicesFile: config.googleServicesPlist,
},
android: {
package: config.androidPackage,
googleServicesFile: config.googleServicesFile,
},
extra: { apiUrl: config.apiUrl },
updates: { url: "https://u.expo.dev/your-project-id" },
runtimeVersion: { policy: "sdkVersion" },
});
When you run eas build --profile preview, EAS Build injects APP_VARIANT=staging before evaluating app.config.ts. Every downstream value — the bundle identifier written into the iOS Info.plist, the Android package name, the Firebase config file path, the OTA channel routing — derives from that single injected string. The resulting .ipa or .apk is immutably scoped to its environment. There's no step after this where a human touches the configuration.
The OTA channel scoping deserves emphasis. When you later run eas update --channel production, the update reaches only devices running builds from the production profile. Devices running preview builds stay on the preview channel. This isn't just convenient — it's a safety boundary that prevents a JS bundle update intended for internal QA from reaching App Store users.
The Insight Most Tutorials Miss
The "run staging and production side-by-side on one device" story is real and useful. QA teams benefit immediately. But if you've shipped a wrong-environment build to production users, you know that parallel installs weren't the problem you were trying to solve.
When your app.config.ts branches on APP_VARIANT, the artifact itself becomes the source of truth for which environment it targets. There's no shared mutable state between the development laptop and the build server. A production profile build contains production credentials and points at production infrastructure — that's a fact about the binary, not a fact about what the developer remembered to do before running the build command.
The long-term operational win is auditability. When a QA engineer files a bug report, the app name visible on their device screen — MyApp Staging rather than MyApp — tells you immediately which artifact they were running, without asking. The OTA channel visible in your Expo dashboard tells you exactly which JS bundle version was active at the time of the bug, without needing them to navigate to a Settings screen or read out a build number. Teams that have been through a major incident involving a wrong-environment production deploy will recognize this as the actual value proposition. It's not the feature; it's the property.
The APP_VARIANT pattern has existed as a community practice for years, assembled from bash scripts and manual app.json templating. EAS Build 16.19.3 and the app.config.ts dynamic config system make it the lowest-effort path, not just the most correct one. That matters: the right approach is now also the easy approach, which is the only way patterns actually get adopted consistently across a whole team.
The EAS lock-in is a real trade-off that should be evaluated honestly. This configuration model is deeply entangled with EAS Build's profile system. If your team later wants to move to a self-hosted CI pipeline or integrate more deeply with Bitrise or Fastlane, you're unwinding a non-trivial amount of configuration logic that currently lives in eas.json and app.config.ts. Teams on managed Expo workflow get the most value with the least friction. Teams already running bare workflow with significant custom native code will encounter more friction because every new native module needs to account for the variant switching.
Practical Implications
Before you ship this to production, fix three things.
First: the local development default. APP_VARIANT is read at JS bundle evaluation time in app.config.ts, but native values like bundle ID are baked at build time. The ?? "development" fallback is intentional for local development, but it means a developer running a local build without setting APP_VARIANT silently gets the development branch. On codebases where the development config is lightly guarded, this is acceptable. On codebases where the production branch is the fallback, a missing env var produces a binary with production credentials and no warning surfaces. Add an explicit guard for EAS builds:
const variant = process.env.APP_VARIANT;
if (!variant && process.env.EAS_BUILD === "true") {
throw new Error("APP_VARIANT must be set in all EAS build profiles");
}
Second: autoIncrement: true on the production profile causes version collisions if you ever trigger a local build for debugging — for example, eas build --local --profile production. The locally built binary receives the same version number as a cloud build in the same sequence. TestFlight and Play Console upload validation will reject or flag the conflict. Reserve autoIncrement for cloud builds and manage version numbers manually for any local production builds you need.
Third: Firebase config files. The google-services.json and GoogleService-Info.plist files for each environment are typically committed to the repository, which is a credential exposure antipattern for production values. The initial tutorial flow is accurate as a starting point, but production Firebase credentials should be stored in EAS Secrets and written to disk during the build process before your first real production push — not after.
Ops considerations that need explicit runbooks:
Each bundle ID registers as a separate App Store Connect and Google Play Console entry, meaning separate review histories and separate crash reporting buckets. Your alerting needs to be configured per app ID. It's easy to configure Crashlytics alerts for your production app ID and miss that staging crashes are routing to a different Firebase project entirely.
OTA channel scoping creates a hotfix coordination problem: a critical bug fix pushed to the production channel won't reach users running preview builds unless you push to both channels explicitly. Your release process must account for this — especially if preview builds are distributed to external beta testers.
EAS Build concurrency limits become visible quickly when three profiles trigger simultaneously on a merge to main. Teams on free or basic EAS plans will queue 20–40 minutes at peak hours when development, preview, and production builds all fire together. Structure your CI workflow to trigger builds selectively per branch rather than all three on every merge.
When to Use This Pattern
Choose the EAS APP_VARIANT approach if you're on managed or bare Expo and your team isn't deeply invested in native toolchains. The setup overhead is low, the operational wins arrive immediately, and the pattern scales cleanly as you add environments.
Choose Android product flavors and iOS schemes if you're already running Fastlane end-to-end, need fine-grained control over signing and native build configuration, or have a dedicated mobile infrastructure team that can absorb the per-environment maintenance cost.
Consider react-native-config with per-environment .env files as a lightweight option if your primary goal is runtime API branching and you don't need parallel device installs. It requires no EAS coupling, but it leaves bundle IDs hardcoded in native files and doesn't give you the build-artifact-as-source-of-truth guarantee.
The question to ask is not "is EAS better than Fastlane" but "is build-time enforcement and auditability worth the portability cost for our team at our current stage." For most Expo teams managing dev, staging, and production tracks, the answer is yes — and the answer becomes clearer after the first time a QA engineer files a bug report and you immediately know from the app name on their screen exactly what they were running.
Sources & Editorial Disclosure
This article was researched and written with AI assistance (Claude by Anthropic) as part of StackRadar's automated editorial pipeline. Content was synthesised from the following public developer community sources: Dev.to.
All technical claims, version numbers, benchmarks, and project details should be independently verified against official documentation or the original sources listed above. StackRadar analyses and synthesises publicly available information and does not claim original authorship of the underlying events, projects, or research described. Mention of any project, product, or organisation does not constitute an endorsement by StackRadar. This content is provided for informational purposes only — 2026-07-16.