Skip to content

Fix: Expo Not Working — Build Failing, Native Module Not Found, or EAS Build Error

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Expo issues — Expo Go vs development builds, native module installation with expo-modules-core, EAS Build configuration, bare workflow setup, and common SDK upgrade problems.

The Problem

A native module throws an error when running in Expo Go:

Error: Invariant Violation: `new NativeEventEmitter()` requires a non-null argument.

Or the app crashes with a “module not found” error after installing a package:

Unable to resolve module 'expo-camera' from 'src/App.tsx':
None of these files exist: expo-camera/build/...

Or an EAS Build fails with a cryptic Gradle or Xcode error:

> Task :app:processDebugResources FAILED
AAPT: error: resource attr/colorPrimary (aka com.myapp:attr/colorPrimary) not found.

Or after upgrading the Expo SDK, features stop working:

Error: Cannot find native module 'ExpoCamera'

Why This Happens

Expo has two distinct workflows with different constraints:

  • Expo Go only supports Expo SDK modules — Expo Go is a pre-built app that includes a fixed set of native modules. Any package with custom native code that isn’t part of the Expo SDK won’t work in Expo Go. You need a development build for those packages.
  • Native modules require a build step — JavaScript-only packages work immediately after npm install. Native modules (anything with android/ and ios/ directories) require running npx expo prebuild or rebuilding the native project. Without this, the module is installed but its native code isn’t linked.
  • EAS Build differences from local builds — EAS builds run in a clean CI environment. Environment variables, credentials, and native project modifications (patches, custom gradle scripts) must be explicitly configured.
  • SDK upgrades change native dependencies — major Expo SDK upgrades update the underlying React Native version and native module APIs. Running npx expo install (not npm install) ensures version-compatible packages.

The core mental shift is that Expo is no longer the “managed-only” platform it was years ago. The continuous native generation (CNG) model lets you start in the managed workflow, then drop down to bare native code whenever you need a custom module — without going through a one-way ejection. The native ios/ and android/ directories are treated as build artifacts that expo prebuild regenerates from app.json and your installed plugins. That makes most “native module not found” errors solvable by re-running prebuild and rebuilding, rather than by manually editing native files.

The second source of confusion is that Expo Go and a development build look identical at runtime but expose different capabilities. Expo Go ships with the Expo SDK baked in, so it can run any project that only uses SDK packages — but the moment you install a community native module like react-native-mmkv or react-native-vision-camera, Expo Go has no way to load that native code. A development build is your own custom Expo Go: it includes the same dev tooling but adds your project’s native modules. Once your project depends on anything outside the SDK, the development build becomes mandatory, and trying to keep Expo Go working past that point wastes hours.

Fix 1: Use the Right Workflow for Your Needs

Expo Go — zero setup, great for prototyping, limited to Expo SDK modules:

# Create a new Expo project
npx create-expo-app MyApp

# Start dev server
npx expo start
# Scan QR code with Expo Go app

Development Build — custom native modules, most like production:

# Install expo-dev-client
npx expo install expo-dev-client

# Build a development client for your device
npx eas build --profile development --platform ios
# OR locally (requires Xcode/Android Studio):
npx expo run:ios
npx expo run:android

# Start the dev server (points to your dev build)
npx expo start --dev-client

When to use each:

FeatureExpo GoDevelopment Build
Custom native modules
All Expo SDK packages
No build required
Production-like behavior
Over-the-air updates (EAS Update)

Fix 2: Install Native Modules Correctly

Always use npx expo install instead of npm install for Expo packages — it picks the version compatible with your SDK:

# CORRECT — Expo-compatible version
npx expo install expo-camera expo-location expo-notifications

# WRONG — may install incompatible version
npm install expo-camera  # Might install wrong version for your SDK

# Check if installed packages are compatible with current SDK
npx expo install --check

# Fix incompatible packages
npx expo install --fix

After installing a native module, rebuild the native project:

# Option 1: Prebuild (managed workflow → bare workflow)
npx expo prebuild
# Creates android/ and ios/ directories with native code

# Option 2: Run directly (rebuilds automatically)
npx expo run:ios    # Builds and runs on iOS simulator
npx expo run:android  # Builds and runs on Android emulator/device

# Option 3: EAS Build
npx eas build --profile development --platform all

Verify a package works with Expo:

# Check Expo compatibility
npx expo install --check some-package

# Look for the Expo config plugin
# expo-modules-core compatible packages have an app.json plugin or
# an expo-module.config.json in their package

Fix 3: Configure app.json and Expo Plugins

Native modules often require config plugins to modify the native project:

