skip to content

Build Variants & Env Config

There is no runtime process.env: values are inlined into the bundle at build time or read from native build config, and each environment needs its own variant. Interviewers probe what leaks.

part ofReact Nativeoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In a React Native app, why is process.env.API_URL undefined at runtime, and how do per-environment values actually reach the JavaScript?

level: middleimportance: must knowfreq 58%

answer

  1. no Node process on the device
  2. process.env holds only NODE_ENV
  3. Metro inlines NODE_ENV and __DEV__
  4. Babel plugin vs native build config
  5. inlined means readable by anyone

basics

~20 s

React Native's JavaScript runs on the device with no Node process, so process.env holds only NODE_ENV. Per-environment values must be inlined into the bundle at build time or read from native build config, and both are readable in the shipped app.

solid answer

~50 s

There is no runtime environment to read: the bundle runs inside the app on the phone, and React Native's startup code creates `process.env` with only `NODE_ENV`, derived from `__DEV__`. In release bundles Metro also replaces `process.env.NODE_ENV` and `__DEV__` with literals. Anything else has to be baked in when the app is built: a Babel plugin such as `react-native-dotenv` substitutes values while Metro transforms the code, while `react-native-config` reads a `.env` file during the Gradle or Xcode build and hands values to native code and to JS. So the staging and production builds of an app are separate builds; with the Babel route a changed `.env` needs a Metro `--reset-cache`, with the native route a rebuild. And everything inlined is readable in the installed app, so only public config such as an API base URL belongs there.

code

typescript · 3 lines
typescript
// config.ts in a bare React Native 0.87 app with no env plugin installed
console.log(process.env.NODE_ENV); // 'development' or 'production'
console.log(process.env.API_URL); // undefined: no build step wrote it

go deeper

for a junior

Recall that the app has no Node process: process.env holds only NODE_ENV, and every other value must be written into the build.

for a middle

Explain the two routes, a Babel plugin inlining at transform time versus native build config read by Gradle or Xcode, and what each needs when a value changes.

for a senior

Show you have debugged it: the stale Metro cache after a .env change, a staging build accidentally pointing at production, and a key someone assumed was hidden.

for a principal

Frame env config as public, per-build settings: decide what may ship in the binary, what must come from a server, and how many builds that commits you to.

