Skip to content
← Back to blog
Expo and React Native iOS

How to run your first iOS build of an Expo app in a pnpm monorepo

14 min read

Written by: Jonathan Reis on

Android built with `pnpm sudoku:apk:dev` for weeks. I borrowed an iPhone and needed the same Sudoku running on iOS. The path is `expo prebuild`, `pod install`, `xcodebuild Release`, `simctl install` — but three things between prebuild and the simulator block the build in a pnpm monorepo, and none of them show up on Android.

OpenGraph preview image for this article. How to run your first iOS build of an Expo app in a pnpm monorepo

Android built with pnpm sudoku:apk:dev for weeks. The JS bundle was embedded in the APK via Gradle, no Metro in runtime, and the app ran on the emulator. I borrowed an iPhone and needed the same Sudoku running on iOS. The path is expo prebuild, pod install, xcodebuild Release, simctl install — but three things between prebuild and the simulator block the build in a pnpm monorepo, and none of them show up on Android.

This post is the step-by-step of how I did it, including where each trap appears and how to fix it in versioned source files (not in generated files that vanish on the next prebuild).

The starting point

The app is a Sudoku on Expo SDK 56 + React Native 0.86.0, in a pnpm monorepo with nodeLinker: hoisted. It uses native plugins: Firebase, Sentry, AdMob, expo-sqlite, expo-audio. Android built with pnpm sudoku:apk:dev, which runs expo prebuild --platform android + gradlew assembleRelease, embeds the JS bundle in the APK, and installs on the device.

On iOS, the equivalent is: generate the native Xcode project with expo prebuild --platform ios, install pods with pod install, compile with xcodebuild -configuration Release, and install on the simulator with simctl install. The JS bundle is embedded in the .app by the Xcode “Bundle React Native code and images” build phase — no Metro in runtime, same as Android.

Step 1: install CocoaPods

The macOS had Xcode 26.6 but no CocoaPods. Without it, pod install does not exist and the .xcworkspace is not generated.

brew install cocoapods

Step 2: generate the iOS project with expo prebuild

pnpm --filter sudoku config:generate
pnpm --filter sudoku exec expo prebuild --platform ios --no-install

This creates the apps/sudoku/ios/ folder with the native Xcode project. --no-install skips the automatic pod install (I run it separately for control).

Before this, I needed to make sure the Firebase GoogleService-Info.plist existed. The app.config.js references ./GoogleService-Info.plist, and prebuild copies it into the iOS project. Without it, the build fails. I run:

sh scripts/sudoku-native.sh firebase-ios

This script reads SUDOKU_FIREBASE_IOS_APP_ID and SUDOKU_FIREBASE_IOS_API_KEY from apps/sudoku/.env and generates the plist. Below I explain what happens when .env has placeholders.

Step 3: install pods

cd apps/sudoku/ios && pod install

121 pods installed, DailySudoku.xcworkspace created. This is the file you open in Xcode (not the .xcodeproj).

Trap 1: babel cannot resolve plugins in isolated pnpm

pod install passed. But when I ran the first xcodebuild Release, the “Bundle React Native code and images” build phase failed:

Failed to construct transformer: Error: Cannot find module 'babel-preset-expo'
Require stack:
- .../.pnpm/@[email protected]/node_modules/@babel/core/lib/config/files/plugins.js

The first reaction was to add babel-preset-expo to the app’s devDependencies. The error changed to @babel/plugin-transform-react-jsx not found. The temptation is to add each missing plugin manually — but the root cause is deeper.

What happens: babel loads babel.config.js from @babel/core, which lives in a pnpm isolate at .pnpm/@[email protected]/node_modules/@babel/core/. When babel resolves the plugins declared in the preset (babel-preset-expo does require('@babel/plugin-transform-react-jsx')), it re-resolves the names from itself, not from the preset that declared them. And from the @babel/core isolate, the plugins are not accessible as siblings — they are in separate isolates.

The fix is twofold and goes in versioned sources:

1. Add @babel/core and babel-preset-expo as explicit devDependencies of the app, in apps/sudoku/package.json:

"devDependencies": {
  "@babel/core": "^7.29.7",
  "babel-preset-expo": "~56.0.15"
}

This ensures pnpm creates symlinks in apps/sudoku/node_modules/, making them accessible to the Node resolution tree.

2. Keep nodeLinker: hoisted in pnpm-workspace.yaml. With hoisted, transitive dependencies (including @babel/plugin-*) are elevated to the root node_modules/, where Node finds them when walking up the tree from the @babel/core isolate.

