How My Software Development Team Upgraded Expo in a Monorepo

Recently, my software development team needed to upgrade our Expo version (it was at 52). Doing so in a monorepo containing multiple web and mobile apps was a big job—when we upgraded, we’d need to verify that the React and React Native versions in all the apps in the monorepo were compatible with the new version, and handle any version skew or dependency conflicts that arose. Here are my takeaways from my walk through versioning hell.

Context

We have two React Native apps in our monorepo, and several web apps. We’re using pnpm workspaces to manage our dependencies. This means that we have a pnpm-workspace.yaml file at the root of the monorepo, and a package.json file in each app (and in the root). We’re also using the Expo CLI to build and run our apps.

Why Upgrading Expo Means Upgrading React and React Native

Each Expo SDK pins a specific React Native version (e.g., SDK 53 bundles React Native 0.79), and each React Native version in turn pins a specific React version. These aren’t suggestions—Expo’s version-locked standard library guarantees compatible native binaries only when you use the React Native version it was built against. Upgrading Expo without upgrading React Native (or vice versa) results in peer dependency conflicts and broken native builds.

In a monorepo this compounds further: our shared packages declare React as a peer dependency, so every consuming app—web and mobile—must provide the same React version. You can’t have the native apps on React 19 while the web apps sit on React 18 without peer dependency mismatches. This is why upgrading Expo in a monorepo is effectively an all-or-nothing operation across Expo, React Native, and React.

Following the Expo-proposed Workflow

We used a few different strategies to upgrade the Expo version in the monorepo successfully before we realized there was an Expo-proposed workflow to upgrade. That said, our first tries weren’t pointless—we ended up using most of the commits from each spike. Here are the strategies we tried:

  • Upgrade to Expo SDK 53, follow the trail of errors, and get all apps to build.
  • Upgrade to Expo SDK 54, follow the trail of errors, and get all apps to build.
  • Attempt at piecemeal upgrade: upgrade React and React Native to the latest version, in anticipation of upgrading Expo to 54. Note: This mistake informed the above section 🙃.
  • Upgrade Expo using the Expo-proposed workflow, get the mobile apps to build, and then contend with the web apps.

The Expo-proposed strategy ended up being the most successful, but it was guided / helped by the first three strategies. What we’d found out from the first three strategies was that:

  1. We needed to upgrade React and React Native to the latest, Expo-supported version, and doing so could not be done incrementally for the version skew reasons described above.
  2. Upgrading one native dependency at a time was incredibly time-consuming and error-prone. It left us attempting fixes that were unnecessary, and wasted time.
  3. Getting the mobile apps to build would be the hardest part, so focusing on building the mobile apps first would be the best approach.

Note: We kept each of these attempts on their own branch, and then cherry-picked a great deal of the commits onto the final attempt branch. This allowed us to save time and effort during the final strategy by having nicely defined, atomic commits to determine which fixes were necessary. One thing I’d do differently, is to save the errors associated with each commit in some fashion so that when we encountered the same error we could reference how we fixed it.

Using Expo’s Versioning Tools

Ultimately, we accomplished upgrading versions most succinctly by using Expo’s built-in command npx expo install –latest, and following their documentation in both native apps. This upgraded the version of the package to the latest version that is compatible with the other packages in the workspace. We then compared the changes between the two apps, searching for misaligned versions.

This resolved quite a few of our issues right away, and we were able to move quickly through some of the known issues we’d already solved in the previous attempts.

Another key here is using Expo’s built-in version doctor to help identify gaps: npx expo doctor. If there are issues, you can use npx expo install –fix to fix the issues.

Note: This will not always fix the issues, but we’ve found it can resolve most issues (more on this later).

Another note: early on, we realized that we needed to match the versions between the two native apps. This meant that after updating the versions in one React Native app, we needed to update the version in the other React Native app to match. This was a bit of a pain, but it was necessary to ensure that the two apps were using the same version of React Native.

Verifying by Building and Running

Throughout the upgrade, we verified correctness by consistently attempting a build and, if successful, running that build. This was the feedback loop: upgrade, build, fix errors, build again, run. Many of the issues we encountered (Firebase, NativeWind, component libraries) only surfaced at build time or at runtime, so there was no shortcut here—you have to build and run to know you’re in a good state.

Considering a ~ Instead of a ^

Another issue we ran into: breaking changes during the upgrade. React Native and Expo ship breaking native changes even on minor releases, so caret ranges (^) can silently pull in a new minor that changes native code, breaking iOS/Android builds. Using a tilde (~) locks you to patch updates only (e.g., ~54.0.20 → 54.0.x), giving you reproducible native binaries while still receiving bug fixes. It’s not the most likely issue to occur while you’re upgrading, but good to be aware of.

Poorly-Supported Component Libraries

Once the Expo upgrades were complete, many components weren’t working correctly (e.g., an image viewer component among others). The issue? Poorly-maintained component libraries. This meant searching through each non-working component, determining which React Native version it supported, and then finding that it was not compatible with the new version of React Native. We ended up either finding a new component library that was compatible with the new version of React Native, or using a native component that did the same thing.

Prepare for Expo config Updates

One example: after upgrading to Expo SDK 54, our iOS builds failed with @react-native-firebase compilation errors—specifically, “include of non-modular header inside framework module” for targets like RNFBApp and RNFBAuth. This was a known issue where React Native Firebase imports React headers in ways incompatible with Expo’s static framework configuration.

We were lucky—by the time we got to the version upgrade, this issue had been resolved upstream. But we still had to replay all the related fixes from that issue thread into our config. The fix involved updating the expo-build-properties plugin in app.json:

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

Takeaway: anticipate needing to adjust your Expo config plugins when upgrading.

Double-check Other Dependencies

Beyond badly behaving component libraries, we noticed that the mobile app didn’t scroll correctly. This turned out to be due to a subtly broken NativeWind style transformation—NativeWind wasn’t correctly applying the layout constraints needed for scroll containers. The fix was to update NativeWind to a version compatible with the new React Native version. TL;DR: don’t rely on Expo to upgrade all your dependencies and do lots of manual testing—anticipate going through each dependency and verifying compatibility with the new version of React Native.

For diagnosing dependency issues during the upgrade, pnpm’s built-in tools were invaluable. I’ve written about those commands in detail in a previous post on migrating an app to a monorepo—specifically pnpm why, pnpm view <package> versions, and pnpm list –depth Infinity for chasing version conflicts.

Conclusion

Upgrading Expo in a monorepo is not just an Expo upgrade—it’s a coordinated version bump across Expo, React Native, React, and every dependency that touches them. The version coupling between these libraries means there’s no incremental path; you’re committing to an all-or-nothing move and then working through the fallout.

What made this manageable was spiking on multiple approaches first, keeping each attempt on its own branch with atomic commits, and leaning on Expo’s own tooling (npx expo install –latest, npx expo doctor) to do the heavy lifting where it could. The rest was methodical: build, run, fix, repeat—and don’t assume Expo caught everything. Third-party libraries, config plugins, and styling dependencies all needed individual attention.

If you’re about to do this yourself: spike early, commit atomically, and budget time for the long tail of runtime issues that only surface after a successful build.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *