◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-10-15Beginner

When EXPO_PUBLIC_ Values Read Fine in Dev but Come Back undefined in Production: Telling the Spellings Apart

The API URL reads fine while developing and turns undefined once the app is built. The cause was how process.env was written. I lined up babel-preset-expo's output for dev and production and tabulated which spellings get replaced and which are left behind.

Expo218Environment Variables3EXPO_PUBLIC3Rork582Troubleshooting43

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_NAME is 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 into process.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_URLreadsreads
const { EXPO_PUBLIC_API_URL } = process.envreadsreads
process.env["EXPO_PUBLIC_API_URL"]readsreads
process.env[key] (key is a variable)readsundefined
const env = process.env; env.EXPO_PUBLIC_API_URLreadsundefined
read(process.env) (passing the whole object)readsundefined
process.env.API_URL (no prefix)undefinedundefined

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 | head

Replace 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.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-10-13
Before You Ship: Go Looking for Your Own Keys Inside the Exported Expo Bundle
A key can be missing from your source and still be sitting inside the finished app. I ran expo export on Expo SDK 57, searched the bundle with grep and a short Node script, and recorded which secrets show up and which don't.
⬡ Dev Tools2026-09-21
A build that stops with exit code 0 — the flag that silenced the log, and the check that let an empty file through
Two reports landed in the same week: eas build --local stopping with exit code 0 but produced no further output, and a zero-byte stub that never got embedded. Both came down to settings that reduce output. Here is how to audit your own scripts.
⬡ Dev Tools2026-10-07
Japanese Headings Won't Turn Bold on Android: How to Trace and Fix a fontWeight That Does Nothing
A troubleshooting note for Rork apps where headings look bold on iPhone but stay thin on Android. How to tell the symptoms apart, pick weights through fontFamily, and verify the fix.