// app.json
{
  "expo": {
    "name": "MyApp",
    "slug": "my-app",
    "version": "1.0.0",
    "sdkVersion": "52.0.0",
    "platforms": ["ios", "android"],

    "plugins": [
      // Simple plugin — string
      "expo-camera",

      // Plugin with options
      ["expo-camera", {
        "cameraPermission": "Allow $(PRODUCT_NAME) to access your camera."
      }],

      // expo-notifications requires extensive config
      ["expo-notifications", {
        "icon": "./assets/notification-icon.png",
        "color": "#ffffff",
        "sounds": ["./assets/notification.wav"]
      }],

      // Custom plugin
      "./plugins/withCustomGradleConfig"
    ],

    "ios": {
      "bundleIdentifier": "com.yourcompany.myapp",
      "buildNumber": "1",
      "infoPlist": {
        "NSLocationWhenInUseUsageDescription": "Used to show nearby restaurants."
      }
    },

    "android": {
      "package": "com.yourcompany.myapp",
      "versionCode": 1,
      "permissions": [
        "ACCESS_FINE_LOCATION",
        "CAMERA"
      ]
    },

    "extra": {
      "eas": {
        "projectId": "your-eas-project-id"
      }
    }
  }
}

After changing app.json plugins, re-run prebuild:

npx expo prebuild --clean  # --clean removes and regenerates native dirs
# Then rebuild:
npx expo run:ios

Fix 4: Set Up EAS Build

# Install EAS CLI
npm install -g eas-cli

# Login to Expo account
eas login

# Initialize EAS in your project
eas build:configure
# Creates eas.json
// eas.json
{
  "cli": {
    "version": ">= 12.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": {
        "simulator": true
      }
    },
    "preview": {
      "distribution": "internal",
      "android": {
        "buildType": "apk"  // APK for internal distribution
      }
    },
    "production": {
      "ios": {
        "credentialsSource": "remote"
      },
      "android": {
        "buildType": "app-bundle"  // AAB for Play Store
      }
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "[email protected]",
        "ascAppId": "1234567890"
      },
      "android": {
        "serviceAccountKeyPath": "./google-service-account.json"
      }
    }
  }
}
# Build for development
eas build --profile development --platform ios

# Build for all platforms
eas build --profile production --platform all

# Submit to app stores
eas submit --platform ios
eas submit --platform android

Fix common EAS Build errors:

# "Gradle build failed" — often a dependency conflict
# In eas.json, add:
{
  "build": {
    "production": {
      "android": {
        "gradleCommand": ":app:bundleRelease",
        "buildType": "app-bundle"
      }
    }
  }
}

# Environment variables in EAS Build
eas secret:create --scope project --name API_URL --value https://api.example.com

# Or in eas.json:
{
  "build": {
    "production": {
      "env": {
        "APP_ENV": "production",
        "API_URL": "https://api.example.com"
      }
    }
  }
}

Fix 5: Upgrade Expo SDK

Expo SDK upgrades are non-trivial but the CLI guides you:

# Check current SDK version
npx expo --version

# Upgrade to latest SDK
npx expo install expo@latest

# Install compatible versions of all Expo packages
npx expo install --fix

# For major version upgrades, use the upgrade command
npx expo upgrade
# Follow the prompts — it updates app.json sdkVersion and dependencies

# After upgrading, check for breaking changes
# https://expo.dev/changelog

Common post-upgrade fixes:

# Clear caches after upgrade
npx expo start --clear
# iOS:
npx expo run:ios --no-install  # Skip pod install if already done
cd ios && pod install  # Then manually run pod install
# Android:
cd android && ./gradlew clean

# If prebuild generates conflicts with manual native changes:
npx expo prebuild --clean  # WARNING: overwrites ios/ and android/

Fix 6: Over-the-Air Updates with EAS Update

Push JavaScript changes without a new app store submission:

# Install EAS Update
npx expo install expo-updates

# Configure
eas update:configure

# Push an update to the preview channel
eas update --branch preview --message "Fix login bug"

# Push to production
eas update --branch production --message "v1.2.0 release"
// app.json — configure update channels
{
  "expo": {
    "updates": {
      "url": "https://u.expo.dev/your-project-id",
      "fallbackToCacheTimeout": 0,
      "checkAutomatically": "ON_LOAD"
    },
    "runtimeVersion": {
      "policy": "appVersion"  // New native build required when app version changes
    }
  }
}
// Manually check for updates in app
import * as Updates from 'expo-updates';

async function checkForUpdate() {
  try {
    const update = await Updates.checkForUpdateAsync();
    if (update.isAvailable) {
      await Updates.fetchUpdateAsync();
      await Updates.reloadAsync();  // Restart with new version
    }
  } catch (error) {
    console.error('Update check failed:', error);
  }
}

Expo vs Bare React Native, Flutter, Capacitor — Picking the Right Stack