## Why there is no runtime environment to read On a server, `process.env` is filled by the operating system when the Node process starts: whatever the shell, the container or the CI job exported is there to read. A **React Native** app has no such moment. Its JavaScript is a **bundle** that Metro produced on a build machine, and that bundle runs inside the app on the phone, in Hermes, launched by the native app — not by a shell. Nothing on the device ever sees your `.env` file or the variables your terminal exported. React Native still creates a `process` global for libraries that expect one. During startup, its global setup code runs roughly this: - create `process` and `process.env` as empty objects if they do not exist; - set `process.env.NODE_ENV` to `'development'` or `'production'`, derived from `__DEV__`; - set nothing else. So `process.env.API_URL` is `undefined` unless some build step wrote a literal into the code. ## What React Native and Metro define for you | Identifier | Who sets it | Value | |---|---|---| | `__DEV__` | Metro, per bundle | `true` for a development bundle; replaced by the literal `false` in a release bundle | | `process.env.NODE_ENV` | Metro inlines it in release bundles; React Native's global setup covers dev | `'production'` in release, `'development'` in dev | | any other `process.env.X` | nobody | `undefined` | In a non-dev bundle, Metro's transform replaces `__DEV__` and `process.env.NODE_ENV` with literals and then runs a **constant-folding** pass, so `if (__DEV__) { ... }` blocks are deleted from the shipped code. Both flags describe the **bundle mode**, not which backend the build should talk to. ## Two ways to get your own values in | | Babel-plugin inlining | Native build config | |---|---|---| | Example library | `react-native-dotenv` | `react-native-config` | | When the value is read | when Metro transforms each JS file | when Gradle or Xcode builds the app | | Where it lives afterwards | a string literal inside the JS bundle | the native build config (and the JS reads it back at runtime) | | Also visible to native code | no | yes — Gradle, Xcode build settings, native code | | What a changed value needs | a Metro cache reset | a native rebuild | Both are **build-time** mechanisms. The React Native docs list these two libraries for "environment-specific variables like API endpoints" and warn that they must not be confused with server-side environment variables. In an Expo project the equivalent is Expo's own `EXPO_PUBLIC_` convention, which follows the same inlining model. ## The Metro cache trap Metro caches the output of every transformed file. Its cache key is built from the file's contents and the transformer configuration — React Native's Babel transformer hashes the preset version and its own source — **not** from the values a Babel plugin happened to read from `.env`. Change `API_URL` in `.env`, reload, and Metro may happily serve the cached module with the old literal. The fix is to restart Metro with the cache cleared: ```bash npx react-native start --reset-cache ``` The native-config route has the opposite cost: the JS may pick up nothing new until the app itself is rebuilt, because the value lives in native build output. ## What leaks, and what belongs in env config Everything that reaches the app this way is **readable by anyone who has the app**: a string in the bundle or in the native build config can be pulled out of the installed package. That is fine for public configuration: 1. API base URLs, such as the staging and production endpoints of a food-delivery app; 2. feature flags that are not security boundaries; 3. identifiers that are designed to be public and ship in every client. It is not fine for anything that grants privileges on its own; how to handle those is a security question, not a build-variant one. Env config is a way to **vary** public settings per build, not a vault. ## One build per environment Because the value is baked in, **each environment is its own build**: the staging build of the food-delivery app contains the staging URL, the production build the production URL, and you cannot flip one binary from staging to production the way a container image is promoted between clusters. Teams therefore pair env config with **build variants** — Android product flavors and iOS build configurations with schemes — so that each environment has its own app identity and its own config file, selected by the build rather than by a shell variable at runtime.

  • Can a React Native team build one binary and promote it from staging to production?
    Not while the API URL is baked into the bundle or the native build config: the staging binary contains the staging URL. Each environment is its own build, usually with its own bundle ID. The alternative is fetching configuration at launch from a bootstrap endpoint, but that endpoint's address is itself baked in, so it only moves the problem.
  • You changed API_URL in .env, reloaded, and the app still calls the old host. Why?
    If a Babel plugin inlines the value, Metro served a cached transform: its cache key covers file contents and transformer config, not the env values the plugin read. Restart Metro with `--reset-cache`. If the value comes through native build config instead, the app has to be rebuilt.
  • Where does process.env.NODE_ENV come from in React Native, then?
    React Native's global setup creates `process.env` and sets `NODE_ENV` from `__DEV__` if nothing set it. In a release bundle Metro replaces `process.env.NODE_ENV` with the literal `'production'` before the code ever runs. It tells you the bundle mode, not the backend.

saying these in an interview costs you the question

  • process.env works on the device the same way it does in Node
  • A gitignored .env file keeps its values private in the shipped app
  • Exporting a variable in the shell that runs Metro makes it readable on the phone
  • One release binary can switch environments by reading env vars at launch
  • An edited .env always shows up on the next reload without a cache reset
open as a page

In React Native, what does the global __DEV__ flag tell you, and why is it the wrong switch for staging versus production APIs?

level: juniorimportance: should knowfreq 42%

basics

~20 s

DEV is true only when the JavaScript bundle was built in development mode and false in any release bundle. It says nothing about the backend: a staging build installed by testers is a release build, so it sees false exactly like production.

open as a page

In a bare React Native app, how do you let staging and production builds install side by side, each pointing at its own API?

level: middleimportance: should knowfreq 45%

basics

~20 s

Give each environment its own native variant and app identity: Android product flavors with applicationIdSuffix, iOS build configurations with their own bundle identifier selected by a scheme. Each variant then feeds its own config file, which carries the API URL into the build.

open as a page

After adding staging variants to a bare React Native app, how do the Android and iOS build hooks decide whether a variant embeds a JS bundle or loads from Metro?

level: seniorimportance: should knowfreq 24%

basics

~20 s

On Android, the React Native Gradle Plugin bundles every variant not named in debuggableVariants, whose default lists only debug and debugOptimized. On iOS, the bundling build phase treats configurations whose name contains Debug as development builds. Flavored variants break both assumptions.

open as a page