Brownfield Basics
10 examples to decide when to embed React Native, rewrite in RN, or fall back to WebView - 7 basic and 3 intermediate.
Search across all documentation pages
10 examples to decide when to embed React Native, rewrite in RN, or fall back to WebView - 7 basic and 3 intermediate.
Brownfield work assumes you already ship a native iOS and/or Android app whose main entry point is not React Native. You add RN as a library, screen, or feature module.
For hands-on integration after these decisions, see expo-brownfield Overview. For architecture boundaries inside the RN slice, see Mobile Architecture Basics.
# Spike: minimal Expo project to validate embed feasibility
npx create-expo-app@latest RnSpike --template blank-typescript@sdk-57
cd RnSpike
npx expo install expo-brownfieldTooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3. Brownfield integration APIs are alpha - budget time for native build debugging.
Greenfield means React Native (or Expo) is the app's root - every screen branches from a JS entry. Brownfield means UIKit/Swift, Jetpack Compose/Kotlin, or another native stack owns the shell; RN is embedded on demand.
Greenfield Brownfield
┌─────────────────────┐ ┌─────────────────────┐
│ RN root (main) │ │ Native root │
│ ├─ Tab A │ │ ├─ Home (native) │
│ ├─ Tab B │ │ ├─ Settings (nat.) │
│ └─ Modal │ │ └─ Checkout (RN) │ ← RN island
└─────────────────────┘ └─────────────────────┘Related: expo-brownfield Overview - packaging RN as AAR/XCFramework | ../architecture-design/mobile-architecture-basics/mobile-architecture-basics.md - structuring the RN slice once embedded
Embed RN when the feature needs native-feeling UI, offline/cache behavior, device APIs, or shared TypeScript with a web team - and the legacy native screen would take quarters to rebuild.
| Signal | Embed RN |
|---|---|
| Complex forms with validation | ✓ |
| Lists with gestures / Reanimated | ✓ |
| Camera, biometrics, BLE via Expo modules | ✓ |
| Reuse existing React design system | ✓ |
| One static FAQ page | ✗ (WebView or native) |
// The RN slice can still use Expo Router inside the host-provided container
// app/_layout.tsx - RN navigation is internal to the embedded module
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="checkout" />
<Stack.Screen name="order-confirmation" />
</Stack>
);
}expo-brownfield isolated artifacts when native teams refuse Node in CImultipleFrameworks planning on iOSRelated: RNHostView & Native UI Embedding - RN inside SwiftUI/Compose layouts | ../native-modules/native-modules-basics/native-modules-basics.md - when JS alone is insufficient
A rewrite replaces the native shell with an Expo/RN app. Justified when most screens are changing anyway, dual navigation costs exceed migration, or you need one OTA pipeline for the entire product.
| Signal | Rewrite |
|---|---|
| >60% of roadmap touches UI shared with web | ✓ |
| Native codebase unmaintainable (no tests, no owners) | ✓ |
| Store compliance requires single binary identity | ✓ |
| One team owns one feature in an otherwise stable app | ✗ (embed) |
Rewrite decision checklist (all "yes" → strong rewrite case):
□ Native and mobile web share a component library
□ Auth, push, and deep links need unified routing (Expo Router)
□ Native headcount cannot sustain two UI stacks
□ Leadership accepts 2–4 sprint migration freeze on affected flowsRelated: Incremental Adoption ADR - ranked strangler fig decisions | ../architecture-design/adr-navigation-library-choice/adr-navigation-library-choice.md - Expo Router vs React Navigation during migration
WebView ships mobile web inside the native shell. Best for read-only, rarely updated, or legally hosted content where native UX and offline are non-goals.
import { WebView } from "react-native-webview";
export function HelpCenterWebView({ url }: { url: string }) {
return (
<WebView
source={{ uri: url }}
startInLoadingState
sharedCookiesEnabled
// Host app must inject auth cookie or token via injectedJavaScript
/>
);
}| Signal | WebView |
|---|---|
| Marketing / legal / help center | ✓ |
| Internal admin tools used monthly | ✓ |
| Checkout with PCI constraints | ✗ (native or RN + certified SDK) |
| Offline-first field app | ✗ |
Related: Shared Authentication & Bridges - passing sessions into WebView and RN
Score each candidate feature 1–5 on these axes; highest total suggests the column.
| Axis | Embed RN | Rewrite | WebView |
|---|---|---|---|
| UI complexity | High forms/lists | Entire app | Static HTML |
| Offline need | Required | Required | Optional |
| Release cadence | Weekly feature | Major version | Rare copy change |
| Native team capacity | Low | Willing to exit native UI | Any |
| Shared code with web | High | High | Already on web |
| Risk tolerance | Medium (island) | High (platform) | Low |
Example: "Order tracking" in a native retail app
UI complexity: 4 | Offline: 3 | Cadence: 5 | Shared TS: 4
→ Embed RN (strangler fig on tracking stack)
Example: "Terms of service"
UI: 1 | Offline: 0 | Cadence: 1
→ WebView or SFSafariViewControllerRelated: Incremental Adoption ADR - formal ranked decisions per scenario
Expo documents two brownfield shapes:
| Approach | RN location | Native CI needs Node? | Best for |
|---|---|---|---|
| Integrated | RN project wraps or neighbors native ios//android/ | Yes | Single team, frequent cross-boundary changes |
| Isolated | Separate repo/monorepo package → AAR + XCFramework | No (consumes artifacts) | Separate native and RN squads |
// app.config.ts - isolated path uses expo-brownfield config plugin
export default {
expo: {
plugins: [
[
"expo-brownfield",
{
ios: { targetName: "CheckoutBrownfield" },
android: {
group: "com.example",
libraryName: "checkout-brownfield",
version: "2.1.0",
},
},
],
],
},
};# RN squad publishes artifacts; native squad consumes Maven / Swift Package
npx expo-brownfield build:android --release
npx expo-brownfield build:ios --release --package CheckoutPackagenpx expo prebuild inside the host repoRelated: expo-brownfield Overview - full cookbook | ../native-modules/config-plugins/config-plugins.md -
expo-brownfieldplugin options
The host app initializes the RN runtime once, then presents a view controller or activity.
// iOS - call early in AppDelegate
import CheckoutBrownfield
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
ReactNativeHostManager.shared.initialize()
return true
}
}
// UIKit - push RN checkout
let vc = ReactNativeViewController(
moduleName: "main",
initialProps: ["cartId": cartId]
)
navigationController?.pushViewController(vc, animated: true)// Android - BrownfieldActivity + fragment
class CheckoutActivity : BrownfieldActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
showReactNativeFragment()
}
}moduleName must match registerRootComponent / app.json main registration in the RN projectinitialProps seed route params - pair with shared auth keys (next article)npx expo start); release builds use the embedded bundle inside the artifactRelated: Brownfield CI/CD - debug vs release artifact pipelines
Pick a flow that is painful in native, bounded, and not on the critical cold-start path.
Good first slices:
✓ Post-login dashboard (auth already native)
✓ Settings sub-flow rarely opened at launch
✓ New feature with no legacy code
Poor first slices:
✗ App launch splash / home tab (cold start + RN init cost)
✗ Deep native navigation hub (back-stack ownership unclear)
✗ Background tasks / widgets (RN not in process)// RN side - listen for native "open slice" messages
import * as Brownfield from "expo-brownfield";
import { useEffect } from "react";
import { router } from "expo-router";
export function useNativeDeepLinks() {
useEffect(() => {
const sub = Brownfield.addMessageListener((event) => {
if (event.type === "OPEN_TRACKING" && event.orderId) {
router.push(`/orders/${event.orderId}`);
}
});
return () => sub.remove();
}, []);
}Brownfield.popToNative() when RN finishes a flow and returns control to UIKit/ComposeRelated: Shared Authentication & Bridges -
BrownfieldMessagingcontract
When PM asks for "just use the mobile website," run this comparison on one screen.
| Criterion | WebView | Embedded RN |
|---|---|---|
| Scroll performance on low-end Android | Janky | Native list virtualization |
| Pull-to-refresh | Custom bridge | RefreshControl |
| Push deep link to row | URL fragility | Typed Expo Router path |
| App Store "minimum functionality" | Risk if thin shell | Stronger native presence |
| Engineering cost this quarter | Days | Weeks |
// Compromise: WebView for MVP spike, RN for v2 - gate with feature flag
import { useFeatureFlag } from "@/shared/feature-flags";
export function LoyaltyScreen() {
const useNative = useFeatureFlag("loyalty_rn_v2");
return useNative ? <LoyaltyNative /> : <LoyaltyWebView uri="https://m.example.com/loyalty" />;
}Related: ../architecture-design/modular-monolith-vs-multi-app/modular-monolith-vs-multi-app.md - feature flags vs separate apps
Define who owns each layer before the first PR lands.
Native squad owns:
- App launch, push registration, keychain/session vault
- Presenting ReactNativeViewController / BrownfieldActivity
- Store binaries, signing, native crash triage
RN squad owns:
- Metro project, Expo SDK pin, JS bundle quality
- Screens inside the embedded module, OTA policy (if enabled)
- expo-brownfield artifact version bumps
Shared contract (document in repo):
- Message types: OPEN_*, SESSION_*, LOGOUT
- Shared state keys: auth.accessToken, auth.userId
- Semantic versioning on AAR/Maven + iOS Swift Package// Version the bridge contract - breaking changes require major artifact bump
export const BRIDGE_CONTRACT_VERSION = "1.2.0";
export type HostToRnMessage =
| { type: "SESSION_UPDATED"; accessToken: string; userId: string }
| { type: "OPEN_CHECKOUT"; cartId: string };multipleFrameworks: true on iOS risk duplicate symbolsRelated: Brownfield Best Practices - 25-item summary | ../native-modules/autolinking-and-expo-modules-core/autolinking-and-expo-modules-core.md - autolinking in monorepos
No - Expo Go is a greenfield container. Use debug expo-brownfield artifacts with Metro, or an integrated dev client inside the host app.
Yes for the RN module when runtime versions align - but the host store binary must still ship when native deps change. See Brownfield CI/CD.
RN when you need offline, native navigation integration, or shared components with web. WebView when content is rarely updated and owned by a separate web team with no mobile capacity.
One is simplest. Multiple isolated frameworks on iOS require multipleFrameworks: true and careful symbol mangling - plan in an ADR before duplicating Expo projects.
expo-brownfield config pluginStack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026