Articles · GuidesUpdated October 2026

Expo reads two modes, you have three environments, and that is why nothing reads your .env.staging file.

Expo CLI picks which .env files to load from the mode a command runs in, and there are two modes, development and production. A file called .env.staging is not one of them. The three environment names you actually want exist, but they live in build profiles in eas.json and in EAS environment variables, not in file names. Everything below is read off the Expo documentation and Apple's App Store Connect Help on 17 October 2026, with the version qualifiers Expo carries, because this behaviour changed in SDK 58 and the documentation does not say what came before.

Then the part that costs real money. A staging build that can sit on the same phone as production needs its own bundle identifier, and Apple says a bundle identifier is spent the moment you upload a build under it. So the name you invent for a throwaway test track is permanent. Choose it once.

Start here

The short version

Two modes decide the files. Profiles decide the environment.

Three separate mechanisms are at work, and people lose a day because they look like one. The first is mode, which is a property of the command you ran and decides which .env files are read. The second is the build profile in eas.json, which is a name you chose and can carry its own variables. The third is EAS environment variables, which are stored on the server, grouped into three environments, and have their own rules about who may read them.

Get the mapping right and the rest is mechanical. One build profile per environment, a plain variable inside it, and a dynamic config that turns that variable into an identifier and a name. What you must not do is invent a file name and expect the CLI to find it.

Why does nothing read your .env.staging file?

Because the file list is fixed and short. Expo's guide says that in SDK 58 and later, Expo commands do not load other .env files, such as .env.staging or .env.test. What they do load is decided by mode. In development mode the order is .env.development.local, then .env.local, then .env.development, then .env. In production mode it is .env.production.local, .env.local, .env.production, .env. Files listed first take priority, so .env loses every conflict.

Mode is a property of the command, not something you pass. Starting the dev server is development mode, and the no dev flag makes it production. Exporting is production mode, and the dev flag makes it development. Running on Android is development mode for the debug and debugOptimized variants and production otherwise. Running on iOS is development mode for any configuration with Debug in the name. Prebuild, config and the doctor command run in development mode locally and in production mode on EAS Build, which is a difference worth remembering the first time a config value is right on your laptop and wrong in CI.

The obvious workaround does not work either. Expo says that in SDK 58 and later each command sets NODE_ENV to its mode and replaces any value you set, and its own example is that starting the dev server with NODE_ENV set to production still runs in development mode and still loads .env.development. On the general idea, the guide says that for React Native projects it is best not to overload NODE_ENV further to switch between .env files. There are two documented switches if you need them: EXPO_NO_DOTENV set to 1 stops .env files loading, and EXPO_NO_CLIENT_ENV_VARS set to 1 stops the inlining into the client bundle.

One prefix decides what your app code can see, and it is EXPO_PUBLIC_. Expo loads variables with that prefix from .env files and replaces a reference such as process.env.EXPO_PUBLIC_API_URL with its value. Two mechanical details catch people. The replacement needs a literal dot notation reference, so a bracket lookup or a destructured variable is not inlined and arrives as undefined at runtime. And a variable without the prefix is not missing, it is simply not in the bundle: it is available to the build and to your config file, and nowhere else. What the prefix means for anything you would rather not ship is a separate subject with its own page. For git, Expo recommends the .gitignore entry .env*.local, because those files hold machine specific settings such as a local network IP address.

Check your SDK before you trust any of this. The documentation qualifies both the file list and the NODE_ENV behaviour with SDK 58 and later, and it does not say what Expo CLI did before that, so an older inherited project is a question you answer by testing. The reference currently documents SDK 58, 57 and 56, and a project that predates them is a separate job. If yours is behind, start with the Expo SDK release notes and work out how far back you are.

Expo, environment variables in Expo, read 17 October 2026, page last modified 12 October 2026

The fileIn development modeIn production modeCommit it
.env.development.localLoaded firstNoNo
.env.production.localNoLoaded firstNo
.env.localSecondSecondNo
.env.developmentThirdNoYes
.env.productionNoThirdYes
.envLast, so it loses every conflictLast, so it loses every conflictYes
.env.stagingNoNoNothing reads it

Where does the environment actually get decided?

When the bundle is built, not when the app runs. Expo replaces every reference to an EXPO_PUBLIC_ variable with its value, so the server address in a shipped build is a literal string inside the JavaScript. There is no setting to flip afterwards. If you want a tester pointed at a different backend, you give them a different build. That single fact reorganises how you think about the problem: an environment is not a configuration, it is an artefact.

Two artefacts, in fact, and they are inlined at different moments. Expo says EAS Build uses the .env files uploaded with your build job. EAS Update inlines values from the .env files available in the local environment or on CI when you publish. So an update published from a laptop can carry that laptop's values on top of a production build. This is the single most expensive mistake in this area, and it has a cheap fix: publish updates from the same place you build them, and keep generated .env files in .gitignore, which is what Expo advises as well.

The three names you want live in eas.json. The default configuration has three build profiles, development, preview and production, and the reference says in so many words that these could have been named foo, bar and baz. They are a convention. A profile can carry an env block of ordinary variables, which are used to evaluate app.config.js locally when you run a build and are also set on the EAS builder. A profile can also extend another, up to five deep, as long as you do not make a circle. The reference's own examples use a plain variable named ENVIRONMENT, with values including staging. That is the whole trick: staging is a profile name and a variable value, not a file name.

Then there is the server side. EAS environment variables are stored by EAS and grouped into environments, and by default EAS supports three: development, preview and production. Custom names are available on the Enterprise and Production plans. A variable can be assigned to more than one environment, sharing a value or holding a different one in each. A build profile picks the environment it reads with an environment field, and the variables for that environment are then available during the build. For local work you pull them down with eas env:pull and an environment name, which is also the thing Expo suggests instead of trying to switch .env files by hand. Worth knowing before you automate it: the documentation names both files, .env in the prose and .env.local on the example output, so look at what actually landed before you commit anything.

Now the way this actually goes wrong, which is hiding in the precedence list. Look again at the two orders Expo documents. .env.local sits second in both of them, above .env.development and above .env.production. It is the one file that is read in every mode and beats the mode specific file. So a developer who once put a staging address in .env.local to debug something gets staging values in a production export too, on that machine, silently, because the file is in .gitignore and nobody reviews it. The build succeeds. The app points at the wrong backend. There is no warning anywhere, because as far as the CLI is concerned you asked for exactly that. The guard is to keep .env.local empty of anything that names an environment, and to put the environment name on screen in every build that is not production, so a wrong one is obvious in two seconds rather than after a week of confused test reports.

Expo, EAS environment variables overview, read 17 October 2026, page last modified 21 July 2026

Sort yours

Where does this value have to live?

Tick what changes between your environments. Each one has a home, and picking the wrong home is most of the trouble people have here.

Nothing ticked. If nothing changes between environments, you do not have environments, you have one app, and you can leave every file here alone.

Do a staging build and a production build need separate identities?

They do if both have to sit on one phone at once, and that is usually the reason you wanted staging. Expo's build variants reference says each variant must have a unique Application ID on Android and Bundle Identifier on iOS. This is not an Expo rule. It is how the operating system decides whether two files are the same app. Install one over the other with the same identifier and the first one is gone, along with its stored data, which is how people lose a test account they had spent an afternoon setting up.

Varying it means app.config.js, because app.json is static and cannot compute anything. Expo's documented pattern reads a variable, often called APP_VARIANT, set by the build profile, and returns an identifier such as com.myapp.dev for development and com.myapp for production. The display name changes with it, so the home screen tells you which one you are looking at. Expo is clear about the limits of this. The variable changes only the app name and the package name on Android and the bundle identifier on iOS, and it does not affect how the binary is compiled. Switching variants locally needs prebuild with the clean flag so the native iOS and Android directories regenerate, which is slow and is why people do it in CI rather than on a laptop.

Going further is native work. A separate icon per variant means an asset catalog setting on the iOS target and a product flavour resource directory on Android. Separate display names mean an Info.plist key on iOS and a strings file per flavour on Android. The iOS side also wants a scheme per profile and an abstract target in the Podfile. None of this is hard, and all of it is the kind of work that sits outside the generated project and has to be maintained by hand afterwards.

Here is the bill, and it is the reason to decide the naming once. Apple's App Store Connect Help page on removing an app carries a warning saying that the SKU cannot be reused in the same organization and that if you have uploaded a build, your bundle ID cannot be reused. Read 17 October 2026. The trigger is the upload, not the release. A staging identifier is therefore spent the first time any build goes up under it, so com.yourcompany.app.staging is a permanent choice made during a hurried afternoon. Settle the pattern before the first upload, which is a good reason to read publishing to the App Store before you need it rather than during.

What changes when you did not write the project?

Nothing about the mechanism, and one thing about the order you meet it in. The files are ordinary files. Expo CLI treats a generated project exactly as it treats a hand typed one, so every rule above holds without an asterisk. What differs is that you did not build this up a value at a time, so the first time you look there may be no .env file at all, or one .env with three keys in it, and no eas.json profile beyond the defaults. That is not neglect. It is what a project that has only ever had one environment looks like.

Newly is an AI app builder. You describe an app in plain English and it writes a real React Native and Expo project you own, with two way GitHub sync, so the .env handling and the eas.json profiles are files in your repository that you edit in your own editor. It uploads iOS builds to TestFlight, publishes to Google Play internal testing and builds a standalone Android APK. It is $25 a month, with no free plan. It does not submit to the App Store for you, because Apple requires the content owner to submit. If the ownership question is the one actually on your mind, who owns AI generated code answers it separately.

The practical sequence is short. Decide how many environments you genuinely have, which for most small apps is two and not three. Add one build profile per environment with a plain variable in its env block. Move the server address out of the code and into a .env file as an EXPO_PUBLIC_ variable. Compute the identifier and the name in app.config.js from the profile variable, and only then upload anything. Everything after the first upload is more expensive than everything before it.

The honest limit is that none of this is about the generator. A tool that writes your app cannot know how many backends you intend to run, and no tool can un-spend a bundle identifier. What a generated project does give you is the whole repository on day one, so the config files are yours to change before the first build rather than after. If you want to know what runs the bundle those values were baked into, read Hermes and the new architecture next.

Questions people ask about environment variables across environments

No, not in current versions. Expo's environment variables guide says that in SDK 58 and later, Expo commands do not load other .env files, such as .env.staging or .env.test. The files it does load are chosen by mode, and there are two modes. Development mode loads .env.development.local, .env.local, .env.development and .env, in that priority order. Production mode loads .env.production.local, .env.local, .env.production and .env. The documentation does not describe what the CLI did before SDK 58, so if you inherited an older project, test it rather than assuming either behaviour. Read 17 October 2026, page last modified 12 October 2026.

Name the environments before the first upload

One build profile per environment, one plain variable inside it, one dynamic config that turns it into an identifier. Ten minutes now, and nothing permanent is spent by accident.

Start building