skip to content

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