After pnpm install --force, the root node_modules/@babel/ had 85 packages, and the JS bundle completed: 2535 modules, 42 assets.

Step 4: compile with xcodebuild Release

cd apps/sudoku/ios
xcodebuild -workspace DailySudoku.xcworkspace \
  -scheme DailySudoku \
  -configuration Release \
  -sdk iphonesimulator \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
  -derivedDataPath build \
  SENTRY_DISABLE_AUTO_UPLOAD=1 SENTRY_ALLOW_FAILURE=true

SENTRY_DISABLE_AUTO_UPLOAD=1 and SENTRY_ALLOW_FAILURE=true prevent the Sentry source map upload from aborting the build when there is no valid auth token (not needed for local dev builds).

Trap 2: ExpoModulesCore prebuilt incompatible with Hermes from react-native 0.86.0

The JS bundle passed. xcodebuild compiled. simctl install worked. But when the app launched on the simulator, it crashed immediately:

Symbol not found: __ZN8facebook3jsi5Value12strictEqualsERNS0_7RuntimeERKS1_S5_
Referenced from: ExpoModulesCore.framework
Expected in: hermesvm.framework

Demangling: facebook::jsi::Value::strictEquals(Runtime&, ...). The ExpoModulesCore.framework references strictEquals with Runtime (concrete class), but hermesvm.framework from [email protected] only has strictEquals with IRuntime (abstract interface). React Native 0.86.0 changed the JSI hierarchy — Runtime became IRuntime, and the symbols were renamed.

The diagnosis came from the crash report at ~/Library/Logs/DiagnosticReports/DailySudoku-*.ips. The termination.reasons field shows which symbol is missing and which framework it is expected from. nm confirmed the difference:

nm .../hermesvm.framework/hermesvm | grep strictEquals
# __ZN8facebook3jsi5Value12strictEqualsERNS0_8IRuntimeERKS1_S5_  (IRuntime)

nm .../ExpoModulesCore.framework/ExpoModulesCore | grep strictEquals
# U __ZN8facebook3jsi5Value12strictEqualsERNS0_7RuntimeERKS1_S5_  (Runtime, undefined)

The U means “undefined reference” — ExpoModulesCore expects the symbol from hermesvm, but the name does not match.

The cause: ExpoModulesCore is distributed as a precompiled xcframework when EXPO_USE_PRECOMPILED_MODULES=1 (the Expo default). That binary was compiled against an older JSI. [email protected] already has the new JSI with IRuntime.

The fix goes in app.json, in the expo-build-properties plugin:

[
  "expo-build-properties",
  {
    "ios": {
      "useFrameworks": "static",
      "buildReactNativeFromSource": true,
      "usePrecompiledModules": false
    }
  }
]

This makes prebuild inject EXPO_USE_PRECOMPILED_MODULES=false and ios.buildReactNativeFromSource=true into Podfile.properties.json. After pod deintegrate && pod install, the logs confirm:

[ReactNativeDependencies] Building from source: true
[ReactNativeCore] Building from source: true

The first build takes longer (it compiles React Native and ExpoModulesCore from source instead of linking prebuilts), but the symbols match because everything is compiled against the same JSI.

This does not happen on Android because Gradle compiles React Native and Expo modules from source by default — there is no equivalent to the precompiled ExpoModulesCore.xcframework.

Step 5: install and launch on the simulator

xcrun simctl install booted apps/sudoku/ios/build/Build/Products/Release-iphonesimulator/DailySudoku.app
xcrun simctl launch booted com.sunstoneapps.dailysudoku

Trap 3: Firebase and AdMob with empty credentials

The app passed dyld and babel, but crashed on launch with:

*** Terminating app due to uncaught exception 'com.firebase.core',
reason: 'Configuration fails. It may be caused by an invalid GOOGLE_APP_ID
in GoogleService-Info.plist'

The GoogleService-Info.plist had been generated by sudoku-native.sh firebase-ios, which reads SUDOKU_FIREBASE_IOS_APP_ID from .env. But the .env had literal placeholders — the value was the variable name itself:

SUDOKU_FIREBASE_IOS_APP_ID="SUDOKU_FIREBASE_IOS_APP_ID"

Firebase validates the format of GOOGLE_APP_ID (1:number:ios:hash) and API_KEY (39 characters in AIzaSy... format) on native launch. With placeholders, both fail.

