Quick answer
Modernizing a legacy mobile app without a rewrite means replacing one feature at a time inside the app you already ship. Pick a screen with a small contract with the host app, add the new code as a module or prebuilt native library, test data, lifecycle and upgrades where old and new code meet, and expand only after that first screen holds up in production.
Step 1: Name the problem the rewrite is supposed to fix
The newest one-star review on your App Store page reads: "Changing my notification settings takes six taps and the screen looks ten years old." Two engineers want to rewrite the whole app in a new framework. In the last retro, though, most of the release delay traced back to manual signing and a flaky test environment, and a new UI framework fixes neither one.
Write down the work you want to make cheaper. It might be business logic duplicated across iOS and Android, a new backend you have to integrate twice, or a screen nobody can test. Some of those problems need an architectural change. Others need better tooling around the code you already have.
List what still works. A mature app does things users rely on that no current spec mentions, and a rewrite tends to find them by breaking them. Keep those behaviors running while you learn which ones a new implementation must preserve.
Pick a measurable outcome for the first increment. "Ship the account preferences screen on both platforms from one maintained implementation" is a claim you can test through a real change. "Replace the legacy stack" is not a useful goal, because it rewards deleting old code even when neither the product nor the team's week got better.
Step 2: Pick a first screen with a small contract
Good first screens have one clear entry point and own very little shared state. A standalone notification preferences screen is much easier to lift out than the app's whole sign-in flow.
Write down the contract between the host app and the new screen: what data crosses, which side owns durable state, how the screen closes, and how errors get back to the host. A contract for the preferences screen might look like this:
Keep credentials out of ad hoc message payloads. Route authenticated calls through a mechanism your security team has reviewed, and decide how long any context you pass to the new screen stays valid.
A good contract shrinks what each side needs to know about the other. If the new screen reaches into the host's private state from three directions, you have built a second tightly coupled system that happens to use a newer framework.
Write a contract test for the interaction: what the host supplies, and what the screen returns on success, cancel and failure. Then test the real integrated path on a device, because a mocked contract cannot prove the native lifecycle behaves.
Step 3: Choose how the new code gets into the app
Incremental modernization does not require a particular framework. You can replace a native module with a better native module, move business logic into a shared library, or embed a new UI framework for some screens.
Your team structure decides more than the framework does. One team that changes the host and the new code together every week can integrate source directly. Separate teams with different release rhythms usually do better with a versioned, prebuilt library that the native app pulls in like any other dependency.
| Integration strategy | Best for | Cost to plan for |
|---|---|---|
| Direct source integration | One team that changes both sides together | Shared dependencies and build coordination |
| Prebuilt native library (AAR, XCFramework or Swift package) | Separate teams that want separate development environments | Packaging, publishing and version compatibility |
| Shared business-logic module | Duplicated rules are the main problem | Clear APIs and platform adapters |
| Native feature replacement | The existing Swift or Kotlin approach still fits | Continued per-platform implementation work |
React Native's guide for existing apps covers adding a single view or user flow to a native Android or iOS app. Flutter's add-to-app embeds Flutter as a module and lists its own limits, such as one Flutter library per app and plugins that assume a Flutter activity. Flutter's architecture overview covers its embedding and interop model. Kotlin Multiplatform lets you share business logic and keep both native UIs. Treat each of these as an option to evaluate in your own app, not as proof the migration will be cheap.
Decide how native dependencies get resolved before the new screen grows. The host and the embedded library can each bring a different version of the same native SDK, and a first successful build does not prove the combined app has no runtime conflict.
Count the edits to the host. If the integration changes the app's entry point or lifecycle handling, those changes belong in the migration plan and in its code review.
Step 4: Test data, lifecycle and upgrades where old and new code meet
The operating system has never seen your architecture diagram. It will pause or kill the app in the middle of the new screen whenever it likes.
Enter and leave the new screen repeatedly. Send the app to the background while a request is in flight, reopen it through a deep link, and check that the host receives the right result when the user cancels.
Decide who owns each piece of state. If the old and new code can both write the same record, define how they avoid overwriting each other. Shared storage is a contract even when neither side calls the other's code.
Keep older app versions working during data migrations. Your backend will serve both implementations for a while, and removing an API because the new screen stopped using it can break an older build still installed on a customer's phone.
Test upgrades as well as clean installs. Install the current public version, create realistic data, then install the new build over it. A clean install exercises different assumptions, so run it separately.
Measure the cost of the new runtime. An embedded framework can add startup time or memory even when it powers one screen, so check its initialization on real devices and decide whether to load it at launch or on first use.
Don't call the migration reversible until you have tested the way back. Switching to the old screen may not restore data the new one has already rewritten.
Version the contract between host and new screen
The host app, the embedded screen and the backend all change on their own schedules. Record which combination each build contains, because a shared library's version number alone says nothing about whether the host that loads it understands every result.
Take a migrated checkout screen that returns a result to the existing order history. The first contract returns an order identifier on success and an explicit cancel result. If a later version adds a pending-payment state, the host has to understand it before the screen can return it. Never let an unknown value quietly count as success.
A small compatibility record covers the combinations that matter:
| Combination | Question to test |
|---|---|
| Current host and new screen | Does the host understand every result the screen can return? |
| Previous public build and current backend | Can installed users still complete the old path? |
| New build installed over the public build | Are saved data and sign-in state read correctly? |
| Old screen after someone used the new one | Can the old path read any state the new path wrote? |
Pick the combinations your release model allows instead of promising every possible pairing. Name a contract owner before a second screen adopts the same contract, include that person in reviews when it changes, and record whether the host or the backend has to ship first.
Step 5: Ship one screen and keep the way back open
Ship the first migrated screen through your existing release process. You want to learn whether your team can support it under real conditions before the next migration depends on it.
If a feature flag picks between implementations, keep its meaning narrow and test both paths. A flag that chooses which screen to show cannot repair a data or native-library mismatch.
Record which users or sessions saw the new screen, without collecting personal data you don't need, and compare outcomes against the old path under similar conditions. A small test group can have a different device mix from your full user base, so not every difference comes from the code.
Before you expand, review the ongoing work:
- Did the new screen cut duplicated changes across iOS and Android?
- When native integration failed, could the on-call engineer understand why?
- Can someone outside the original migration pair maintain it?
You may find the new approach fits one feature and not the rest of the app. Keeping that split is a valid result, and a migration is useful without becoming a full rewrite.
Set removal criteria for the old path up front. Keep it until the new screen meets the agreed behavior and nobody needs the fallback, then delete the obsolete code and its tests together and note the decision where the next team will find it.
Where Expo fits
Expo supports adding React Native screens to an existing native app, which Expo's brownfield overview defines as an app whose main entry point is not a React Native view. The overview describes an integrated approach and an isolated approach, and it lists the Expo SDK, Expo Router, EAS Build, EAS Submit and EAS Update as working in brownfield apps.
The isolated approach keeps your React Native code in its own project and packages it as an Android AAR and an iOS XCFramework (or a Swift package). Your native engineers add that output like any other dependency and never install Node.js or React Native build tooling. The expo-brownfield library provides the build commands:
On Android, the host app adds the published Maven dependency and opens the React Native screen from an activity that extends BrownfieldActivity. On iOS, the host calls ReactNativeHostManager.shared.initialize() at launch and presents a ReactNativeViewController or a SwiftUI ReactNativeView. The prebuilt library lowers what the native team has to install, and in exchange you take on a versioning and compatibility process for it.
The integrated approach keeps the React Native code inside the native project's development and build setup. It suits a single team that changes native and React Native code together.
Limitations
Expo's overview labels support for integrating Expo modules into existing native projects as alpha, and it warns that not every feature of Expo's tools is available in an existing native app. The Expo development client, for example, is listed as not supported there. Check the exact integration and libraries you plan to use.
Examples written for a new Expo app are background reading, not proof they work unchanged in your host. Packages that assume they own the app's root or navigation may need special handling.
Keep the host app's production release and signing process as it is until you have shown a replacement works. Adding one shared screen is enough work for the first increment.
Next step
Read the brownfield integration overview and write the host-to-screen contract for one first screen before you change the app's architecture.
Verified on 12 September 2026.
