+1 (415) 943-4271

Brownfield React Native: Embedding RN Into an Existing iOS and Android App

Every React Native tutorial assumes you're starting npx create-expo-app. Most of the React Native work we're actually asked to do looks nothing like that: there is a ten-year-old Swift app and a Kotlin app in production, a native team that owns them, and a product group that wants two or three new flows shipped on both platforms this quarter without waiting for two native backlogs. That's brownfield React Native — RN as a feature inside a native host, not as the app.

It works well. It also fails in a very specific set of ways, most of which are architectural rather than technical. Here's the shape we recommend.

1. Decide what RN owns before you write any code

Draw the boundary at the flow, not at the screen. A single RN screen sandwiched between two native screens means you pay the full integration cost — runtime, build pipeline, release process, two-way navigation — for almost no leverage, and every navigation transition crosses the boundary.

Good candidates:

  • Self-contained flows with their own stack: onboarding, checkout, settings, a rewards section, an in-app content hub.
  • Screens that change weekly and are driven by remote config or CMS content.
  • Anything that exists on both platforms and is expected to look identical.

Bad candidates: the root tab bar, camera/AR-heavy surfaces, anything with a hard cold-start budget on the first frame, and screens that need dozens of existing native view components you'd have to wrap.

2. The host entry point on the New Architecture

Post-0.76 the integration API is much nicer than the old RCTRootView / ReactActivityDelegate juggling, because Fabric and TurboModules are the default and the factory classes wrap the setup.

iOS — create one factory for the whole app process and hold it on your app delegate or a dedicated singleton:

import React_RCTAppDelegate

final class RNHost {
  static let shared = RNHost()
  private let factory: RCTReactNativeFactory

  private init() {
    let delegate = ReactNativeDelegate()
    factory = RCTReactNativeFactory(delegate: delegate)
  }

  func viewController(moduleName: String,
                      props: [AnyHashable: Any] = [:]) -> UIViewController {
    let vc = UIViewController()
    vc.view = factory.rootViewFactory.view(withModuleName: moduleName,
                                           initialProperties: props)
    return vc
  }
}

// Anywhere in your existing UIKit code:
navigationController?.pushViewController(
  RNHost.shared.viewController(moduleName: "CheckoutFlow",
                               props: ["orderId": orderId]),
  animated: true
)

Android — one ReactHost for the process, then mount ReactDelegate (or a ReactFragment) wherever you need it:

class RNHost private constructor(app: Application) {
  val reactHost: ReactHost = getDefaultReactHost(app, packageList)

  companion object {
    lateinit var instance: RNHost
    fun init(app: Application) { instance = RNHost(app) }
  }
}

class CheckoutActivity : ReactActivity() {
  override fun getMainComponentName() = "CheckoutFlow"
  override fun getLaunchOptions() = Bundle().apply {
    putString("orderId", intent.getStringExtra("orderId"))
  }
}

Two rules that matter more than the code:

  1. One runtime per process. Every extra host means another JS bundle parse, another set of module instances, and double the memory. Mount many surfaces from a single host.
  2. Register components once, at app start, and keep the registry list in one file both platforms agree on. Mismatched module names are the single most common "white screen" in brownfield apps.

3. Pay the startup cost on your terms

The first RN surface in a session is the expensive one: bundle load plus runtime init. You have three levers.

  • Warm the host early but not on the critical path. Initialise after first paint of your native home screen — an idle callback or a low-priority background task — so by the time a user taps into the RN flow the runtime is up.
  • Ship a precompiled/embedded bundle with the binary rather than loading from a remote URL on first launch. Remote-first loading is the reason "our RN screens feel slow" in half the audits we do.
  • Budget it. Measure time-to-first-frame for the RN surface separately from native screens and treat a regression as a release blocker. In observability terms it's a distinct span, not part of app cold start.

4. The data and navigation contract

The boundary needs an explicit, versioned contract, or every team ends up reading each other's internals.

Native → JS: initial props. Pass identifiers and scalars, not objects that duplicate native state. orderId, userId, locale, theme, feature flags. Initial props are a snapshot; they do not update on their own.

Native → JS: live updates. For auth token refresh, connectivity, theme changes, use a TurboModule with an event emitter — one module, a small set of named events. Don't push whole models across.

JS → Native: navigation and completion. Give the flow exactly one way out:

import { NativeModules } from "react-native";
const { HostBridge } = NativeModules;

// leaf of the RN flow
HostBridge.flowDidComplete({ status: "purchased", orderId });

The native side decides what "completion" means — dismiss the modal, pop to root, push a native receipt screen. Never let JS drive the native stack directly; that coupling is unpickable later.

Auth is native's job. One token source of truth, held natively in Keychain/Keystore, exposed to JS through the bridge module. RN flows should never own a second login or a second refresh timer.

5. Build and release, where brownfield actually hurts

  • Dependencies: CocoaPods/SPM and Gradle now inherit RN's autolinking. Expect version conflicts on anything shared with the native app (OkHttp, Glide, SQLite, analytics SDKs). Pin deliberately and write down who owns each pin.
  • Build times: native CI jobs get noticeably longer. Precompiled React Native artifacts (0.81+) and aggressive Gradle/Xcode caching are worth setting up on day one, not after the native team complains.
  • Packaging the JS: publish your RN feature as a versioned artifact — an XCFramework/AAR or an internal package — so the native app upgrades on its own schedule instead of tracking your branch.
  • OTA updates need a policy. You can push JS-only updates to RN surfaces inside a native shell, and it's a genuine advantage. But the native binary and the bundle must agree on native module versions. Gate updates on a runtime-version key tied to the binary, stage rollouts, and keep rollback one click away.
  • Crash reporting: upload Hermes source maps per release and make sure your native crash reporter attributes JS errors to the RN surface rather than swallowing them into "unknown".

6. A migration path that doesn't strand you

Brownfield is often described as a stepping stone to a full rewrite. Sometimes it is; more often the steady state is the goal, and that's fine — a native shell with a few RN flows is a legitimate long-term architecture.

If you do intend to go further, the order that works is: one contained flow → shared design-system components in RN → a second and third flow → RN owns the tab content while native owns the shell → native shell becomes thin. Each step ships, and each step is reversible. Nothing about that sequence requires a big-bang cutover, which is exactly why it survives a change of leadership.

Checklist before your first brownfield PR merges

  • One RN host per process, initialised off the critical path
  • Component names registered in a single shared file
  • Initial props are scalars only; live data goes through one event-emitting module
  • One documented "flow complete" callback into native
  • Tokens owned natively, read by JS
  • Embedded bundle by default; remote/OTA gated on a binary-tied runtime version
  • Separate time-to-first-frame metric for RN surfaces
  • Source maps uploaded per release
  • A named owner on both the native and RN side of the boundary

Brownfield integration is where most of the interesting React Native work is in 2026, and it's mostly a contract-design problem. If you're weighing adding React Native flows to an app your native team already ships, get in touch — a short architecture review up front is far cheaper than unpicking a boundary later.