Intro
Expo has become the default starting point for many React Native developers because it removes the friction of native build tooling, yet production teams quickly discover that real applications need more than expo start. They need to understand what happens behind the CLI, how the build pipeline works, where configuration lives, and how to diagnose a failed OTA update or an Android build that compiles locally but crashes on a physical device.
This article explains the advanced concepts behind Expo through practical examples: the internal module graph, the app config resolution order, custom native code via development builds, environment variable handling, EAS Build and EAS Update internals, and the failure modes that occur in real teams. Each section includes concrete commands, expected output, and a recovery path. The goal is operational confidence: you should be able to inspect before changing, limit the blast radius, never expose secrets, and verify the result.
The examples assume Expo SDK 51 or newer, Node.js 20 LTS, and the Expo CLI installed globally. Where behavior differs between managed workflow and development builds, that difference is stated explicitly.
Version and Environment Inventory
Before changing anything in an Expo project, record the exact versions and topology. The first read-only command is:
npx expo --version
Expected output:
1.0.0
Then inspect the installed SDK and dependency graph:
npx expo install --check
If packages are out of date, the output lists mismatched dependencies with the expected versions, for example:
The following packages should be updated for best compatibility with the installed expo version:
[email protected] - expected version: ~51.0.28
[email protected] - expected version: 0.74.5
Do not run expo install blindly. First compare the current state with the lockfile:
git status --short
Only after confirming a clean working tree should you apply a minimal dependency bump:
npx expo install expo@~51.0.28
Version scoping matters because Expo packages are tied to the SDK. Mixing SDK 50 and SDK 51 packages causes runtime errors like Invariant Violation: Native module cannot be null. The recovery path is to pin all Expo-related packages to the same SDK using npx expo install --fix and then run npx expo-doctor to validate the project.
A project’s deployment topology also matters. If you use EAS Update, list the channels and branches:
npx eas channel:list
Expected output for a typical staging setup:
Name Created
production 2024-03-01T10:00:00Z
staging 2024-03-01T10:00:00Z
Record these values in your runbook: SDK version, CLI version, Node version, channel names, and build profile names. That inventory is the foundation for every later section.
Safe Configuration Path
Expo reads configuration from app.json, app.config.js, or app.config.ts, and the resolution order matters. If both app.json and app.config.js exist, the JavaScript file wins. This allows environment-specific configuration without duplicating files.
A safe pattern is to keep static values in app.json and expose only dynamic values through app.config.js:
// app.config.js
export default ({ config }) => {
return {
...config,
extra: {
apiUrl: process.env.API_URL || 'https://api.example.com',
enableAnalytics: process.env.ENABLE_ANALYTICS === 'true',
},
};
};
This configuration is then accessible in JavaScript via expo-constants:
import Constants from 'expo-constants';
const apiUrl = Constants.expoConfig?.extra?.apiUrl;
Important: values in extra are embedded in the client bundle at build time. They are not secrets. If you put a private key in extra, it ships to every user. Any environment variables that contain secrets should stay on the server side or be provided through EAS Build secrets.
To add a secret to EAS Build without committing it:
npx eas secret:create --name API_PRIVATE_KEY --value "sk_live_12345" --scope project
Then reference it in eas.json:
{
"build": {
"production": {
"env": {
"API_PRIVATE_KEY": "$API_PRIVATE_KEY"
}
}
}
}
The variable is only available during the build process, not inside the running app.
Before changing any configuration, observe the current merged config with:
npx expo config --type public
This prints the resolved public configuration that Expo will use. Verify that plugins, permissions, and extra values are as expected. Then make one change at a time and run npx expo config --type public again to confirm the delta.
A common mistake is editing android.package or ios.bundleIdentifier after an app has been published to the stores. Changing the package name creates a new app identity and invalidates existing builds and OTA updates. The recovery path for an accidental change is to revert the identifier in app.json and rebuild the binary. Never change package identifiers after store submission without a full migration plan.
Internal Module Graph and Custom Native Code
The managed workflow hides native folders, but Expo modules still run native code. When you need a custom native module, you either use a config plugin that modifies the native project at prebuild time, or you create a development build that includes your own native code.
A config plugin is a JavaScript function that receives the native project config and mutates it. For example, to add the Android permission for Bluetooth:
// plugins/withBluetoothPermission.js
const { withAndroidManifest } = require('@expo/config-plugins');
module.exports = function withBluetoothPermission(config) {
return withAndroidManifest(config, async (config) => {
const manifest = config.modResults;
const usesPermission = {
$: {
'android:name': 'android.permission.BLUETOOTH',
},
};
manifest.manifest['uses-permission'] = manifest.manifest['uses-permission'] || [];
manifest.manifest['uses-permission'].push(usesPermission);
return config;
});
};
Register the plugin in app.json:
{
"expo": {
"plugins": ["./plugins/withBluetoothPermission"]
}
}
Now npx expo prebuild will apply this plugin to the generated Android project. To verify, run:
npx expo prebuild --no-install
Then inspect android/app/src/main/AndroidManifest.xml and confirm the permission line exists. The plugin approach is safe because it is reproducible: any future prebuild regenerates the same native files. If you edit the native files directly without a plugin, the next prebuild overwrites your changes. Recovery from that mistake is to recreate the change as a config plugin and run prebuild again.
For custom React Native modules that are not part of Expo SDK, you cannot use a plain managed build. You need a development build:
npx expo run:android --variant debug
This generates the native project, compiles it with your custom code, and installs the debug app on a connected device. The debug build includes the Expo dev launcher, so you can still load JavaScript from Metro.
Before creating a development build, verify that all native dependencies are compatible with Expo SDK:
npx expo-doctor
Expected output on a healthy project:
✅ Checked 15/15 checks passed
If expo-doctor reports an incompatible package, the command suggests the correct version. Apply the suggestion with npx expo install <package>@<version> and rerun the doctor.
Environment Variables and Secrets
Many teams first encounter Expo environment variables when they try to use process.env.API_URL inside their React Native code. That fails because the Metro bundler does not automatically inline Node.js environment variables into the client bundle. The correct approaches are:
- Use
app.config.jsandexpo-constantsas shown earlier for non-secret values. - Use
.envfiles withexpo-envordotenvonly if the values are non-secret and you accept the risk of bundling. - Use EAS Build secrets for build-time secrets.
- Use a runtime secret retrieval mechanism such as fetching from a secure endpoint after authentication.
Here is a concrete example of loading a .env file with expo-env in development:
npx expo install expo-env
In babel.config.js:
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
['module:expo-env', {
envFiles: ['.env.development', '.env.production']
}]
]
};
};
Then in code:
import { API_URL } from 'expo-env';
The value is inlined at bundling time. A common mistake is to commit a .env file containing real keys. To prevent this, add every .env* file to .gitignore and provide a .env.example with placeholder values:
# .env.example
API_URL=https://api.example.com
To verify that no secret is in the bundle, search the compiled JavaScript after building:
npx expo export --platform web --output-dir dist
Then grep:
grep -r "API_PRIVATE_KEY" dist/ || echo "No secret found"
If a secret is found, the recovery is to remove the secret from the config, rotate the key immediately, and rebuild the app. This verification should be part of every CI pipeline.
EAS Build and EAS Update Internals
EAS Build runs your native compilation in the cloud. Understanding its internals helps you debug failed builds without guessing. A typical eas.json for a production build looks like this:
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true,
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {}
}
}
To start a build:
npx eas build --platform android --profile production
The CLI uploads your project (respecting .easignore), then runs expo prebuild, installs dependencies, executes the Gradle build, and signs the output. The build logs are available in the terminal and on the EAS dashboard.
If the build fails, the first diagnostic command is:
npx eas build:view
Select the failed build and scroll to the error. Common failure signals:
- Gradle dependency resolution error - fix by aligning SDK versions or using
expo install. - Missing environment variable - check
eas secret:listandeas.jsonreferences. - Keystore lost - if you lose the Android keystore, you cannot update the app on Google Play. EAS stores your keystore if you let it generate one; otherwise, keep a backup locally.
EAS Update pushes JavaScript and assets over the air without a store review. The command to publish an update:
npx eas update --branch production --message "Fix crash on login"
The update is linked to the native build through the runtimeVersion field in app.json. For example:
{
"expo": {
"runtimeVersion": "1.0.0"
}
}
Only builds with the same runtime version receive the update. This prevents incompatible native code from downloading a new JS bundle. The main pitfall is confusing runtimeVersion with the app version in package.json. They are independent; runtimeVersion must remain stable for a given native binary.
To verify an update is live:
npx eas update:list --branch production
Expected output shows the updates with their IDs and timestamps. To roll back a bad update, you can republish the previous commit with a new update, or use the --republish flag:
npx eas update --branch production --message "Rollback to last stable" --republish
Always test updates on a staging branch first. A bad OTA update reaches users faster than any store release, so the blast radius is immediate.
Debugging and Diagnostics
When something breaks in an Expo app, the fastest reliable diagnostic path starts with expo start --clear to reset the Metro cache:
npx expo start --clear
If the bundler still fails, inspect the Metro logs for module resolution errors. A classic error is:
Unable to resolve module './src/components/Button' from '/path/to/project/App.tsx'
This means the import path is wrong. Check the file case-sensitivity; Android and iOS file systems are case-insensitive, but Metro is case-sensitive. Rename the file or fix the import and restart the bundler.
For runtime crashes on a development build, use the device logs:
npx react-native log-android
Filter for ReactNativeJS to see JavaScript errors:
ReactNativeJS: Error: Cannot read property 'map' of undefined
Add a temporary console.trace() in the suspected code path and rebuild to see the stack. Once fixed, remove the trace.
Remote debugging has changed: the old Chrome debugger is replaced by React Native DevTools. Launch it with:
npx expo start --dev-client
Then press j in the terminal. This opens a dedicated debugging window with breakpoints, source maps, and the React component inspector. A common mistake is trying to connect Chrome directly to the Metro port, which now requires a special setup and often fails. Use the built-in DevTools for SDK 50 and newer.
To verify the JavaScript bundle is valid for production, export it locally:
npx expo export --platform android --output-dir dist-android
Then serve it with a static server and load it in the Expo Go app using a custom URL. This simulates an OTA update before publishing.
Failure Modes and Recovery
The most disruptive failures are not the ones that happen during development, but those that affect published apps. Here are three concrete failure modes and tested recovery steps.
Failure 1: OTA update installs but app crashes on launch
Symptom: Users report the app immediately closes after the update. Cause: The update references a native module not present in the installed binary, or the JavaScript bundle is corrupted. Recovery:
- Republish the previous known-good update:
npx eas update --branch production --message "Emergency rollback" --republish. - Instruct affected users to force-close the app and reopen; Expo Go and development builds check for updates on launch.
- Investigate the bad update by diffing the commits. Do not publish a new update until the cause is confirmed.
Failure 2: EAS Build succeeds but Google Play rejects the AAB
Symptom: The build finishes, but the Play Console reports an error about version code or signing. Cause: The versionCode was not incremented, or the upload key differs from the previous release. Recovery:
- Check the current
versionCodeinapp.jsonorapp.config.js. - Increment it manually or set
"autoIncrement": trueineas.jsonfor the production profile. - Verify the signing key: if EAS generated the keystore, retrieve it from the EAS dashboard or use
npx eas credentialsto download it. If you lost the keystore and did not use EAS, Google cannot help; you must publish a new app.
Failure 3: Config plugin breaks prebuild on CI
Symptom: Local prebuild works, but CI fails with an error like Cannot find module './plugins/withCustomPlugin'. Cause: The plugin file is not included in the upload because .easignore or .gitignore excludes the plugins directory. Recovery:
- Check the build log for the exact missing file.
- Add the missing file to the project and ensure
.easignoredoes not exclude it (default.easignoreincludes everything not committed, but if the plugin is gitignored, it will not upload). - Commit the plugin and rerun the build.
Each recovery step assumes you have a baseline inventory from the first section. Without version numbers and channel names, recovery becomes guesswork.
Common Pitfalls
Beyond the failure modes above, several pitfalls repeatedly appear in Expo projects:
1. Treating app.json as the only source of truth
Many developers edit app.json and expect the change to apply immediately, but if app.config.js exists, it overrides app.json. The merged config is only visible with npx expo config --type public. Always run that command after config changes.
2. Using the wrong package versions
Running npm install react-native-svg without npx expo install may install a version incompatible with the current SDK. This causes cryptic native errors. The fix is npx expo install react-native-svg which picks the correct version.
3. Ignoring the runtimeVersion in OTA updates
If runtimeVersion changes, existing builds stop receiving updates. This is intentional but often accidental. Keep runtimeVersion stable for a given native build, and only bump it when the native code changes.
4. Committing secrets in app.config.js
Environment variables are tempting, but expo-constants exposes extra to the client. Use EAS secrets and server-side validation.
5. Deleting the ios and android folders without a prebuild plan
If you use development builds, the native folders are generated. Deleting them is safe if you have config plugins and run npx expo prebuild again. But if you manually edited those folders, deleting them loses work. Recover by re-applying changes as plugins.
6. Not testing OTA updates before publishing to production
Always publish to a staging branch, install the update on a test device, and verify. A bad production update affects every user immediately.
Operations Checklist
Use this checklist before every release or major configuration change:
- [ ] Run
npx expo-doctorand resolve all issues. Owner: Lead Developer. Frequency: every build. - [ ] Run
npx expo config --type publicand verify the output. Owner: DevOps Engineer. Frequency: before each build. - [ ] Confirm
runtimeVersioninapp.jsonmatches the intended native build. Owner: Release Manager. Frequency: every release. - [ ] Check that no secrets are present in
app.config.js,.envfiles, or the exported bundle. Owner: Security Reviewer. Frequency: before production builds. - [ ] Publish an OTA update to a staging channel and test on a physical device. Owner: QA Engineer. Frequency: every update.
- [ ] Verify the latest update is live on the production branch with
npx eas update:list. Owner: Release Manager. Frequency: after each production publish. - [ ] Document any manual native changes as config plugins. Owner: Mobile Developer. Frequency: continuous.
Each item has a single accountable owner to avoid diffusion of responsibility.
Conclusion
Advanced Expo work is not about memorizing commands; it is about understanding the resolution order, the build pipeline, and the failure signals. The practices in this article reduce surprises: inventory first, change one thing at a time, verify with explicit commands, and keep a recovery path.
Start with one low-risk verification: run npx expo config --type public and compare the output with your expectation. Then add a config plugin for a small native change and run npx expo prebuild --no-install. Those two exercises build the muscle memory for the rest.
A reliable Expo workflow makes failures visible, protects secrets, limits changes to the intended resource, and defines recovery before an incident forces the decision. Version everything, automate checks, and never ship a configuration you have not seen in the merged output.