◉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 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●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 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●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/App Dev
◇ App Dev/2026-10-22Intermediate

Expo SDK 58 No Longer Loads .env.staging. A 70-Line Script to Audit Your .env Files

From SDK 58, Expo commands skip custom files like .env.staging and overwrite any NODE_ENV you pass in. A small Node script that shows which .env files load and which line wins, run against a sample project.

Expo SDK 584environment variables6.envEAS11audit script

One sentence in the SDK 58 page "Environment variables in Expo" made me stop reading.

In SDK 58 and later, Expo commands don't load other .env files, such as .env.staging or .env.test.

Keeping staging values in .env.staging and switching with NODE_ENV=staging is the first setup I reach for as an indie developer when a server URL has to differ per environment. If that sentence is accurate, the app will still start after the upgrade and look perfectly healthy. It may just be talking to a different API than you think.

So I wrote a small script that audits every .env file in a project and ran it against a sample. It does nothing clever: it applies the rules in the official docs (page dated 2026-10-12) mechanically.

The three rules the docs set

Here are the rules the audit needs, and nothing else.

  1. Each mode (development or production) loads a fixed list of files, and the file listed first wins.
  2. From SDK 58, files outside that list, such as .env.staging and .env.test, are not loaded.
  3. From SDK 58, each command sets NODE_ENV to its own mode and replaces whatever you pass in. NODE_ENV=production npx expo start runs in development mode and loads .env.development.
ModeFiles loaded (highest priority first)
Development.env.development.local → .env.local → .env.development → .env
Production.env.production.local → .env.local → .env.production → .env

The third rule reaches eas update too. It calls npx expo export underneath, so the docs say NODE_ENV=test eas update will run as production, not test.

The audit script

It does four things. It classifies each .env file as loaded or not in each mode. It prints which file's value wins in production when a key appears more than once. It looks in package.json scripts for NODE_ENV= next to an expo or eas update command. And it searches the source for process.env['…'] and const { … } = process.env, because according to the docs a variable is only inlined into the bundle when it is written statically as process.env.EXPO_PUBLIC_X.

// env-audit.mjs — audit a project's .env files and env references against the Expo SDK 58 loading rules
// usage: node env-audit.mjs <project root>
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
 
const root = process.argv[2] ?? '.';
 
// From the official docs (Environment variables in Expo). Earlier = higher priority
const LOADED = {
  development: ['.env.development.local', '.env.local', '.env.development', '.env'],
  production: ['.env.production.local', '.env.local', '.env.production', '.env'],
};
 
const envFiles = readdirSync(root).filter((f) => /^\.env(\..+)?$/.test(f));
console.log('== .env files ==');
for (const f of envFiles) {
  const dev = LOADED.development.includes(f);
  const prod = LOADED.production.includes(f);
  const verdict = dev || prod ? `loaded (dev: ${dev ? 'yes' : 'no'} / prod: ${prod ? 'yes' : 'no'})` : 'NOT loaded (SDK 58+)';
  console.log(`${f.padEnd(26)} ${verdict}`);
}
 
// when a key appears in several files, which one wins in production mode
const parse = (f) =>
  Object.fromEntries(
    readFileSync(join(root, f), 'utf8')
      .split('\n')
      .map((l) => l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/))
      .filter(Boolean)
      .map((m) => [m[1], m[2].trim()]),
  );
console.log('\n== Values that actually apply in production mode ==');
const winner = {};
for (const f of [...LOADED.production].reverse()) {
  if (!envFiles.includes(f)) continue;
  for (const [k, v] of Object.entries(parse(f))) winner[k] = { v, f };
}
for (const [k, { v, f }] of Object.entries(winner)) console.log(`${k} = ${v}    <- ${f}`);
 
// From SDK 58, expo commands overwrite NODE_ENV= from package.json
console.log('\n== NODE_ENV in package.json ==');
const scripts = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts ?? {};
let hit = false;
for (const [name, cmd] of Object.entries(scripts)) {
  if (/NODE_ENV=/.test(cmd) && /\bexpo\b|eas update/.test(cmd)) {
    hit = true;
    console.log(`scripts.${name}: ${cmd}  <- the NODE_ENV you set is ignored`);
  }
}
if (!hit) console.log('none');
 
// forms that are not inlined
console.log('\n== process.env forms that are not inlined ==');
const walk = (d) =>
  readdirSync(d).flatMap((n) => {
    if (n === 'node_modules' || n.startsWith('.')) return [];
    const p = join(d, n);
    return statSync(p).isDirectory() ? walk(p) : ['.ts', '.tsx', '.js', '.jsx'].includes(extname(p)) ? [p] : [];
  });
