Preface
Over the years as a React Native developer, I've run into the same problem more times than I'd like to admit — migrating legacy projects to newer versions and dealing with the endless headaches that come with it. I've even resorted to what people quietly recommend in the depths of internet forums: copying files to a new project one by one until everything works. It sounds ridiculous, and it is, but sometimes it's the only realistic option. The problem is it doesn't scale — you can't pull that off in a large team with tight delivery cycles.
Today I'm sharing an architecture I designed to attack exactly that: migrating a React Native CLI project to Expo without stopping the development cycle or executing a massive refactoring effort.
The Problem
Older versions of React Native handed you complete control over the native layer. At the time, that made sense — the ecosystem was young, Expo wasn't production-ready, and if you needed something custom you had to go deep into Xcode and Gradle yourself. So you did. Everybody did.
The bill comes later. The ios/ and android/ directories accumulate years of manual configuration: custom build phases, hand-wired SDK integrations, Gradle resolutions nobody fully understands anymore, and Podfile patches that exist for reasons lost to git history. The CI/CD pipeline grows on top of that — environment scripts, pipeline configs, secrets scattered across three different places. Each decision was reasonable at the time. Together, they become a system nobody feels confident touching.
The deeper problem: most React Native developers come from a web background, not a native one. The few who do understand the native layer become critical dependencies — and when they leave, that knowledge walks out with them.
When the moment comes to upgrade for the New Architecture — Fabric, TurboModules, Bridgeless — all that accumulated weight makes it nearly impossible to move. You're not just updating a framework version, you're coordinating changes across the native layer, the pipeline, every third-party library, and a codebase where decisions from three years ago are now load-bearing walls.
What if you could rebuild the entire foundation without touching a single line of business logic and without interrupting your release cycle?
The Strategy
The core of this approach is Metro acting as a package proxy. The bundler intercepts imports of conflicting or abandoned native packages at compile time and silently routes them to shim adapters — Expo-compatible replacements that expose the exact same API. The caller never changes. The package name in the import stays the same. Metro does the swap transparently.
The structural prerequisite that makes this possible is having your business logic separated from your native app shell. Not because shared/ needs to be free of native imports — Metro handles those — but because you need two apps consuming the same codebase simultaneously: legacy/ keeps running in production while expo-bridge/ gets validated alongside it. If your business logic is completely entangled with your native shell, there's no shared layer to build on.
One more constraint worth naming upfront: a shim can only exist if a functional equivalent does. If legacy/ uses a deeply custom native module with no Expo - community package analog, that specific package needs real migration work — the shim buys you time and isolation for everything else, but it can't invent an API that doesn't exist.
The case where I applied this: a React Native CLI 0.79 app where business logic already lived in a shared/ directory consumed by both the mobile app and the web. If yours isn't structured that way yet, that reorganization is a prerequisite — and a separate decision.
This approach applies the Strangler Fig pattern: the new system gradually grows around the old one until it fully replaces it, reducing risk at every step.
One thing worth making explicit: legacy/ keeps running exactly as before. Its Metro config has no shims, its node_modules are untouched, and its build pipeline doesn't know expo-bridge/ exists. The shim layer lives exclusively inside expo-bridge/metro.config.js — it only affects that bundle. You can introduce the new app into your scaffolding with zero risk of regression to what's already in production.
Silent Substitution: How Metro Redirects Imports
The deepest challenge isn't purely technical — it's a problem of accumulated abandonment. Native dependencies in an old React Native project accumulate two types of debt.The first is New Architecture incompatibility: packages written on top of the classic bridge don't work with Fabric or TurboModules without an interop layer, and that layer has limits. The second is upstream abandonment — libraries that were once the de facto standard haven't had active maintenance in years, have no New Architecture support, and at some point stop compiling against new Xcode or Android NDK versions. You discover this after three hours reading closed GitHub issues from 2019.
The problem isn't replacing them — that's manageable. The problem is that legacy/ has dozens of those imports scattered across screens, hooks, and services. Rewriting all of them before you can move forward is exactly the kind of effort that stalls a migration for months.
The solution is in Metro. The bundler has a property called extraNodeModules that lets you intercept imports at compile time and silently route them to completely different files — the shims. A shim exposes exactly the same API as the legacy package but calls the Expo equivalent underneath. blockList hides the old packages from Metro to prevent version collisions.
// metro.config.js — inside expo-bridge/
const { getDefaultConfig } = require('expo/metro-config');
const exclusionList = require('metro-config/src/defaults/exclusionList');
const path = require('path');
const config = getDefaultConfig(__dirname);
// 1. Block legacy app modules to avoid version collisions
config.resolver.blockList = exclusionList([
/legacy\/node_modules\/react\/.*/,
/legacy\/node_modules\/react-native\/.*/,
]);
// 2. Redirect legacy imports to our shim files
config.resolver.extraNodeModules = {
...config.resolver.extraNodeModules,
'react-native-push-notifications': path.resolve(__dirname, 'src/shims/push-notifications.ts'),
'react-native-splash-screen': path.resolve(__dirname, 'src/shims/splash-screen.ts'),
'react-native-config': path.resolve(__dirname, 'src/shims/config.ts'),
};
module.exports = config;
A shim looks like this — same interface, different implementation:
typescript
// src/shims/splash-screen.ts
import * as ExpoSplashScreen from 'expo-splash-screen';
export default {
hide: () => ExpoSplashScreen.hideAsync(),
show: () => { /* no-op: Expo handles this in the app lifecycle */ },
};
The full resolution flow:
The code in legacy/ stays completely unaware that the underlying native infrastructure was replaced.
ios/ and android/ as Build Artifacts, not permanent baggage
With module resolution handled, the focus shifts to native code. The end goal is Expo's Continuous Native Generation (CNG): ios/ and android/ stop living in git and become ephemeral build artifacts generated on demand. Running npx expo prebuild builds them from scratch — clean, reproducible, no accumulated debt.
All custom native configuration lives in app.config.js through Config Plugins:
// app.config.js
export default {
expo: {
name: "MyApp",
newArchEnabled: true,
plugins: [
// Official Expo plugin
["expo-splash-screen", { image: "./assets/splash.png" }],
// Community plugin from a third-party SDK vendor
["@some-vendor/expo-sdk", { apiKey: process.env.SDK_KEY }],
// Local plugins for integrations with no official Expo support yet
"./plugins/withCustomNativeSdk.js",
"./plugins/withManifestFix.js",
]
}
};
Upgrading React Native goes from a stressful multi-week effort to a deterministic, reproducible process.
The real cost of Config Plugins — don't skip this part.
A plugin modifies generated Xcode or Gradle files programmatically. When Expo updates its SDK and changes the native project templates — every major version — your plugins can break, sometimes silently. What starts as one or two plugins grows: every third-party SDK without official support, every manifest conflict fix, every custom network config. Each one is a maintenance surface someone has to understand when things blow up at 2am in CI.
What actually helps: one plugin, one responsibility. npx expo prebuild --clean on every PR as part of CI. And document the why behind each plugin — many exist to patch bugs the upstream package will eventually fix, and without documentation you never know which ones can be deleted.
The Result
The product team keeps shipping features to production while the infrastructure gets replaced underneath them. Zero native folders in git, New Architecture running, updated SDKs, modern CI/CD.
Most importantly: zero risk to the developers still working in legacy/. The minefield isn't navigated — it's bypassed entirely, until it can finally be dismantled.



Top comments (0)