Sometimes a screen that worked all through development stops fetching data the moment you install a real build on a device. No error message appears. The screen is just empty.
The first thing I check is whether the API URL came through empty. As an indie developer I often switch a server address through an environment variable, and when the address ships as undefined, the symptom looks like it simply disappeared.
What I most want to say up front: a value that reads fine on the dev server is no evidence that it will read in production. I put the transformed output side by side to see why.
What I checked, and how far
I checked two things: the Babel transform Expo uses, and the single line the dev server injects. Versions were babel-preset-expo 57.0.15, @expo/metro-config 57.0.13, and Node 22.
I did not bundle with Metro or run on Hermes. I transformed the source and then executed the result with process.env holding only NODE_ENV. Whether a device behaves the same is something to confirm in your own project, using the last step below.
What this showed is that dev and production get their environment variables in two entirely different ways.
- Production build: anywhere that reads
process.env.EXPO_PUBLIC_NAMEis replaced at build time by the string itself. Anywhere that wasn't replaced is left with nothing. - Dev server: separately from that replacement, one line is placed at the top of the bundle that copies every
EXPO_PUBLIC_variable intoprocess.env.
So in dev, even a spelling that never gets replaced can still read process.env directly and find the value.
Result per spelling
I passed the same value, https://api.example.com, and ran each spelling in dev and in production.
| Spelling | Dev | Production |
|---|---|---|
process.env.EXPO_PUBLIC_API_URL | reads | reads |
const { EXPO_PUBLIC_API_URL } = process.env | reads | reads |
process.env["EXPO_PUBLIC_API_URL"] | reads | reads |
process.env[key] (key is a variable) | reads | undefined |
const env = process.env; env.EXPO_PUBLIC_API_URL | reads | undefined |
read(process.env) (passing the whole object) | reads | undefined |
process.env.API_URL (no prefix) | undefined | undefined |
The three bold rows are the spellings that work in dev and break only in production. What they share is that the name is not finished inside a single expression. The replacement only fires when process.env and the name sit in the same expression. Once a variable is in between, the name isn't known at the point of writing, and the spelling falls outside the replacement.
One thing surprised me. The official documentation reads as if destructuring and bracket spellings are not replaced. In this version, destructuring and a string-literal bracket were replaced. I couldn't tell whether the documentation is out of date or whether it depends on settings and versions.
Which spellings pass may shift between versions, so I stay with the direct form instead of leaning on them. That sentence is my line for now.
Finding the risky lines in your own code
What you're looking for is any line where process.env isn't immediately followed by .name.
# Print only lines where process.env is not followed by "."
grep -rnE "process\.env(\[|[^.A-Za-z_\[]|$)" src \
--include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx"Run against a small test file of about seven lines, it printed exactly three: the variable key, the alias, and the pass-the-object case. A string-literal bracket is safe yet still matches, so treat the output as a list to read line by line.
Gather the reads in one place, and stop at startup if empty
The fix is to read in one place, write only that place in the direct form, and stop before an empty screen appears.
// src/env.ts
// Read each value as "process.env.NAME" directly. This is what gets replaced at build time.
const raw = {
EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL,
EXPO_PUBLIC_SENTRY_DSN: process.env.EXPO_PUBLIC_SENTRY_DSN,
} as const;
const missing = Object.entries(raw)
.filter(([, value]) => !value)
.map(([name]) => name);
if (missing.length > 0) {
throw new Error(`Public env vars are empty: ${missing.join(', ')}`);
}
export const env = raw as Record<keyof typeof raw, string>;Everywhere else, import { env } from "./env" and use env.EXPO_PUBLIC_API_URL.
I transformed this with the production settings and ran it. With both values present it passed; with one removed it stopped with a message naming EXPO_PUBLIC_SENTRY_DSN. Because it stops right at startup, there is less time spent searching for the cause while staring at an empty screen. The point is to learn, before your users do, that the production build never received the value.
One last check on a real build
My reproduction stops at the transform, so it's worth looking at a real bundle too. You can search the build output for the value as a string.
# Export a production bundle and count files that contain the API URL
npx expo export --platform ios
grep -rl "https://api.example.com" dist | headReplace api.example.com with your own address. If nothing turns up, the replacement did not happen. This check assumes the value is meant to be in the bundle. Secrets that must not be there should never carry the EXPO_PUBLIC_ prefix at all, which I wrote up in EAS secrets are not a "keep it out of the app" setting.
To start, run the grep above once on your own project today and see how many lines it prints. If it's zero, you're fine to carry on.