skip to content

In an Expo project, how do EXPO_PUBLIC_ variables from .env files get into the app, and what limits does build-time inlining impose?

level: middleimportance: must knowfreq 58%

answer

  1. literal substitution in release bundles
  2. prefix decides what is inlined
  3. dot notation only, no brackets
  4. node_modules left untouched
  5. release: new build or update to change

basics

~20 s

Expo 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 s

Expo 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
typescript
// 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

for a junior

Remember the EXPO_PUBLIC_ prefix, the process.env.EXPO_PUBLIC_NAME form, and that the value is visible to anyone with the app.

for a middle

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.

for a senior

Diagnose wrong-environment bundles by asking where the bundle was made, and centralise reads in one validated config module.

for a principal

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