Environment variables in Expo work through three layers in 2026: .env files with the EXPO_PUBLIC_ prefix for client-safe values, app.config.js for build-time configuration read via expo-constants, and EAS Environment Variables for server-side secrets that never touch your bundle. Coming from Next.js or Vite, the mental model is similar, but the boundaries are strict. Anything without the EXPO_PUBLIC_ prefix is invisible to your client code, and there is no server-side runtime to fall back on unless you deploy Expo Router API Routes. I've shipped this pattern across four production apps, and this guide walks through every seam.
Only variables prefixed with EXPO_PUBLIC_ are inlined into your JavaScript bundle at build time. Everything else is invisible to process.env in app code.
Use app.config.js with the extra field for structured, per-environment configuration you read at runtime through expo-constants.
Store true secrets (API keys, service tokens) in EAS Environment Variables with a "secret" or "sensitive" visibility. They are injected into build workers, never committed to git.
You do not need react-native-dotenv, babel-plugin-transform-inline-environment-variables, or react-native-config anymore. Expo SDK 51+ reads .env files natively.
Switch environments by pairing an APP_VARIANT variable with conditional logic in app.config.js, then binding EAS profiles to the same variant.
All EXPO_PUBLIC_ values are effectively public. They ship in plaintext inside your APK/IPA, so treat them as configuration, not secrets.
What environment variables actually do in Expo
Environment variables in an Expo app are values resolved at build time, not at runtime. Honestly, that distinction is the biggest thing to internalize if you're coming from a React web stack. In Next.js, process.env.NEXT_PUBLIC_API_URL can also be read on the server for SSR. In a React Native app, there is no server. Whatever value your JavaScript reads from process.env was hard-coded into the bundle Metro produced when you ran eas build or npx expo start. There is no way to change it after the fact without publishing an update or a new binary.
Expo SDK 55 (the current release as of September 2026) treats .env, .env.local, .env.development, and .env.production as native inputs, with no Babel plugin required. Metro parses them, filters to variables starting with EXPO_PUBLIC_, and inlines those into your bundle. Everything else is stripped for safety. The official Expo environment variables guide confirms this behavior and lists load-order precedence.
Two other layers sit alongside .env. Your app.config.js (or app.config.ts) is a JavaScript file that runs during npx expo prebuild and every build. It has full access to process.env in the Node.js sense, so it can read anything, including non-public variables. Whatever you write into its extra field becomes readable in the app via expo-constants. The third layer, EAS Environment Variables, provides a cloud-hosted store where you can mark values as "sensitive" or "secret" and control which build profiles see them.
.env files vs app.config.js vs EAS Environment Variables
The three layers overlap, and picking the wrong one is the most common source of leaked keys I see in code reviews. Here's how they compare on the axes that actually matter: where the value is stored, whether it's safe for public exposure, when it's evaluated, and how you consume it in code.
Feature
.env with EXPO_PUBLIC_
app.config.js extra
EAS Environment Variables
Where stored
Local .env file (usually gitignored)
Committed JS file
Expo cloud dashboard / eas env
Safe for secrets?
No. Inlined into bundle in plaintext
No. Ends up in Constants.expoConfig
Yes when marked "secret" or "sensitive"
When evaluated
Metro bundling
Prebuild + every EAS build
Injected into build worker env
Accessed via
process.env.EXPO_PUBLIC_X
Constants.expoConfig.extra.x
process.env.X inside app.config.js
Visible to app.config.js?
Yes
N/A (it is app.config.js)
Yes during build
Rotatable without rebuild?
No
No
No, but faster to rotate in dashboard
Best for
Public API base URLs, feature flags
App identifiers, versioned config
Sentry auth tokens, backend secrets
The pattern I use on every project: .env for local development defaults (checked in as .env.example only), app.config.js for anything that changes per variant like app name and bundle ID, and EAS Environment Variables for anything I'd be upset about leaking. If you're already familiar with the Expo Config Plugins architecture, this is the same three-tier separation applied to values rather than native project mods.
How do you use .env files with the EXPO_PUBLIC_ prefix?
Create a .env file at your project root and add a variable that starts with EXPO_PUBLIC_. Metro will pick it up on the next start, no config required.
# .env
EXPO_PUBLIC_API_URL=https://api.example.com
EXPO_PUBLIC_SENTRY_DSN=https://[email protected]/0
EXPO_PUBLIC_FEATURE_FLAGS=new-onboarding,dark-mode
# The following will NOT be inlined. Safe to keep alongside:
DATABASE_URL=postgres://never-shipped
INTERNAL_ADMIN_KEY=this-stays-server-side
Then read them anywhere in your app:
// app/lib/api.ts
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
const flags = (process.env.EXPO_PUBLIC_FEATURE_FLAGS ?? "")
.split(",")
.filter(Boolean);
export async function fetchProfile(userId: string) {
const res = await fetch(`${apiUrl}/users/${userId}`);
if (!res.ok) throw new Error(`Profile fetch failed: ${res.status}`);
return res.json();
}
A few important behaviors that catch web developers off guard. First, Metro inlines these as string literals at bundle time. After that, changing .env does nothing until you restart the dev server with the cache cleared: npx expo start --clear. Second, the values are baked into your index.bundle, so anyone who unzips your APK can read them with strings. Third, Expo Router API Routes running on EAS Hosting can read non-public variables through process.env because they execute on a server, which is the one exception to the "everything is client-side" rule.
For TypeScript users, extend the global namespace so the compiler knows about your variables:
Using app.config.js and expo-constants for dynamic config
app.config.js is a Node.js file that Expo executes during builds. Because it's real JavaScript, you can conditionally set values, read files, and (most usefully) vary configuration based on environment variables that aren't in your bundle.
There are two subtle behaviors here that matter. First, app.config.js runs on every EAS build, so its extra field snapshots whatever process.env looks like on the build worker. Local development uses your local shell values. Second, unlike EXPO_PUBLIC_ variables, values in extra aren't privileged in any way. They're serialized into a JSON manifest and shipped with your app, so don't put secrets there thinking they're hidden.
Storing secrets securely with EAS Environment Variables
For anything you'd be upset to leak (third-party service tokens, signing keys, private API endpoints) use EAS Environment Variables. They live in the Expo dashboard, are scoped to your project, and can be marked with three visibility levels:
Plaintext: readable in the dashboard and CI logs. Fine for non-sensitive config that just varies per environment.
Sensitive: hidden in logs but still readable to project admins. Good for API URLs that are environment-specific but not catastrophic if disclosed.
Secret: write-only. Once you set the value, it can only be injected into builds. Nobody can read it back through the UI or CLI. Use this for anything with real-world blast radius.
Inside your build worker, these variables are available on process.env during the entire build, so app.config.js can read them, plugins can read them, and any postinstall script can read them. They are not automatically inlined into your JavaScript bundle. If you want a value in your app code, either add the EXPO_PUBLIC_ prefix (which converts it to a public value) or wire it through app.config.js's extra field.
The Sentry auth token is the canonical example of a value that should stay non-public. Your build uses it to upload sourcemaps, but the shipped app doesn't need it. Same for App Store Connect API keys and Google Play service accounts. If you're wiring up EAS Update or over-the-air deployments, the EAS Update rollout guide covers how these tokens plug into the release flow.
How do you switch between dev, staging, and production?
The pattern that scales is a single APP_VARIANT variable that drives everything else. Set it locally with direnv or .env.local, set it per-profile in eas.json, and branch on it inside app.config.js.
Different bundle identifiers let you install all three variants on the same device side-by-side. In my last project I kept dev on the simulator, preview on my physical iPhone, and production from TestFlight all at once. To start a specific variant locally:
# Dev build against localhost
APP_VARIANT=development npx expo start --dev-client
# Preview build against staging
APP_VARIANT=preview eas build --profile preview --platform ios
# Production release build
APP_VARIANT=production eas build --profile production --platform all
If you're running builds in EAS Workflows, the same variables flow through. See the EAS Workflows CI/CD tutorial for how to wire this into a matrix build. And when you push OTA updates, be sure the channel name matches the variant so preview builds don't accidentally pull production JavaScript.
Common pitfalls: bundling, runtime vs build-time, and CI
The mistakes I see repeatedly come down to five patterns.
process.env changes don't hot-reload
Metro caches your .env at first bundle. If you edit a variable, changing the source file that reads it will not update, because the value is already inlined. Kill the dev server and restart with npx expo start --clear. This trips up every Vite refugee at least once because Vite invalidates the module on .env change.
EXPO_PUBLIC_ is not "safe", it's just "visible"
The prefix decides whether Metro exposes the variable to your bundle. It has nothing to do with secrecy. Anyone with your APK can dump strings and extract every EXPO_PUBLIC_ value in seconds. If you need per-user secrets, fetch them from a server after auth.
Constants.manifest is gone; use Constants.expoConfig
In SDK 49+, the old Constants.manifest is removed. Read Constants.expoConfig?.extra instead. The expo-constants SDK reference covers the current field layout.
Non-public variables are not available in your app code
If you set DATABASE_URL in .env or in an EAS Environment Variable, then try to read process.env.DATABASE_URL from a React component, you'll get undefined. Only EXPO_PUBLIC_ variables reach the bundle. Route non-public values through app.config.js's extra field if you truly need them in JS (and remember they're no longer secret at that point).
CI needs the same variables you use locally
If app.config.js reads process.env.APP_VARIANT and CI doesn't set it, your build silently falls back to the default branch. Explicitly set every required variable in your eas.json or workflow file, and throw an error in app.config.js if a required variable is missing. Better to fail the build than ship a misconfigured binary.
// app.config.js — fail loudly
const required = ["APP_VARIANT"];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required env var: ${key}`);
}
}
Frequently Asked Questions
Do I still need react-native-dotenv or react-native-config?
No. Since Expo SDK 51, Metro reads .env files natively and exposes any variable prefixed with EXPO_PUBLIC_ to your bundle. Both react-native-dotenv and react-native-config add complexity and Babel transforms you no longer need. Remove them when you migrate.
Can I read environment variables at runtime instead of build time?
Not in the app bundle itself. Metro inlines values at build. The workaround is to fetch a config JSON from your backend on startup and cache it. That gives you runtime overrides without rebuilds, at the cost of one extra network round trip. For truly dynamic config, use a service like a remote config provider or push updates via EAS Update.
What's the difference between app.json and app.config.js?
app.json is static JSON, evaluated once at prebuild. app.config.js (or app.config.ts) is a JavaScript module that runs on every build, so it can read process.env, conditionally set fields, and compute values dynamically. Use app.config.js anytime you need environment-driven configuration.
How do I keep .env out of version control safely?
Add .env, .env.local, and any .env.*.local variants to .gitignore. Commit a .env.example that lists the required variable names with placeholder values so teammates know what to set. Never commit real values (even for staging) because git history is forever.
How do I rotate a secret without shipping a new app?
You can't rotate a secret already baked into a shipped bundle. Design your app so secrets are fetched from your backend after authentication rather than embedded. For build-time secrets like a Sentry auth token, rotate the value in EAS Environment Variables and trigger a new build. Old builds keep using the old token, which is fine as long as it's still valid.
Add Stripe to a React Native Expo app in 2026 with PaymentSheet, Apple Pay, Google Pay, SetupIntent, and 3D Secure. Full config-plugin setup, testing, and webhook confirmation.