After fixing Firebase, AdMob crashed with GADInvalidInitializationException because GADApplicationIdentifier in Info.plist was empty. The AdMob iosAppId was in app.json, but @apps/ads/node (which resolves AdMob plugins during prebuild) reads app IDs from the environment config YAML, not from app.json. And config.development.yml did not have nativeAppIds.ios.

The fix has two parts, both in versioned sources:

1. Add nativeAppIds.ios to apps/sudoku/src/config.development.yml:

ads:
  nativeAppIds:
    android: ca-app-pub-3940256099942544~3347511713
    ios: ca-app-pub-3940256099942544~1458002511

This makes @apps/ads/node inject the iosAppId into the AdMob plugin during prebuild, and GADApplicationIdentifier appears in Info.plist.

2. Modify generate_firebase_ios in scripts/sudoku-native.sh to detect placeholders (when the value equals the variable name) and generate a valid mock:

const isPlaceholder = (name, value) => !value || value === name;

if (isPlaceholder("SUDOKU_FIREBASE_IOS_APP_ID", requiredValues.SUDOKU_FIREBASE_IOS_APP_ID)) {
  // use mock: "1:000000000000:ios:0000000000000000"
  // and warn on console
}

This way, another developer running sh scripts/sudoku-native.sh firebase-ios without real credentials gets a plist that does not crash Firebase on boot. Analytics does not work with the mock, but the game is playable.

The complete flow, from scratch to simulator

After applying the three fixes in versioned sources, the complete iOS build flow is:

# 1. Install CocoaPods (once per machine)
brew install cocoapods

# 2. Generate GoogleService-Info.plist (mock if .env has placeholders)
sh scripts/sudoku-native.sh firebase-ios

# 3. Generate the native iOS project
pnpm --filter sudoku config:generate
cd apps/sudoku && node ../../node_modules/expo/bin/cli prebuild --platform ios --clean --no-install

# 4. Install pods
cd ios && pod install

# 5. Compile Release
xcodebuild -workspace DailySudoku.xcworkspace \
  -scheme DailySudoku -configuration Release \
  -sdk iphonesimulator \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
  -derivedDataPath build \
  SENTRY_DISABLE_AUTO_UPLOAD=1 SENTRY_ALLOW_FAILURE=true

# 6. Install and launch on the simulator
xcrun simctl install booted build/Build/Products/Release-iphonesimulator/DailySudoku.app
xcrun simctl launch booted com.sunstoneapps.dailysudoku

Everything survives prebuild --clean because the fixes are in app.json, config.development.yml, apps/sudoku/package.json, pnpm-workspace.yaml, and scripts/sudoku-native.sh — no file in ios/ is manually edited.

Checklist before your first iOS build

  • CocoaPods installed. Without it, pod install does not exist.
  • @babel/core and babel-preset-expo in the app’s devDependencies. pnpm with nodeLinker: hoisted may not expose them in the babel resolution tree. Without them, Cannot find module 'babel-preset-expo' in the Xcode build phase.
  • expo-build-properties with buildReactNativeFromSource: true and usePrecompiledModules: false. If react-native is a version that changed the JSI ABI (like 0.86.0 with IRuntime), the ExpoModulesCore.xcframework prebuilt is incompatible. Disable it and compile from source to align symbols.
  • nativeAppIds.ios in the development environment config YAML. Without it, GADApplicationIdentifier is empty in Info.plist and AdMob crashes on boot.
  • GoogleService-Info.plist generated with valid credentials (or mock). Firebase validates GOOGLE_APP_ID and API_KEY on launch. Literal placeholders in .env produce an invalid plist.
  • Crash report at ~/Library/Logs/DiagnosticReports/. When the app crashes on simulator boot, the .ips file shows the exact missing symbol and which framework it is expected from.

Conclusion

The first iOS build of an Expo app in a pnpm monorepo has three points that block and do not appear on Android: babel cannot resolve plugins because isolated pnpm does not expose them in the @babel/core resolution tree, the ExpoModulesCore prebuilt is compiled against a different JSI than the hermesvm.framework from [email protected], and Firebase/AdMob crash with empty credentials. Each fix goes in a different versioned source (package.json, app.json, config.development.yml, sudoku-native.sh), and the complete flow — prebuild, pod install, xcodebuild, simctl — works without manual editing of generated files.

Related postWhen astro check says a dependency is missing but the real bug is a pnpm override8 min readWritten by: Jonathan Reis on