let bad = 0;
for (const p of walk(root)) {
  readFileSync(p, 'utf8').split('\n').forEach((line, i) => {
    if (/process\.env\[/.test(line) || /\}\s*=\s*process\.env\s*;?\s*$/.test(line)) {
      bad++;
      console.log(`${p.replace(root + '/', '')}:${i + 1}  ${line.trim()}`);
    }
  });
}
if (!bad) console.log('none');
process.exitCode = envFiles.some((f) => !LOADED.development.includes(f) && !LOADED.production.includes(f)) || bad || hit ? 1 : 0;

It needs only Node. Run it as node env-audit.mjs ./my-app. It exits with 1 when it finds a problem, so it can sit in CI.

What it printed on a sample project

The sample has four files: .env, .env.local, .env.production, and .env.staging, with a LAN address in .env.local. Its package.json has NODE_ENV=staging eas update --branch preview, and the source uses all three ways of reading process.env, once each.

== .env files ==
.env                       loaded (dev: yes / prod: yes)
.env.local                 loaded (dev: yes / prod: yes)
.env.production            loaded (dev: no / prod: yes)
.env.staging               NOT loaded (SDK 58+)
 
== Values that actually apply in production mode ==
EXPO_PUBLIC_API_URL = http://192.168.0.12:3000    <- .env.local
EXPO_PUBLIC_FLAG = off    <- .env
 
== NODE_ENV in package.json ==
scripts.build:stg: NODE_ENV=staging eas update --branch preview  <- the NODE_ENV you set is ignored
 
== process.env forms that are not inlined ==
app/api.ts:1  const { EXPO_PUBLIC_API_URL } = process.env;
app/api.ts:2  export const url = process.env['EXPO_PUBLIC_API_URL'];

Two lines matter.

The first is .env.staging. The file is there, its contents are right, and no mode reads it. Nothing throws. The value comes back undefined or is silently replaced by another file's value, so you notice late.

The second is the production result. For EXPO_PUBLIC_API_URL, .env.local beats .env.production. By the table, .env.local loads in production mode and sits ahead of .env.production. If a .env.local with your dev machine's address is still around, it overrides the production value whenever you run npx expo export or eas update from your own machine.

A file existing is not the same as its value being used. The audit has to answer "in production mode, which line wins", not only "which files exist".

The order I would fix things in

  1. Move what's in .env.staging to somewhere that is loaded. With EAS, the docs recommend eas env:pull, which replaces your .env.local with the environment you pick, so you no longer depend on NODE_ENV. Without EAS, they suggest writing a small script that overwrites .env.local or .env.
  2. Delete NODE_ENV=staging from package.json. It does no harm, but it looks as though it works, and that misleads the next reader.
  3. Put .env*.local in .gitignore. The docs show exactly this line.
  4. Rewrite process.env['…'] and destructuring into process.env.EXPO_PUBLIC_X.

The fourth point isn't specific to SDK 58. Destructuring just looks tidy, so it tends to survive unnoticed.

What this audit does not prove

What I confirmed is that the script applies the documented rules correctly. I did not run the real Expo CLI on SDK 58 and compare the loaded values. Docs get revised, so if the table changes, update LOADED in the script.

Variables you keep on EAS servers with eas env are outside its reach. It only sees what is in the repository.

Also remember that EXPO_PUBLIC_ values are plain text inside the built app, and the docs warn against putting secrets there. For the source side, see A 40-Line Script to Check Whether Rork Wrote an API Key Into Your Code. For another SDK 58 audit of what the build adds on its own, see My plugins Array Was Empty, and Info.plist Still Had Nine Permission Strings.

Next time you upgrade, run this first and check only two things: the files that are not read, and the lines that win.

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

◇ App Dev2026-10-21
expo-widgets Works on iOS but Fails Only in the Android Build — Pin the Version or Exclude It?
Reports say expo-widgets 58.0.17 and later stops at Kotlin compilation on Android only. How to recognize it, when to pin versus exclude from autolinking, and a script that checks your project.
◇ App Dev2026-10-14
App Review Says "Guideline 2.1, Blank Screen": Three Places to Look in an Expo Release Build
It opens fine in TestFlight, yet review rejects it for a blank screen. Here is the order I check things in an Expo + EAS release build: which environment holds your variables, secret visibility during config resolution, and what the reviewer's device does differently.
⬡ Dev Tools2026-09-06
There is no --json on eas env:list, so I rebuilt my environment variable inventory
eas env:list does not accept a --json flag. Here is how I turn --format short output into safe JSON, and how I spot a missing variable across three environments in one line.