SDK 58 の「Environment variables in Expo」を読み直していて、一文で手が止まりました。
SDK 58 以降、Expo のコマンドは
.env.stagingや.env.testなど、標準以外の.envファイルを読み込みません。
ステージング用の値を .env.staging に分けて、NODE_ENV=staging を付けて切り替えます。個人開発のアプリで、配信先の URL を環境ごとに分けたくなったとき、私も真っ先に思いつく組み立てです。この一文が正しければ、アップグレードしたあとも、画面は何事もなく動きます。ただし、指している API は、思っていたものとは別かもしれません。
そこで、手元の .env 系ファイルを一度に点検するスクリプトを書き、サンプルプロジェクトで走らせました。公式ドキュメント(更新日 2026-10-12)の記述を、機械的に当てはめるだけの小さな道具です。
公式ドキュメントが決めている三つの規則
ドキュメントから、点検に必要な規則だけを抜き出します。
- モード(開発か本番か)ごとに読み込まれるファイルが決まっており、左にあるものが優先されます。
- SDK 58 以降、上の表に載らないファイル(
.env.staging、.env.testなど)は読み込まれません。 - SDK 58 以降、各コマンドが
NODE_ENVを自分のモードに合わせて設定し、外から渡した値は置き換えられます。たとえばNODE_ENV=production npx expo startは、開発モードで動き、.env.developmentを読みます。
| モード | 読み込まれるファイル(優先順) |
|---|---|
| 開発 | .env.development.local → .env.local → .env.development → .env |
| 本番 | .env.production.local → .env.local → .env.production → .env |
三つ目の規則は、eas update にも及びます。eas update は内部で npx expo export を呼ぶため、NODE_ENV=test eas update も、test ではなく production で動くと書かれています。
点検スクリプト
私はアップグレードの前に、まず「何が読まれなくなるか」を機械に数えさせる派です。やることは四つです。.env 系ファイルが開発・本番のどちらで読まれるかを判定します。本番モードで同じキーが複数あるときは、どのファイルの値が勝つかを出します。package.json の scripts では、expo や eas update と並んだ NODE_ENV= がないかを探します。最後に、ソース中の process.env['…'] と const { … } = process.env を探します。ドキュメントによれば、変数は process.env.EXPO_PUBLIC_X のように静的に書かれていないと、バンドルに埋め込まれません。
// env-audit.mjs — Expo SDK 58 の .env 読み込み規則で、プロジェクトの .env 系ファイルと参照箇所を点検します
// 使い方: node env-audit.mjs <プロジェクトのルート>
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
const root = process.argv[2] ?? '.';
// 公式ドキュメント(Environment variables in Expo)の表。左ほど優先
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 系ファイル ==');
for (const f of envFiles) {
const dev = LOADED.development.includes(f);
const prod = LOADED.production.includes(f);
const verdict = dev || prod ? `読まれる(開発:${dev ? 'はい' : 'いいえ'} / 本番:${prod ? 'はい' : 'いいえ'})` : '読まれません(SDK 58 以降)';
console.log(`${f.padEnd(26)} ${verdict}`);
}
// 同じキーが複数ファイルにあるとき、本番モードでどれが勝つか
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== 本番モードで実際に効く値 ==');
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}`);
// package.json の NODE_ENV= は SDK 58 以降、expo コマンドに上書きされます
console.log('\n== package.json の NODE_ENV 指定 ==');
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} ← 指定した NODE_ENV は使われません`);
}
}
if (!hit) console.log('なし');
// インライン化されない書き方
console.log('\n== インライン化されない process.env の書き方 ==');
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('なし');
process.exitCode = envFiles.some((f) => !LOADED.development.includes(f) && !LOADED.production.includes(f)) || bad || hit ? 1 : 0;依存は Node だけです。node env-audit.mjs ./my-app のように、プロジェクトのルートを渡します。問題が見つかると終了コード 1 を返すので、CI に載せることもできます。
確認用のサンプルで走らせた結果
次の構成のサンプルを作りました。.env、.env.local、.env.production、.env.staging の四つを置き、.env.local には開発機の LAN アドレスを入れています。package.json には NODE_ENV=staging eas update --branch preview を、ソースには三通りの process.env の書き方を一つずつ置きました。
== .env 系ファイル ==
.env 読まれる(開発:はい / 本番:はい)
.env.local 読まれる(開発:はい / 本番:はい)
.env.production 読まれる(開発:いいえ / 本番:はい)
.env.staging 読まれません(SDK 58 以降)
== 本番モードで実際に効く値 ==
EXPO_PUBLIC_API_URL = http://192.168.0.12:3000 ← .env.local
EXPO_PUBLIC_FLAG = off ← .env
== package.json の NODE_ENV 指定 ==
scripts.build:stg: NODE_ENV=staging eas update --branch preview ← 指定した NODE_ENV は使われません
== インライン化されない process.env の書き方 ==
app/api.ts:1 const { EXPO_PUBLIC_API_URL } = process.env;
app/api.ts:2 export const url = process.env['EXPO_PUBLIC_API_URL'];見てほしい箇所は二つです。
一つ目は、.env.staging の行です。ファイルはそこにあって、中身も正しいのに、どのモードでも読まれません。エラーは出ません。値が undefined になるか、別のファイルの値に化けるだけなので、気づくのが遅れます。
二つ目は、本番モードの結果です。EXPO_PUBLIC_API_URL に勝っているのは、.env.production ではなく .env.local でした。上の表のとおり、.env.local は本番モードでも読まれ、.env.production より前に並んでいます。開発機のアドレスを書いた .env.local が残っていると、手元で npx expo export や eas update を走らせたとき、本番の値を押しのけます。
ファイルが存在することと、その値が使われることは別の話です。 点検は「どのファイルがあるか」ではなく、「本番モードで、どの行が勝つか」まで確かめます。
直す順番
私なら、次の順に手を入れます。
.env.stagingの中身を、読み込まれる場所へ移します。ドキュメントが勧めているのは、EAS を使っているならeas env:pullです。選んだ環境の値で.env.localを置き換えてくれるので、NODE_ENVに頼らずに済みます。EAS を使わないときは、.env.localや.envを書き換える小さなスクリプトを自作するやり方が紹介されています。package.jsonのNODE_ENV=stagingを消します。残しても害はありませんが、効いているように見えるぶん、読む人を誤解させます。.env*.localを.gitignoreに入れます。ドキュメントもこの行を例示しています。process.env['…']と分割代入を、process.env.EXPO_PUBLIC_Xの形に書き直します。
四つ目は、SDK 58 に限った話ではありません。ただ、分割代入は見た目がきれいなので、気づかないまま残りやすい書き方です。
この点検が保証しないこと
確認できたのは、公式ドキュメントの規則をスクリプトが正しく当てはめることまでです。実際の Expo CLI を SDK 58 で走らせて、読み込まれた値を突き合わせたわけではありません。ドキュメントの記述が更新されることもあるので、規則の表は、スクリプトの LOADED を書き換えれば追従できます。
また、EAS のサーバーに登録した環境変数(eas env で管理するもの)は、このスクリプトの対象外です。点検できるのは、リポジトリの中にあるものだけです。
なお、EXPO_PUBLIC_ を付けた値は、ビルドされたアプリの中で平文のまま見えます。鍵のような秘密の値を入れない、という注意も、ドキュメントに明記されています。ソース側の確認は、Rork が書いたコードに鍵が直書きされていないか、40行ほどのスクリプトで一度だけ確かめますに書きました。SDK 58 に上げたあとの、ビルドで増える設定値の確認は、plugins に何も書いていないのに、Info.plist に権限の説明文が 9 件並んでいましたが参考になります。
次にアップグレードの作業をするときは、先にこのスクリプトを走らせて、「読まれないファイル」と「勝っている行」の二つだけ確認してみてください。