In an Expo project, how do EXPO_PUBLIC_ variables from .env files get into the app, and what limits does build-time inlining impose?
answer
- literal substitution in release bundles
- prefix decides what is inlined
- dot notation only, no brackets
- node_modules left untouched
- release: new build or update to change
basics
~20 sExpo CLI loads .env files, and Metro replaces each static process.env.EXPO_PUBLIC_NAME reference in your code with its literal value when it builds a release bundle. In a release bundle the value is frozen as plain text until the next build or update.
solid answer
~40 sExpo CLI loads the project's `.env` files (standard dotenv resolution, so `.env.local` overrides `.env`) into its process. When Metro builds a release bundle, every static reference `process.env.EXPO_PUBLIC_NAME` in your source is replaced with the literal string. That has consequences. Only the dot-notation form is inlined: `process.env['EXPO_PUBLIC_X']` or destructuring from `process.env` is not. Only variables with the `EXPO_PUBLIC_` prefix reach the bundle, and code in `node_modules` is not rewritten. In development the CLI picks up `.env` edits without a restart (the docs ask for a full reload of the app), but in a release the value is fixed when the bundle is made, so changing it means a new build or update. And because the value sits in the bundle as plain text, nothing secret belongs there.
code
typescript · 13 lines// config.ts
const apiUrl = process.env.EXPO_PUBLIC_API_URL; // inlined as a literal in release bundles
// Not inlined: may work in development (a dev-only process.env object exists)
// but read undefined in a release bundle
// const { EXPO_PUBLIC_API_URL } = process.env;
// const url = process.env['EXPO_PUBLIC_API_URL'];
if (!apiUrl) {
throw new Error('EXPO_PUBLIC_API_URL is not set for this bundle');
}
export const config = { apiUrl };go deeper
Remember the EXPO_PUBLIC_ prefix, the process.env.EXPO_PUBLIC_NAME form, and that the value is visible to anyone with the app.
Explain that Metro substitutes static references when it makes a release bundle, why bracket access and destructuring fail, and why only prefixed keys reach the client.
Diagnose wrong-environment bundles by asking where the bundle was made, and centralise reads in one validated config module.
Decide which values may live in the client at all and make the bundling environment, not the developer's laptop, the source of truth for each release.
## What an EXPO_PUBLIC_ variable is An **environment variable** here means a key-value pair defined outside the source, typically in a `.env` file at the project root, used to vary behaviour per environment: an API base URL, a feature toggle, a public map key. Expo supports this without a plugin, with one rule: only variables whose names start with `EXPO_PUBLIC_` are made available to your app's JavaScript. ```bash # .env EXPO_PUBLIC_API_URL=https://staging.api.example.com ``` ## How the value gets into the bundle There are two steps, and both happen **on your machine or the build server, not on the phone**: 1. **Loading.** Whenever Expo CLI runs (`npx expo start`, `npx expo export`, or the bundling step of a release build), it loads the `.env` files into its own `process.env` following the standard dotenv file resolution, so `.env.local` can override `.env`. Variables already set in the shell are also visible. 2. **Inlining.** When Metro makes a production bundle, each `process.env.EXPO_PUBLIC_API_URL` expression is **replaced by the literal string**. The shipped bundle contains `"https://staging.api.example.com"`, not a lookup. There is no environment on the device to read from later; in a release, inlining is text substitution at bundle time. In development, Expo's Metro setup routes the same references through a small generated module that reads the `.env` files, keeping only `EXPO_PUBLIC_` keys for the client, so edits can reach the running app without restarting the CLI. The static-reference rules below apply in both modes. ## The limits inlining imposes - **Static dot notation only.** `process.env.EXPO_PUBLIC_KEY` is inlined. `process.env['EXPO_PUBLIC_KEY']`, `const { EXPO_PUBLIC_KEY } = process.env`, or building the name in a variable are **not**. The trap is that they can appear to work in development, where Expo injects a dev-only `process.env` object carrying the `EXPO_PUBLIC_` values, and then read `undefined` in a release bundle. - **Prefix required.** A `.env` entry without the prefix is not inlined into the app. Node-side code such as `app.config.ts` can still read it, because it runs inside Expo CLI's process. - **Your code only.** For security, code inside `node_modules` is not rewritten. - **Frozen per release bundle.** In development, editing `.env` needs no CLI restart or cache clear; the Expo docs recommend a **full reload** of the app to be sure it shows the new value. In a release, the value is whatever was present when that binary's bundle or that update was exported; changing it means a new build or a new update. - **Public by construction.** The literal ends up in the JavaScript anyone can extract from the app package. The Expo docs warn never to put private keys in `EXPO_PUBLIC_` variables. ## Choosing which .env file loads | Approach | Behaviour | Verdict | |---|---|---| | `.env` committed with safe defaults | loaded everywhere | fine | | `.env.local` gitignored | per-machine overrides, higher priority | recommended for local tweaks | | `NODE_ENV=test` to pick `.env.test` | works for `npx expo start`, but `npx expo export` always forces `NODE_ENV=production` | discouraged | | A script (or `eas env:pull`) that rewrites `.env.local` | explicit and predictable | recommended | Two switches exist for debugging: `EXPO_NO_DOTENV=1` stops Expo CLI loading `.env` files, and `EXPO_NO_CLIENT_ENV_VARS=1` stops the inlining into the client bundle. ## Worked example: a car-sharing staging bundle Suppose a car-sharing app reads `process.env.EXPO_PUBLIC_API_URL` in its booking client, and the committed `.env` holds the staging URL while a developer's gitignored `.env.local` points at a local server. 1. On the developer's laptop, `.env.local` overrides `.env`, so a dev session talks to the local server. 2. On a build server that checks out the repository, the gitignored `.env.local` is absent, so the release bundle is inlined with the staging URL from `.env`. 3. If someone later edits `.env` to the production URL, the installed staging build keeps the staging literal; only the next build or update carries the new one. Each step follows from one rule: the value is decided where and when the bundle is made. ## Reading them well - Read each variable once in a small config module and export typed constants, so a missing value is caught in one place. - Treat a missing value as an error at startup rather than letting `fetch(undefined)` fail later. - Remember the value came from the machine that bundled the app. When a tester reports the wrong API host, the first question is which `.env` was present where that bundle was made.
- A test build points at the wrong API even though the repository's .env is correct; where do you look?At the environment that bundled it. `EXPO_PUBLIC_` values are inlined when Metro builds the bundle, so check which `.env` files, shell variables or EAS environment were present on the machine or build job that produced that binary or update. A gitignored `.env.local` on a laptop is not present on a build server.
- Destructuring EXPO_PUBLIC_ variables from process.env worked in development but the release build reads undefined; why?In development, Expo's Metro setup injects a `process.env` object holding the `EXPO_PUBLIC_` values so they survive Fast Refresh, so any access style works. A release bundle relies on inlining, which only replaces static `process.env.EXPO_PUBLIC_NAME` expressions; destructuring is left as a lookup on an object that no longer holds them.
- Why can app.config.ts read API_URL from .env while app code cannot?Expo CLI loads the `.env` files into its own Node process before evaluating the app config, so `app.config.ts` sees every variable. Only the `EXPO_PUBLIC_` ones are inlined into the client bundle; an unprefixed value reaches the app only if the config deliberately copies it into a field such as `extra`.
saying these in an interview costs you the question
- The app reads EXPO_PUBLIC_ variables from the device environment at runtime
- Destructuring from process.env works the same as dot access
- Changing .env updates installed release builds automatically
- EXPO_PUBLIC_ values are hidden once the app is compiled
- Any .env variable is inlined, the prefix is only a convention
- Libraries in node_modules get the values inlined too