Expo isn’t the only way to ship a cross-platform mobile app, and the choice has real consequences for build infrastructure, native-module access, and update strategy.

Expo (managed + EAS). Fastest path to shipping. EAS Build handles CI/CD without you maintaining Xcode and Gradle machines. EAS Update pushes JS-only changes over the air, skipping the App Store review for code changes that don’t touch native modules. The downside is that you’re betting on Expo’s roadmap for any native module you don’t write yourself; if a community library doesn’t ship a config plugin, you write one or wait. Best fit: product teams that want to focus on the React code, not CI maintenance.

Bare React Native. You own the android/ and ios/ projects fully. Every native module installs with whatever instructions the library publishes — no expo prebuild regeneration step. Trade-off: you need real CI (GitHub Actions with macOS runners, Bitrise, etc.) and you re-implement features Expo gives you for free (OTA, config plugins, dev menus, error reporting). Best fit: apps with heavy native customization, teams with existing iOS/Android expertise, or projects that can’t accept Expo’s release cadence.

Flutter. Different language (Dart), different rendering model (Skia-painted widgets instead of native views). Single codebase across iOS, Android, and now web/desktop with the same widget tree. Hot reload is faster than RN’s Fast Refresh, and the build pipeline is simpler because Flutter doesn’t bridge to native UI per frame. Trade-off: if you have an existing React web codebase, you can’t share components. Best fit: greenfield mobile-first apps where Dart isn’t a blocker.

Capacitor (Ionic). Wraps a web app in a native shell, similar to Cordova but with a saner plugin API. The UI is HTML rendered in a WebView, not native components. Lowest learning curve for web developers, but performance and feel are visibly worse than RN or Flutter for animation-heavy screens. Best fit: PWAs that need access to a few native APIs (camera, push notifications) and where pixel-perfect native feel isn’t required.

The OTA-update story is what often locks people into Expo. EAS Update lets you ship a bug fix in minutes instead of waiting on App Store review. Microsoft’s CodePush filled this role for bare RN, but Microsoft retired CodePush in 2025 and the supported replacement (Expo’s expo-updates) only works fully in Expo projects. Bare-RN projects can use third-party services like Pushy or self-host the expo-updates server, but the developer experience is closer to “build it yourself.” For native build pipelines, see React Native Android build failed for issues that occur regardless of which workflow you pick.

Still Not Working?

“Module not found” after npx expo install — you installed the package but didn’t rebuild the native code. JavaScript-only packages work immediately, but packages with native code require npx expo run:ios or npx expo run:android to link the native modules. If using Expo Go, check if the package is supported — look for “Expo Go” compatibility on the package’s Expo documentation page.

App works on simulator but crashes on device — common causes: (1) permissions not declared in app.json — add required permissions to the ios.infoPlist or android.permissions sections, (2) architecture mismatch — physical iOS devices require arm64 builds, simulators use x86_64/arm64, (3) provisioning profile issues — check your Apple Developer account and EAS credentials.

Hermes engine errors — Expo 50+ uses Hermes by default. Hermes doesn’t support all JavaScript features (some Proxy and Reflect behaviors differ). If a package fails only on Hermes, check if the package has a known Hermes incompatibility. As a last resort, disable Hermes in app.json:

{
  "expo": {
    "jsEngine": "jsc"  // Switch back to JavaScriptCore — not recommended
  }
}

expo-router shows a blank screen with no error — file-based routing depends on app/_layout.tsx being present and exporting a valid React component. If you have app/index.tsx but no _layout.tsx, the router renders nothing. Also check that you imported expo-router/entry in package.json’s main field, not a custom entry point. See Expo Router not working for routing-specific issues.

Metro bundler crashes with “Unable to resolve module” only on the second start — Metro’s cache can serialize a stale module graph after switching branches or upgrading packages. Stop the server, delete the Metro cache (rm -rf $TMPDIR/metro-* $TMPDIR/haste-map-* on macOS), and restart with --clear. If the error survives a cache reset, the dependency is genuinely missing — re-run npm install and confirm the package appears in node_modules. See React Native Metro bundler failed for more cache-related fixes.

Push notifications work in development but never arrive in production builds — the most common cause is APNs credentials in EAS not being set for the production environment. Run eas credentials and confirm the production push key is uploaded for iOS, and that google-services.json for the production Firebase project is committed for Android. The development build uses Expo’s push notification service automatically; production builds need real APNs/FCM credentials.

Animations stutter or never start after upgrading the SDK — Expo SDK upgrades change the underlying react-native-reanimated major version, which can leave your Babel plugin order or worklet syntax incompatible. Re-run npx expo install --fix to align versions, then confirm react-native-reanimated/plugin is still the last plugin in babel.config.js. See React Native Reanimated not working for worklet rules that changed between Reanimated 2 and 3.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles