A beginner-friendly walkthrough of how to embed React Native inside existing native iOS and Android apps, using this demo workspace as a hands-on reference.
Table of Contents
- What is "Brownfield"?
- The Big Picture
- Prerequisites
- Project Structure
Understanding Each Project
- RNApp -- Your React Native Code
- AndroidApp -- The Native Android App
- AppleApp -- The Native iOS App
Key Concepts Explained
- What is an AAR?
- What is an XCFramework?
- What is the Brownfield CLI?
- Debug vs Release
- The Facade Pattern
- Brownie -- Shared State Between Native and JS
- JavaScript API
- Step-by-Step: Android Integration
- Step-by-Step: iOS Integration
- Commands Cheat Sheet
- Common Problems and Fixes
- Monorepo Note
- Learn More
What is "Brownfield"?
Imagine your company already has a native Android app written in Kotlin and a native iOS app written in Swift. They work fine, but now you want to add some new screens using React Native -- maybe a settings page, a chat feature, or a new onboarding flow.
You don't want to rewrite the entire app in React Native. You just want to add React Native into your existing app. That's brownfield integration.
| Term | What it means |
|---|---|
| Greenfield | Building a brand new app from scratch in React Native |
| Brownfield | Adding React Native into an app that already exists |
The @callstack/react-native-brownfield library makes this possible by packaging your React Native code into a single file that your native app can use -- just like any other native library. The native app does not need Node.js, npm, or any JavaScript tooling.
Trusted in production by Zalando, HelloFresh, and AutoZone. See the official site for more.
Version Compatibility
From the Introduction:
| React Native Version | Brownfield Version |
|---|---|
| 0.81.x, 0.82.x | 2.x, 3.x |
| 0.78.x | ^1.2.0 |
The Big Picture
Here's what happens at a high level. You'll understand every piece of this by the end of this guide.
In plain English:
- You write your React Native app normally (screens, navigation, components, etc.)
- You run a CLI command that bundles everything -- your JS code, the Hermes engine, and all native dependencies -- into a single file
- Your native Android/iOS app adds that file as a dependency, initializes it, and shows the RN screens wherever it wants
The native app team doesn't need to know anything about React Native, npm, or JavaScript. They just call initialize() and render a view.
Prerequisites
Before you start, make sure you have these installed:
| Tool | Version | Why you need it |
|---|---|---|
| Node.js | >= 20 | Runs React Native tooling, Metro bundler, and the brownfield CLI |
| Android Studio | Latest | Builds the Android AAR and the native Android consumer app |
| Xcode | Latest | Builds the iOS XCFramework and the native iOS consumer app |
| CocoaPods | Latest | Manages iOS native dependencies (sudo gem install cocoapods) |
| JDK | 17 | Required by the Android Gradle build system |
| Watchman | Recommended | Makes the Metro bundler's file watching faster |
Project Structure
This workspace has 3 projects:
RN_Brownfield_Apps/
│
├── RNApp/ # YOUR REACT NATIVE CODE
│ │ # This is a normal RN app that also gets
│ │ # packaged into AAR (Android) and XCFramework (iOS)
│ │
│ ├── index.js # Entry point -- registers the root component
│ ├── app.json # App name configuration
│ ├── package.json # Dependencies: RN 0.82.1, React 19.1, etc.
│ ├── BrownfieldStore.brownie.ts # Shared state definition (generates native types)
│ ├── metro.config.js # Metro bundler configuration
│ ├── babel.config.js # Babel configuration
│ ├── tsconfig.json # TypeScript configuration
│ │
│ ├── src/
│ │ ├── App.tsx # Root component with NavigationContainer
│ │ ├── HomeScreen.tsx # Your home screen
│ │ ├── navigation/ # React Navigation stack setup
│ │ └── components/ # Reusable UI components
│ │
│ ├── android/
│ │ ├── app/ # Standard RN Android app (for standalone dev)
│ │ ├── BrownfieldLib/ # IMPORTANT: This module becomes the AAR
│ │ │ └── build.gradle.kts # Brownfield plugin + Maven publishing config
│ │ ├── build.gradle # Root Gradle (adds brownfield plugin)
│ │ └── settings.gradle # Includes both :app and :BrownfieldLib
│ │
│ ├── ios/
│ │ ├── RNApp.xcworkspace # Open this in Xcode (not .xcodeproj)
│ │ ├── RNApp/ # Standard RN iOS app (for standalone dev)
│ │ ├── BrownfieldLib/ # IMPORTANT: This target becomes the XCFramework
│ │ │ └── BrownfieldLib.swift # Public interface that native apps import
│ │ └── Podfile # CocoaPods -- BrownfieldLib inherits all deps
│ │
│ └── __tests__/
│ └── App.test.tsx
│
├── AndroidApp/ # NATIVE ANDROID APP (the "consumer")
│ │ # A normal Kotlin + Compose app that imports
│ │ # the AAR to show React Native screens
│ │
│ ├── build.gradle.kts # Root Gradle configuration
│ ├── settings.gradle.kts # Adds mavenLocal() so Gradle finds the AAR
│ ├── gradle/
│ │ └── libs.versions.toml # Version catalog -- AAR coordinates defined here
│ └── app/
│ ├── build.gradle.kts # App build config with AAR dependency
│ └── src/
│ └── main/ # MainActivity, Compose UI, theme
│
└── AppleApp/ # NATIVE iOS APP (the "consumer")
│ # A normal SwiftUI app that imports the
│ # XCFramework to show React Native screens
│
├── prepareXCFrameworks.js # Script that copies XCFrameworks into project
├── Brownfield-Apple-App-Info.plist
├── Brownfield Apple App.xcodeproj/
└── Brownfield Apple App/
├── BrownfieldAppleApp.swift # App entry point -- initializes React Native
└── components/
├── ContentView.swift # Main view -- embeds ReactNativeView here
├── GreetingCard.swift # Native SwiftUI greeting card
├── MaterialCard.swift # Native material-style card
├── MessagesView.swift # Demo: sending messages between native and RN
└── Toast.swift # Toast notification component
Understanding Each Project
RNApp -- Your React Native Code
Ref: Quick Start
| What | Value |
|---|---|
| React Native | 0.82.1 |
| React | 19.1.1 |
| Navigation | React Navigation 7 (native-stack) |
| Brownfield lib | @callstack/react-native-brownfield |
| State sharing | @callstack/brownie |
This is a completely normal React Native app. You can run it standalone with npx react-native run-android or npx react-native run-ios during development. The only extra thing it has is a BrownfieldLib module (Android) and target (iOS) that the CLI uses to package everything into a distributable artifact.
How it starts: index.js calls AppRegistry.registerComponent('RNApp', () => App). This is exactly how every React Native app works. The name 'RNApp' is what the native side uses to find and display your component.
What makes it "brownfield-ready":
Android: Theandroid/BrownfieldLib/folder is an Android Library module. The brownfield CLI packages it into an AAR file that contains your JS bundle, the Hermes engine, and all native dependencies baked in.
iOS: Theios/BrownfieldLib/folder is an Xcode Framework target. The brownfield CLI packages it into an XCFramework with everything included.
AndroidApp -- The Native Android App
Ref: Android Integration Guide, Kotlin API
| What | Value |
|---|---|
| Language | Kotlin |
| UI Framework | Jetpack Compose + Material 3 |
| Min SDK | 24 |
| Compile SDK | 36 |
This is a regular native Android app. It has zero React Native code in its source. It gets the React Native screens by importing the AAR file (published to the local Maven repository) as a Gradle dependency.
How it uses React Native (just 2 steps):
Initialize -- InMainActivity.kt, one line boots up React Native:
ReactNativeHostManager.initialize(application) {
// Called when the JS bundle finishes loading
}
Render -- Wherever you want a React Native screen, drop in a fragment:
AndroidFragment<ReactNativeFragment>(
arguments = Bundle().apply {
putString(ReactNativeFragmentArgNames.ARG_MODULE_NAME, "RNApp")
}
)
That's it. The native Android developer doesn't need to know anything about React Native internals.
AppleApp -- The Native iOS App
Ref: iOS Integration Guide, Swift API
| What | Value |
|---|---|
| Language | Swift |
| UI Framework | SwiftUI |
This is a regular native iOS app. It gets the React Native screens by embedding 3 XCFramework files into the Xcode project.
How it uses React Native (just 2 steps):
Initialize -- In the app'sinit(), two lines boot up React Native:
ReactNativeBrownfield.shared.bundle = ReactNativeBundle
ReactNativeBrownfield.shared.startReactNative {
print("React Native has been loaded")
}
Important: You must set
.bundlebefore calling.startReactNative. Otherwise you get a "No script URL provided" error.
Render -- Wherever you want a React Native screen, use the SwiftUI view:
ReactNativeView(moduleName: "main")
That's it. The native iOS developer doesn't need to know anything about React Native internals.
Key Concepts Explained
What is an AAR?
AAR stands for Android Archive. Think of it like a ZIP file that contains:
- Compiled Android code (
.classfiles) - Resources (images, layouts, etc.)
- Native libraries (
.sofiles -- compiled C/C++ code) - A manifest file
It's the standard way to distribute Android libraries. When you add a library in build.gradle, Gradle downloads an AAR from a repository (like Maven Central or mavenLocal) and includes it in your app.
In our case, the AAR contains your entire React Native app -- JS bundle, Hermes runtime, and all native modules.
Ref: Android Integration - Step 7
What is an XCFramework?
XCFramework is Apple's format for distributing pre-compiled libraries. It can contain binaries for multiple platforms and CPU architectures (e.g., real iPhone arm64 + simulator arm64 + simulator x86_64) in a single bundle.
It replaced the older "fat framework" approach and is the modern way to share iOS libraries. You add it to your Xcode project by dragging it in.
In our case, you need to add 3 XCFrameworks to your iOS app:
BrownfieldLib.xcframework-- your React Native app code
hermesvm.xcframework-- the Hermes JavaScript engine
ReactBrownfield.xcframework-- the brownfield library itself
Ref: iOS Integration - Step 5, iOS Integration - Step 6
What is the Brownfield CLI?
The CLI is a command-line tool that comes with @callstack/react-native-brownfield. It automates everything you would otherwise have to do manually:
- Bundle your JavaScript with the right settings
- Compile native code for all CPU architectures (arm64, x86_64, etc.)
- Compile Hermes bytecode for production
- Embed all native dependencies into a single file
- Handle debug and release variants
Without the CLI, you'd need to write and maintain complex build scripts yourself. With it, you just run:
# For Android: creates the AAR file
npx brownfield package:android --module-name :BrownfieldLib --variant release
# For Android: publishes the AAR to your local Maven repo (~/.m2/repository)
npx brownfield publish:android --module-name :BrownfieldLib
# For iOS: creates the XCFramework files
npx brownfield package:ios --scheme BrownfieldLib --configuration Release
Debug vs Release
| Mode | Where JS code comes from | What Hermes does | When to use |
|---|---|---|---|
| Debug | A local Metro dev server (npx react-native start) | Interprets JS on the fly | During development -- gives you hot reload and fast iteration |
| Release | Pre-bundled inside the AAR/XCFramework | Compiles JS to optimized bytecode | For production -- no dev server needed, runs faster |
The Facade Pattern
Ref: Guidelines
The official docs strongly recommend a pattern called the facade pattern. The idea is:
Your native app should never import React Native APIs directly. Instead, your brownfield artifact should expose a simple wrapper class (like
ReactNativeHostManager) that hides all the RN internals.
Why? Because:
- If something breaks, the stack trace points to your artifact, making debugging easier
- The native team doesn't need to learn React Native APIs
- If RN APIs change in a future version, you only update the wrapper -- the native app code stays the same
This project follows this pattern. Look at how clean the native app code is -- just ReactNativeHostManager.initialize() and ReactNativeFragment.
Brownie -- Shared State Between Native and JS
Ref: Brownie Overview, Codegen CLI
Sometimes your native app and React Native code need to share data -- for example, a counter value or a username. The @callstack/brownie library handles this with type-safe state synchronization.
Here's how it works:
- You define your shared state in a
*.brownie.tsfile (TypeScript) - You run
brownfield codegento generate matching Swift types - Both sides (JS and native) can read and write to this shared state, and changes sync automatically
In this project, BrownfieldStore.brownie.ts defines the shared state, and the codegen command generates the Swift types.
Note: Brownie currently supports iOS (Swift) only. Android support is coming soon.
JavaScript API
From your React Native code, the library gives you these methods:
| Method | What it does |
|---|---|
ReactNativeBrownfield.popToNative(animated) | Go back to the native screen that opened the RN screen |
ReactNativeBrownfield.setNativeBackGestureAndButtonEnabled(enabled) | Enable or disable the iOS swipe-back gesture and Android hardware back button |
ReactNativeBrownfield.postMessage(data) | Send data from RN to the native app |
ReactNativeBrownfield.onMessage(callback) | Listen for data sent from the native app to RN |
Example of two-way communication:
import ReactNativeBrownfield from '@callstack/react-native-brownfield';
// Send a message to native
ReactNativeBrownfield.postMessage({ action: 'greet', name: 'Alice' });
// Listen for messages from native
const subscription = ReactNativeBrownfield.onMessage((event) => {
console.log('Native sent:', event.data);
});
// Clean up when done
subscription.remove();
Step-by-Step: Android Integration
Full reference: Android Integration Guide
Here's every step mapped to the actual files in this project:
Step 1: Create a library module
Ref: Create an Android Library Module
In Android Studio, go to File > New Module > Android Library. In this project, it's called BrownfieldLib and lives at RNApp/android/BrownfieldLib/.
It's registered in RNApp/android/settings.gradle:
include ':BrownfieldLib'
Step 2: Add the Gradle plugins
Ref: Set Up the AAR Gradle Plugin
First, add the plugin to the root RNApp/android/build.gradle:
classpath("com.callstack.react:brownfield-gradle-plugin:1.0.1-SNAPSHOT")
Then apply it in RNApp/android/BrownfieldLib/build.gradle.kts:
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.callstack.react.brownfield") // The brownfield plugin
`maven-publish` // For publishing the AAR
id("com.facebook.react") // For autolinking
}
Step 3: Add React Native dependencies
Ref: Add React Native Dependencies
react {
autolinkLibrariesWithApp()
}
dependencies {
api("com.facebook.react:react-android:0.82.1")
api("com.facebook.react:hermes-android:0.82.1")
}
Important: The version numbers here must match the React Native version in your package.json. For RN >= 0.83, the Hermes package changes to com.facebook.hermes:hermes-android.
Step 4: Create the ReactNativeHostManager
Ref: Create React Native Host Manager
This is the "facade" class that hides all RN internals from the native consumer:
object ReactNativeHostManager {
fun initialize(application: Application, onJSBundleLoaded: OnJSBundleLoaded? = null) {
loadReactNative(application) // Required for RN >= 0.80.0
val packageList = PackageList(application).packages
ReactNativeBrownfield.initialize(application, packageList, onJSBundleLoaded)
}
}
Step 5: Configure Maven publishing
Ref: Configure Maven Publishing
The BrownfieldLib module publishes to mavenLocal() with coordinates com.rnapp:brownfieldlib:0.0.1-SNAPSHOT. A custom Gradle task strips internal RN dependencies from the POM file so the consumer doesn't try to download them separately (they're already baked into the AAR).
Step 6: Package and publish the AAR
Ref: Create the AAR, CLI docs
cd RNApp
npm run brownfield:package:android # Builds the AAR
npm run brownfield:publish:android # Publishes to ~/.m2/repository
Step 7: Add the AAR to your native app
Ref: Add the AAR to Your Android App
In AndroidApp/settings.gradle.kts, add mavenLocal() so Gradle can find the AAR:
dependencyResolutionManagement {
repositories {
mavenLocal()
}
}
In AndroidApp/gradle/libs.versions.toml, define the dependency coordinates:
[versions]
brownfieldlib = "0.0.1-SNAPSHOT"
[libraries]
brownfieldlib = { module = "com.rnapp:brownfieldlib", version.ref = "brownfieldlib" }
In AndroidApp/app/build.gradle.kts, add the dependency:
dependencies {
implementation(libs.brownfieldlib)
}
Step 8: Initialize and show the RN screen
Ref: Initialize React Native, Show the React Native UI
In MainActivity.kt, initialize React Native:
ReactNativeHostManager.initialize(application) {
Toast.makeText(this, "React Native has been loaded", Toast.LENGTH_LONG).show()
}
Then render it anywhere in your Compose UI:
AndroidFragment<ReactNativeFragment>(
arguments = Bundle().apply {
putString(ReactNativeFragmentArgNames.ARG_MODULE_NAME, "main")
}
)
Step-by-Step: iOS Integration
Full reference: iOS Integration Guide
Here's every step mapped to the actual files in this project:
Step 1: Create a Framework target in Xcode
Ref: Create a Framework Target
Open RNApp/ios/RNApp.xcworkspace in Xcode, then go to File > New > Target and choose Framework. In this project, it's called BrownfieldLib.
After creating it, set these build settings:
| Setting | Value | Why |
|---|---|---|
| Build Libraries for Distribution | YES | Creates a Swift module interface |
| User Script Sandboxing | NO | Allows the JS bundle script to write files |
| Skip Install | NO | Ensures Xcode produces the framework files |
| Enable Module Verifier | NO | Faster builds (skips verification) |
Step 2: Update CocoaPods
Ref: Update CocoaPods
In RNApp/ios/Podfile, nest the framework target inside the app target so it inherits all dependencies:
target 'RNApp' do
config = use_native_modules!
use_react_native!(:path => config[:reactNativePath], ...)
target 'BrownfieldLib' do
inherit! :complete # Gets ALL pods from the parent target
end
end
Run pod install after making changes.
Step 3: Configure static linking
Ref: Static Linking Requirement
React Native Brownfield requires static linking to work. The Podfile handles this:
linkage = ENV['USE_FRAMEWORKS']
if linkage != nil
use_frameworks! :linkage => linkage.to_sym
end
The CLI sets USE_FRAMEWORKS=static automatically when you run package:ios, so you don't need to worry about this during packaging.
Step 4: Add the bundle script
In Xcode, copy the "Bundle React Native code and images" build phase from the app target to your framework target. Add these input files:
$(SRCROOT)/.xcode.env.local$(SRCROOT)/.xcode.env
This ensures the JS bundle gets compiled and included in the framework.
Step 5: Create the framework's public interface
Ref: Create the Framework's Public Interface
RNApp/ios/BrownfieldLib/BrownfieldLib.swift is a small but critical file:
@_exported import ReactBrownfield
@_exported import Brownie
public let ReactNativeBundle = Bundle(for: InternalClassForBundle.self)
class InternalClassForBundle {}
What this does:
@_exported import ReactBrownfield-- re-exports the brownfield library so the consumer can use its classes
ReactNativeBundle-- points to the framework's own bundle, which is where the JS code lives. The consumer app uses this to tell the brownfield library where to find the JavaScript.
Step 6: Package into XCFramework
Ref: Create the XCFramework, CLI docs
cd RNApp
npm run brownfield:package:ios
This creates 3 files in .brownfield/ios/package/:
BrownfieldLib.xcframework-- your React Native app
hermesvm.xcframework-- the Hermes JavaScript engine
ReactBrownfield.xcframework-- the brownfield library
Step 7: Add all 3 frameworks to your native iOS app
Ref: Add the Framework to Your iOS App
Drag all 3 .xcframework files into your Xcode project. In this project, the AppleApp/prepareXCFrameworks.js script automates this step.
Step 8: Initialize and show the RN screen
Ref: SwiftUI Integration, Swift API
In BrownfieldAppleApp.swift, initialize React Native when the app starts:
@main
struct BrownfieldAppleApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
init() {
ReactNativeBrownfield.shared.bundle = ReactNativeBundle // MUST come first
ReactNativeBrownfield.shared.startReactNative {
print("React Native has been loaded")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Then in ContentView.swift, show the React Native screen:
ReactNativeView(moduleName: "main")
.clipShape(RoundedRectangle(cornerRadius: 16))
.background(Color(UIColor.systemBackground))
Commands Cheat Sheet
RNApp (your React Native project)
| Command | What it does |
|---|---|
npm run start | Start the Metro dev server (needed for Debug mode) |
npm run android | Run the RN app standalone on Android (for development) |
npm run ios | Run the RN app standalone on iOS (for development) |
npm run brownfield:package:android | Package your RN code into an AAR file |
npm run brownfield:publish:android | Publish the AAR to local Maven (~/.m2/repository) |
npm run brownfield:package:ios | Package your RN code into XCFramework files |
npm run codegen | Generate Swift types from *.brownie.ts state definitions |
AndroidApp (native consumer)
| Command | What it does |
|---|---|
npm run build | Build the native app with the RNApp AAR |
AppleApp (native consumer)
| Command | What it does |
|---|---|
npm run build | Build the native app with the RNApp XCFramework |
Common Problems and Fixes
Full list: Troubleshooting Guide
"Error: duplicate resources" during Android build
When: Building the AAR after upgrading from RN < 0.82 to >= 0.82.
Why: RN 0.82 changed the path where the JS bundle is written, causing a conflict with leftover files from the old path.
Fix: Delete the app/build/ directory and rebuild. This only needs to be done once.
"No script URL provided" on iOS Release builds
When: Running the iOS app in Release configuration.
Why: You forgot to set the bundle before starting React Native.
Fix: Make sure this line comes before startReactNative:
ReactNativeBrownfield.shared.bundle = ReactNativeBundle // <-- this MUST be first
ReactNativeBrownfield.shared.startReactNative { ... }
Monorepo Note
This workspace comes from the react-native-brownfield monorepo by Callstack. Some config files reference parent directories:
RNApp/metro.config.js-- root set to../..
RNApp/babel.config.js-- alias to../../packages/react-native-brownfield/src
RNApp/tsconfig.json-- extends../../tsconfig
package.jsonusesworkspace:^for brownfield dependencies
If you're using this standalone (not inside the monorepo), you'll need to:
- Replace
workspace:^with actual npm versions inpackage.json
- Update the
../../packages/...paths to point tonode_modules/...
Learn More
Getting Started
- React Native Brownfield -- Home
- Introduction
- Quick Start Guide
- iOS Integration Guide
- Android Integration Guide
- Examples
CLI Reference
Brownfield CLI --package:android,package:ios,publish:android
Brownie Codegen CLI --codegenfor generating native types
API Reference
JavaScript Module --popToNative,postMessage,onMessage
Swift --ReactNativeBrownfield,ReactNativeView,ReactNativeViewController
Objective-C -- same APIs for Obj-C
Kotlin --ReactNativeBrownfield,ReactNativeFragment,createView
Java -- same APIs for Java
Brownie (Shared State)
Brownie Overview -- what it is and how it works
Defining Stores --*.brownie.tsfile format
TypeScript Usage --useStorehook
Swift Usage --@UseStoreproperty wrapper
Best Practices
Guidelines -- facade pattern, team separation
Troubleshooting -- common issues and fixes
