agents/openai.yaml
interface:
display_name: "Expo Brownfield"
short_description: "Integrate Expo and React Native into existing native iOS or Android apps"
default_prompt: "Use $expo-brownfield when adding Expo or React Native to an existing native app, choosing isolated vs integrated brownfield architecture, embedding AAR/XCFramework outputs, wiring Gradle or CocoaPods, and troubleshooting native integration issues."
references/brownfield-integrated.md
# Brownfield: Integrated Approach
Add React Native and Expo directly to the existing native project's build system — Gradle on Android, CocoaPods on iOS — the same way you would add any other library. The native project gains React Native capabilities while keeping a single, unified build.
## When to use
- A single team owns both the native and React Native code.
- The team is comfortable adding React Native and Expo to the native build (Gradle plugin, CocoaPods pods).
- You want hot reload, JS source maps, and a single Metro instance to "just work" inside the existing build.
- You prefer one repository and one build pipeline over shipping a prebuilt artifact.
If the native team must not need Node, Yarn, or React Native tooling, use [./brownfield-isolated.md](./brownfield-isolated.md) instead.
## Prerequisites
- **Expo SDK 54 or later** — the `ExpoReactHostFactory`, `ExpoReactNativeFactory`, and `ApplicationLifecycleDispatcher` entry points used below require SDK 54+. Earlier SDKs do not support this setup.
- **Node.js (LTS)** — runs JavaScript and the Expo CLI.
- **Yarn** — manages JavaScript dependencies.
- **CocoaPods** (iOS) — `sudo gem install cocoapods`.
---
## 1) Create an Expo project
Create the Expo project inside (or alongside) the existing native project. **Pin to SDK 55 or later — earlier SDKs do not support brownfield integration:**
```sh
npx create-expo-app@latest my-project --template default@sdk-55
```
The new project ships a TypeScript example app. The JS entry point registers a root component under the name `"main"` — this name must match the `moduleName` referenced from the native side later.
## 2) Place native projects under the Expo project
A standard React Native project keeps native code under `android/` and `ios/`. Move the existing native projects in:
```sh
mkdir my-project/android
mv /path/to/your/android-project my-project/android/
# repeat for ios/
```
### Monorepo alternative
If the native projects cannot be moved, set up a monorepo with the Expo project as a workspace. Create a root `package.json`:
```json
{
"version": "1.0.0",
"private": true,
"workspaces": ["my-project"]
}
```
Run `yarn install` at the root. This installs `node_modules` at the workspace root so Gradle and CocoaPods scripts can resolve React Native and Expo dependencies.
> **Monorepo callout:** with a monorepo, the Expo project is not at `../../` from the native projects. You must set `projectRoot` explicitly in Gradle and pass the project root to CocoaPods so autolinking can find the Expo project.
---
## 3) Configure Android
### `settings.gradle`
Register the React Native Gradle plugin and Expo autolinking. Reference: [bare-minimum template `settings.gradle`](https://github.com/expo/expo/blob/main/templates/expo-template-bare-minimum/android/settings.gradle).
```groovy
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
expoAutolinking.useExpoModules()
expoAutolinking.useExpoVersionCatalog()
includeBuild(expoAutolinking.reactNativeGradlePlugin)
```
> **Monorepo:** add an explicit project root before `expoAutolinking.useExpoModules()` so autolinking finds your Expo project's `node_modules`.
### Top-level `build.gradle`
Add the React Native Gradle plugin classpath and the Expo root-project plugin:
```groovy
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
```
### `app/build.gradle`
Apply the React Native plugin and configure the `react { ... }` block. The full template is at [bare-minimum `app/build.gradle`](https://github.com/expo/expo/blob/main/templates/expo-template-bare-minimum/android/app/build.gradle); the minimum that must change in your existing module:
```groovy
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
autolinkLibrariesWithApp()
}
```
> **Monorepo:** set `root = file("../../")` (or wherever your Expo project lives) inside the `react { ... }` block.
### `gradle.properties`
```properties
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
newArchEnabled=true
hermesEnabled=true
```
`newArchEnabled` and `hermesEnabled` must match across all sub-modules in your build.
### `AndroidManifest.xml`
Add the `INTERNET` permission to your main manifest at `app/src/main/AndroidManifest.xml`:
```xml
<uses-permission android:name="android.permission.INTERNET" />
```
In the debug-variant manifest at `app/src/debug/AndroidManifest.xml`, enable cleartext traffic so the app can talk to the local Metro bundler over HTTP:
```xml
<application
android:usesCleartextTraffic="true"
tools:targetApi="28"
tools:ignore="GoogleAppIndexingWarning">
...
</application>
```
### `MainApplication.kt`
Initialize React Native and Expo lifecycle dispatch in your `Application` class:
```kotlin
package com.example.myapp
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ExpoReactHostFactory
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
ExpoReactHostFactory.getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages
)
}
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (_: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}
```
### `ReactActivity`
Create an `Activity` that hosts a React Native screen. The `moduleName` returned by `getMainComponentName()` must match the name registered via `AppRegistry.registerComponent(...)` in your JS entry point (`"main"` for the default template).
```kotlin
package com.example.myapp
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MyReactActivity : ReactActivity() {
override fun getMainComponentName(): String = "main"
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) {}
)
}
}
```
Register the activity in `AndroidManifest.xml` with a non-ActionBar theme:
```xml
<activity
android:name=".MyReactActivity"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
/>
```
Launch it from existing native code:
```kotlin
startActivity(Intent(this, MyReactActivity::class.java))
```
---
## 4) Configure iOS
The integrated approach drives iOS through CocoaPods + Expo modules autolinking, exactly like a fresh Expo project. The key difference is that you are integrating into your existing Xcode project rather than starting from the template.
### `ios/Podfile`
Create (or update) `ios/Podfile` based on the [bare-minimum Podfile](https://github.com/expo/expo/blob/main/templates/expo-template-bare-minimum/ios/Podfile). The essential lines:
```ruby
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
platform :ios, podfile_properties['ios.deploymentTarget'] || '16.4'
prepare_react_native_project!
target 'MyApp' do
use_expo_modules!
config_command = [
'node',
'--no-warnings',
'--eval',
'require(\'expo/bin/autolinking\')',
'expo-modules-autolinking',
'react-native-config',
'--json',
'--platform',
'ios'
]
config = use_native_modules!(config_command)
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
:app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
)
post_install do |installer|
react_native_post_install(installer, config[:reactNativePath], :mac_catalyst_enabled => false)
end
end
```
Replace `'MyApp'` with the existing Xcode target name. The `:app_path` value tells `use_react_native!` where the JS app lives — set it to the absolute path of your Expo project root if you are in a monorepo.
Create `ios/Podfile.properties.json` alongside the Podfile (defaults are fine):
```json
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true"
}
```
Install pods:
```sh
cd ios && pod install
```
Open the generated `.xcworkspace` (not the `.xcodeproj`) from now on.
### Xcode project changes
Three Xcode-side adjustments are required before the app can build and run a React Native screen. Skip any one and either CocoaPods scripts fail under sandboxing, the JS bundle never lands in the IPA (release crashes looking for `main.jsbundle`), or the status bar fights React Native at runtime.
#### 1. Disable user script sandboxing
In Xcode, select your project → app target → **Build Settings**, search for `ENABLE_USER_SCRIPT_SANDBOXING`, and set it to **No**. CocoaPods' Hermes scripts need to switch between debug and release engine binaries at build time, which sandboxing blocks.
#### 2. Add a Run Script phase to embed the JS bundle
On the app target's **Build Phases** tab, add a new **Run Script** phase **before** `[CP] Embed Pods Frameworks`. This phase bundles JS for release builds and is skipped automatically in debug (Metro serves the bundle then).
```sh
if [[ -f "$PODS_ROOT/../.xcode.env" ]]; then
source "$PODS_ROOT/../.xcode.env"
fi
if [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then
source "$PODS_ROOT/../.xcode.env.local"
fi
export PROJECT_ROOT="$PROJECT_DIR"/..
if [[ "$CONFIGURATION" = *Debug* ]]; then
export SKIP_BUNDLING=1
fi
if [[ -z "$ENTRY_FILE" ]]; then
export ENTRY_FILE="$("$NODE_BINARY" -e "require('expo/scripts/resolveAppEntry')" "$PROJECT_ROOT" ios absolute | tail -n 1)"
fi
if [[ -z "$CLI_PATH" ]]; then
export CLI_PATH="$("$NODE_BINARY" --print "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })")"
fi
if [[ -z "$BUNDLE_COMMAND" ]]; then
export BUNDLE_COMMAND="export:embed"
fi
if [[ -f "$PODS_ROOT/../.xcode.env.updates" ]]; then
source "$PODS_ROOT/../.xcode.env.updates"
fi
if [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then
source "$PODS_ROOT/../.xcode.env.local"
fi
`"$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'"`
```
> **Monorepo:** override `PROJECT_ROOT` to point at the Expo project (e.g. `export PROJECT_ROOT="$PROJECT_DIR"/../../my-project`). Without this, bundling looks for `node_modules` in the wrong directory.
This script writes `main.jsbundle` into the app's resources directory in release configurations. Without it, the `bundleURL()` fallback in `ReactNativeDelegate` resolves to `nil` and the React Native screen fails to load whenever Metro is not running.
#### 3. Update `Info.plist`
Set `UIViewControllerBasedStatusBarAppearance` to `NO` so React Native can manage the status bar:
```xml
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
```
### `AppDelegate.swift`
Wire React Native into the app delegate via Expo's `ExpoReactNativeFactory`. The delegate's `bundleURL()` selects the Metro dev server in `DEBUG` and the embedded bundle in release.
```swift
internal import Expo
import React
import ReactAppDependencyProvider
@main
class AppDelegate: ExpoAppDelegate {
var window: UIWindow?
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
var reactNativeFactory: RCTReactNativeFactory?
public override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "main",
in: window,
launchOptions: launchOptions
)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
override func sourceURL(for bridge: RCTBridge) -> URL? {
bridge.bundleURL ?? bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
#else
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}
```
The module name `"main"` must match what the JS side registers with `AppRegistry.registerComponent("main", () => App)`.
### Embedding RN inside an existing screen (not the root window)
If you do not want React Native to take over the whole window, instantiate the factory the same way but mount the produced root view inside an existing `UIViewController`:
```swift
import UIKit
import React
import Expo
class ReactNativeScreenViewController: UIViewController {
private var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
private var reactNativeFactory: RCTReactNativeFactory?
override func viewDidLoad() {
super.viewDidLoad()
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
self.reactNativeDelegate = delegate
self.reactNativeFactory = factory
let rootView = factory.rootViewFactory.view(
withModuleName: "main",
initialProperties: nil,
launchOptions: nil
)
rootView.frame = view.bounds
rootView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(rootView)
}
}
```
Present it like any other view controller:
```swift
navigationController?.pushViewController(ReactNativeScreenViewController(), animated: true)
```
> **Monorepo iOS:** `pod install` is run from `ios/`, but Node module resolution starts from the Expo project root. Pass `EXPO_PROJECT_ROOT=/absolute/path/to/expo-project` to the `pod install` invocation if autolinking cannot find the Expo project automatically.
---
## 5) Test the integration
Start Metro from the Expo project (or `yarn start` from the monorepo root):
```sh
yarn start
```
Build and run the native app normally (Android Studio / Xcode). Navigate to your React Native-powered Activity or screen - it loads JS from the Metro dev server with hot reloading.
### Development vs. production
- **Development** — Metro serves the JS bundle with hot reloading over HTTP. Debug builds use the Metro URL via `RCTBundleURLProvider` (iOS) or the dev server detection in `ReactActivity` (Android).
- **Production** — Metro is not used. Run `expo export:embed` (invoked automatically by the React Native Gradle plugin and the iOS build phase) to embed the bundle into the APK/IPA.
For Metro connection issues, build failures, missing modules, or arch mismatches, see [./troubleshooting.md](./troubleshooting.md).
---
## Related references
- [./brownfield-isolated.md](./brownfield-isolated.md) — Alternative: ship RN as a prebuilt AAR/XCFramework.
- [./comparison.md](./comparison.md) — Decide between isolated and integrated.
- [./troubleshooting.md](./troubleshooting.md) — Common Metro, build, and integration issues.
references/brownfield-isolated.md
# Brownfield: Isolated Approach
Build the React Native + Expo code as a prebuilt native library, **AAR** on Android and **XCFramework** on iOS, and consume it from the existing native app like any other dependency.
## When to use
- Native and React Native are owned by different teams or release on different cadences.
- The native team must not be required to install Node.js, Yarn, or React Native tooling.
- React Native code lives in a separate repo or monorepo from the native app.
- You want the smallest possible footprint on the existing native build pipeline.
If a single team owns both layers, is comfortable with React Native tooling and needs deep integration, see [./brownfield-integrated.md](./brownfield-integrated.md).
## What you produce
| Platform | Artifact | Default location |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| Android | `{group}:{libraryName}:{version}` AAR | Local Maven (`~/.m2`) by default; remote Maven also supported |
| iOS | Set of `.xcframework`s — see [the iOS section below](#ios) for how `ios.buildReactNativeFromSource` (default `false` on SDK 56+) controls whether you get 5 frameworks or 2 — or a single Swift Package via `--package` | `./artifacts` |
The JavaScript bundle is **embedded inside the artifact** in release builds, so the native app does not need Metro at runtime in production.
## Prerequisites
- **Expo SDK 55 or later** — brownfield support, `expo-brownfield`, and the required runtime classes are only available on SDK 55+. Earlier SDKs will not work.
- **Node.js (LTS)** — runs JavaScript and the Expo CLI.
- **Yarn** — manages JavaScript dependencies.
Node and Yarn are only needed in the environment that _builds_ the artifact. The consuming native app does not need them.
---
## 1) Set up the Expo project
### Create a new Expo project
```sh
npx create-expo-app@latest my-project --template default@sdk-55
```
**Pin to SDK 55 or later — earlier SDKs do not support brownfield.** The project can live in a separate repo or alongside the native app in a monorepo; it does not need to be inside the native project.
### Install expo-brownfield
```sh
cd my-project
npx expo install expo-brownfield
```
The plugin self-registers in `app.json` with defaults derived from your app config.
### Configure the plugin (optional)
To override the auto-generated names, expand the plugin entry in `app.json`:
```json
{
"expo": {
"plugins": [
[
"expo-brownfield",
{
"ios": {
"targetName": "MyBrownfield",
"bundleIdentifier": "com.example.mybrownfield"
},
"android": {
"libraryName": "mybrownfield",
"group": "com.example",
"package": "com.example.mybrownfield",
"version": "1.0.0"
}
}
]
]
}
}
```
**iOS options** — `targetName` (XCFramework target name), `bundleIdentifier` (framework bundle ID).
**Android options** — `libraryName` (AAR name), `group` (Maven group ID), `package` (Android package), `version` (library version), `publishing` (Maven publication targets — see [Publishing the Android AAR](#publishing-the-android-aar)).
### Speed up iOS builds with prebuilt Expo modules
Enable `expo-build-properties`'s `ios.usePrecompiledModules` so `pod install` downloads each Expo module as a prebuilt `.xcframework` instead of compiling it from source. `build:ios` detects those xcframeworks under `ios/Pods/` and bundles them into the Swift Package output alongside the brownfield framework, React, Hermes, and `ReactNativeDependencies`.
```json
{
"expo": {
"plugins": [
["expo-build-properties", { "ios": { "usePrecompiledModules": true } }],
"expo-brownfield"
]
}
}
```
When precompiled modules are detected, `build:ios` is pinned to a single flavor (`--debug` or `--release`) per package — Swift Package Manager has no per-configuration overload for `.binaryTarget(path:)`. Build once per flavor and distribute the two packages side by side.
---
## 2) Build the native libraries
### Android
```sh
npx expo-brownfield build:android
```
Produces an AAR and publishes it to the local Maven repository at `~/.m2`. The Maven coordinates come from the plugin config — e.g. `com.example:mybrownfield:1.0.0`.
#### Publishing the Android AAR
The plugin's `publishing` option controls where the AAR is published. When unset, it defaults to local Maven. To push to other targets (e.g. a shared CI Maven, an internal Artifactory/Nexus, or a folder pulled into another build), declare the publications explicitly:
```json
{
"expo": {
"plugins": [
[
"expo-brownfield",
{
"android": {
"libraryName": "mybrownfield",
"group": "com.example",
"version": "1.0.0",
"publishing": [
{ "type": "localMaven" },
{
"type": "localDirectory",
"name": "build",
"path": "./out/maven"
},
{
"type": "remotePublic",
"name": "company",
"url": "https://maven.example.com/releases"
},
{
"type": "remotePrivate",
"name": "artifactory",
"url": { "variable": "ARTIFACTORY_URL" },
"username": { "variable": "ARTIFACTORY_USER" },
"password": { "variable": "ARTIFACTORY_TOKEN" }
}
]
}
}
]
]
}
}
```
Supported `type` values: `localMaven`, `localDirectory`, `remotePublic`, `remotePrivate`. For private repos, credentials and URL accept either inline strings or `{ "variable": "ENV_VAR_NAME" }` to read from the environment at publish time.
By default, `build:android` runs every declared publication. To pick specific publications or repositories from the command line, use the CLI flags:
```sh
npx expo-brownfield build:android --task publishReleasePublicationToCompanyRepository
npx expo-brownfield tasks:android # list available publish tasks and repositories
```
### iOS
```sh
npx expo-brownfield build:ios
```
Outputs to `./artifacts`. The set depends on the `ios.buildReactNativeFromSource` flag (set via `expo-build-properties`):
- **`buildReactNativeFromSource: false`** (default on SDK 56+) — React Native is consumed as a prebuilt binary, so `build:ios` emits five xcframeworks side-by-side: `{TargetName}.xcframework`, `React.xcframework`, `ReactNativeDependencies.xcframework`, `ExpoModulesJSI.xcframework`, and `hermesvm.xcframework`.
- **`buildReactNativeFromSource: true`** (default on SDK 55, opt-in on SDK 56+) — React Native is compiled from source and statically linked into the brownfield framework, leaving two xcframeworks: `{TargetName}.xcframework` and `hermesvm.xcframework`.
To force source builds on SDK 56+, add `expo-build-properties` to `app.json`:
```json
{
"expo": {
"plugins": [
["expo-build-properties", { "ios": { "buildReactNativeFromSource": true } }],
"expo-brownfield"
]
}
}
```
**Every xcframework in the produced set must be embedded in the consuming app** (Embed & Sign). The Swift Package output below (`--package`) wires this for you automatically.
> **iOS deployment target:** the brownfield artifact inherits the Expo project's iOS deployment target (16.4 on SDK 56+). The consuming app's deployment target must be set to 16.4 or higher; otherwise Xcode will refuse to link the embedded frameworks. If the host app is on an older floor (e.g. iOS 14.0), bump its `IPHONEOS_DEPLOYMENT_TARGET` before adding the artifact.
#### Ship as a Swift Package (recommended)
Pass `--package [name]` to bundle the output as a self-contained Swift Package instead of separate `.xcframework` directories. The host iOS app then consumes it via **Add Package Dependencies → Add Local** in Xcode and links every bundled framework automatically — no manual drag-and-drop, no per-framework "Embed & Sign" toggles.
```sh
npx expo-brownfield build:ios --release --package MyAppPackage
```
The flag accepts an optional name. If omitted, the package is named `{TargetName}Artifacts`. The resulting directory is a complete Swift Package:
```
artifacts/MyAppPackage/
├── Package.swift
└── xcframeworks/
├── MyAppPackage.xcframework
├── hermesvm.xcframework
├── React.xcframework
└── ReactNativeDependencies.xcframework
```
When `usePrecompiledModules` is enabled, the package directory is suffixed with the build flavor (e.g. `MyAppPackage-release/`) and includes every prebuilt Expo module xcframework. Run `build:ios --debug --package …` and `build:ios --release --package …` separately, and point your host app at the matching package for each build configuration.
### Generate native projects for debugging
To inspect or debug the generated native code, run prebuild:
```sh
npx expo prebuild
```
This creates `android/` and `ios/` directories containing the brownfield wrappers:
**Android (Kotlin):** `ReactNativeHostManager`, `BrownfieldActivity`, `ReactNativeFragment`, `ReactNativeViewFactory`, `BrownfieldMessaging`.
**iOS (Swift):** `ReactNativeHostManager`, `ReactNativeViewController`, `ReactNativeView` (SwiftUI), `BrownfieldMessaging`, `ReactNativeDelegate`.
---
## 3) Consume from the native app
### Android
#### Add the Maven dependency
In `app/build.gradle.kts`:
```kotlin
dependencies {
implementation("com.example:mybrownfield:1.0.0")
}
```
If consuming from the local Maven repo, register `mavenLocal()` in `settings.gradle.kts`:
```kotlin
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
mavenLocal()
}
}
```
> **Note:** `mavenLocal()` must be added under `dependencyResolutionManagement`, not the deprecated top-level `allprojects { repositories { ... } }` block.
If the artifact is published to a remote Maven, declare that repository in the same `dependencyResolutionManagement` block instead — credentials follow Gradle's standard `maven { url = uri(...); credentials { username = ...; password = ... } }` form.
#### Show a React Native screen
Extend `BrownfieldActivity` and call `showReactNativeFragment()`:
```kotlin
import android.os.Bundle
import com.example.mybrownfield.BrownfieldActivity
import com.example.mybrownfield.showReactNativeFragment
class ExpoActivity : BrownfieldActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
showReactNativeFragment()
}
}
```
`BrownfieldActivity` extends `AppCompatActivity` and forwards configuration changes. `showReactNativeFragment()` registers the React Native root fragment and wires native back-button handling automatically.
Register the activity in `AndroidManifest.xml`:
```xml
<activity
android:name=".ExpoActivity"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
/>
```
Launch it from native code:
```kotlin
startActivity(Intent(this, ExpoActivity::class.java))
```
### iOS
#### Add the artifacts to the Xcode project
If you built a **Swift Package** (`build:ios --package …`):
- In Xcode, **File → Add Package Dependencies… → Add Local…**, then select the generated package directory (e.g. `artifacts/MyAppPackage/`).
- Add the package's product to your app target. Xcode links every bundled XCFramework through the aggregate library product — no manual "Embed & Sign" step.
- If you produced both debug and release packages (because `usePrecompiledModules` is enabled), point the host app at the matching package per build configuration.
If you built **standalone XCFrameworks** (default output):
- Drag **every** `.xcframework` produced under `./artifacts` into the Xcode project navigator.
- In the import dialog, check **Copy items if needed** and add them to your app target.
- Under the app target's **General** tab → **Frameworks, Libraries, and Embedded Content**, set **every** framework to **Embed & Sign**. Forgetting one (commonly `hermesvm.xcframework`) is a leading cause of runtime "Library not loaded" crashes — see [./troubleshooting.md](./troubleshooting.md#ios-xcframework-signing-isolated-approach).
#### Initialize React Native at app launch
Call `ReactNativeHostManager.shared.initialize()` from `AppDelegate` **before any React Native view is created**. Initialization is asynchronous-friendly but must precede the first `ReactNativeViewController`/`ReactNativeView` instantiation.
```swift
import UIKit
import MyAppBrownfield // Replace with your target name
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
ReactNativeHostManager.shared.initialize()
return true
}
}
```
#### Present a React Native view (UIKit)
```swift
import UIKit
import MyAppBrownfield
class ViewController: UIViewController {
@IBAction func openReactNative(_ sender: Any) {
let rnViewController = ReactNativeViewController(moduleName: "main")
navigationController?.pushViewController(rnViewController, animated: true)
}
}
```
Pass props and launch options if needed:
```swift
let rnViewController = ReactNativeViewController(
moduleName: "main",
initialProps: ["userId": "123"],
launchOptions: [:]
)
```
> **Note:** `moduleName` must match the name registered via `AppRegistry.registerComponent(...)` in the Expo project's JS entry point. The default Expo template registers `"main"`.
#### Present a React Native view (SwiftUI)
```swift
import SwiftUI
import MyAppBrownfield
struct ContentView: View {
@State private var showReactNative = false
var body: some View {
Button("Open React Native") {
showReactNative = true
}
.fullScreenCover(isPresented: $showReactNative) {
ReactNativeView(moduleName: "main")
}
}
}
```
---
## Development vs. production
### Development (debug builds)
Start Metro in the Expo project:
```sh
npx expo start
```
Build and run the native app in debug. React Native screens load JS from the Metro dev server over HTTP with full hot reloading. The device or emulator must be able to reach the dev machine — see [./troubleshooting.md](./troubleshooting.md) if Metro connections fail.
### Production (release builds)
The JS bundle is embedded inside the AAR/XCFramework. Metro is not used. Build the native app in Release configuration and confirm the React Native screen loads.
---
## Related references
- [./brownfield-integrated.md](./brownfield-integrated.md) — Alternative: add RN directly to the native build.
- [./comparison.md](./comparison.md) — Decide between isolated and integrated.
- [./troubleshooting.md](./troubleshooting.md) — Common Metro, build, and integration issues.
references/comparison.md
# Brownfield: Isolated vs. Integrated
Use this reference to choose between the two ways of adding React Native + Expo to an existing native app. If the team and constraints are already known, jump to one of:
- [./brownfield-isolated.md](./brownfield-isolated.md) — RN as a prebuilt AAR / XCFramework.
- [./brownfield-integrated.md](./brownfield-integrated.md) — RN added directly to existing Gradle / CocoaPods.
## Quick decision rules
- **Choose isolated** if the native team must consume React Native as a regular library (AAR or XCFramework) without installing Node, Yarn, or RN tooling.
- **Choose isolated** if React Native and the native app live in **separate repositories**, or release on **different cadences**.
- **Choose isolated** if the existing native build is heavily customized (Tuist, Bazel, Buck, custom Gradle plugins) and adding the React Native Gradle plugin or CocoaPods autolinking would be disruptive.
- **Choose integrated** if a **single team** owns the native and React Native code and is willing to maintain the RN build chain inside the native project.
- **Choose integrated** if you want **hot reload, JS source maps, and devtools** to "just work" inside the existing native build with no extra orchestration.
- **Choose integrated** if you expect to add many Expo modules and want them autolinked by the standard Expo tooling rather than rebuilt into a fresh artifact each time.
When in doubt — and especially when the question is "can the native team avoid React Native tooling?" — pick **isolated**.
## Comparison
| Dimension | Isolated | Integrated |
| ---------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------- |
| What ships to the native app | Prebuilt AAR + XCFramework | React Native + Expo sources, autolinked into the existing build |
| Native team needs Node / Yarn / RN CLI | **No** | **Yes** |
| Build-system footprint | Minimal — one Maven dependency, two embedded XCFrameworks | Pervasive — React Native Gradle plugin, Podfile, autolinking, codegen |
| Iteration speed for RN devs | Fast in isolation; native rebuild needed to pick up new artifact | Fast end-to-end; one combined build |
| Dev-time hot reload | Yes (via Metro, when running the consumer app in debug) | Yes (native build embeds Metro detection) |
| Production JS bundle location | Embedded in the AAR/XCFramework | Embedded in the APK/IPA by the RN Gradle plugin / Xcode build phase |
| Maintenance ownership | RN team owns the artifact pipeline; native team owns the consumer build | One team owns the unified build |
| Suitability for incremental adoption | High — easy to slot into one screen of an existing app | High, but with more setup before the first screen renders |
| Suitability for multi-repo / multi-team setups | High | Low — tends to require a monorepo |
| Risk of build-system conflicts with existing tooling | Low | Higher (RN Gradle plugin, codegen, Podfile assumptions) |
| Re-publish workflow for RN changes | `npx expo-brownfield build:*` then bump the dependency | Rebuild the native app |
## Common scenarios
**"React Native code is in `xyz-react` and the native apps are in `xyz-ios` and `xyz-android`. Each ships independently."**
→ **Isolated.** Build versioned artifacts (`com.xyz:onboarding:1.4.0`, `Onboarding.xcframework`). Native apps pin a version like any other dependency.
**"Our app uses a heavily customized Gradle setup with multiple variants and flavors."**
→ **Isolated.** The RN Gradle plugin is opinionated about variant naming and bundle output paths; integrating cleanly with non-standard variants is non-trivial.
**"We don't know yet whether RN will stay — we want to be able to remove it cheaply."**
→ **Isolated.** Removing the dependency removes the framework; the native build is barely touched.
**"Our iOS team uses Tuist and refuses to add Node to the iOS build."**
→ **Isolated.** Ship an XCFramework. The iOS team adds two `.xcframework` files and one call to `ReactNativeHostManager.shared.initialize()` in `AppDelegate`. No Node, no CocoaPods changes to Expo.
**"We have one repo, one team, and we want to deeply integrate React Native with the onboarding flow to an existing Android app."**
→ **Integrated.** Move the existing `android-project` into `my-project/android/`, add the React Native Gradle plugin to `settings.gradle`, register `MainApplication`, and host the flow in a `ReactActivity`. One build pipeline.
**"We want to use CNG on the RN code and not worry about manual RN upgrades."**
→ **Isolated.** The AAR/XCFramework approach decouples the Expo RN version from the native app's build, so you can upgrade Expo and React Native independently of the native app's release cycle. The integrated approach requires more coordination between the RN version and the native app's build.
## What is identical between the approaches
- The React Native + Expo source code itself — the same Expo project, the same `app.json`, the same modules — only differs in **how** it is shipped.
- The JavaScript module registered with `AppRegistry.registerComponent("main", () => App)` is the same; the native side passes the same `moduleName` string in both flows.
## What is different at runtime
- **Isolated** uses Expo's brownfield runtime wrappers — `ReactNativeHostManager`, `BrownfieldActivity`, `ReactNativeViewController`, `ReactNativeView`. These are generated by `npx expo-brownfield build:*` and bundled into the artifact.
- **Integrated** uses the standard React Native runtime — `ReactActivity`, `ReactActivityDelegate`, `RCTReactNativeFactory`, `ExpoReactNativeFactory` — exposed by `react-native` and `expo` directly.
references/troubleshooting.md
# Brownfield Troubleshooting
Cross-cutting issues that apply to both the isolated and integrated approaches. For approach-specific setup, see [./brownfield-isolated.md](./brownfield-isolated.md) or [./brownfield-integrated.md](./brownfield-integrated.md).
## Build failures
**Symptom:** Gradle or Xcode build fails after a config change, dependency upgrade, or Expo SDK bump.
- **Integrated approach** — regenerate native projects from scratch:
```sh
npx expo prebuild --clean
```
Then `cd ios && pod install` and re-open the `.xcworkspace`.
- **Isolated approach** — clear the local Maven cache and rebuild the artifact:
```sh
rm -rf ~/.m2/repository/<group>/<libraryName>
npx expo-brownfield build:android
npx expo-brownfield build:ios
```
- For stubborn iOS issues, also delete `ios/build/`, `ios/Pods/`, and `ios/Podfile.lock`, then re-run `pod install`.
- For stubborn Android issues, `./gradlew clean` and delete the project's `.gradle/` and `build/` directories.
## Missing autolinked Expo modules
**Symptom:** Compilation succeeds but a module throws "Native module cannot be null" / "Cannot find native module 'X'" at runtime.
- Install with `npx expo install <package>` rather than plain `yarn add` — `expo install` picks the version compatible with the current SDK.
- After installing a new module, rebuild the native app. Autolinking runs at native build time, not at JS bundle time.
- For the **isolated approach**, you must re-run `npx expo-brownfield build:android|ios` after adding a module, and republish/re-embed the new artifact.
## Metro connection
**Symptom:** "Could not connect to development server" / red screen on launch in debug.
- Ensure the device or emulator can reach the dev machine. The Android emulator can talk to the host via `10.0.2.2`; physical devices need a reachable LAN IP.
- For physical Android devices on USB: `adb reverse tcp:8081 tcp:8081`.
- Confirm Metro is actually running: `npx expo start` from the Expo project (or `yarn start` from the workspace root).
- Verify the debug `AndroidManifest.xml` enables cleartext traffic — Android 9+ blocks HTTP by default. The debug variant should include `android:usesCleartextTraffic="true"` on `<application>`, or a `network_security_config` allowing the dev server.
- iOS simulator: Metro should be reachable at `localhost:8081`. If it is not, check that ATS exceptions are still in place in `Info.plist` for `localhost` (the Expo template ships this by default).
## iOS XCFramework signing (isolated approach)
**Symptom:** App launches but immediately crashes with "Library not loaded" or codesign errors during archive.
- **Every** xcframework produced by `build:ios` must be set to **Embed & Sign** in the app target's **Frameworks, Libraries, and Embedded Content** section. On SDK 56+ this is five frameworks: `{TargetName}.xcframework`, `React.xcframework`, `ReactNativeDependencies.xcframework`, `ExpoModulesJSI.xcframework`, and `hermesvm.xcframework`. On SDK 55 it's two: `{TargetName}.xcframework` and `hermesvm.xcframework`. Missing any of them is a common cause of runtime crashes.
- The frameworks must be added to the _app target_, not a framework or extension target.
- Prefer the Swift Package output (`build:ios --package`) — it links every bundled xcframework through one aggregate product, so you cannot forget one.
## iOS architecture / simulator mismatch
**Symptom:** "Building for iOS Simulator, but the linked library was built for iOS" or "Undefined symbols for architecture arm64".
- The XCFramework includes both device and simulator slices. If a slice is missing, rebuild on the missing platform. The `expo-brownfield build:ios` command produces both by default.
- On Apple Silicon simulators, do **not** set `EXCLUDED_ARCHS = arm64` for the simulator configuration — Apple Silicon simulators require `arm64`. The classic Rosetta-only exclusion is no longer correct.
## Android `mavenLocal()` not found (isolated approach)
**Symptom:** Gradle reports "Could not find com.example:mybrownfield:1.0.0" even after a successful `expo-brownfield build:android`.
- `mavenLocal()` must be declared under `dependencyResolutionManagement { repositories { ... } }` in `settings.gradle.kts`, not the deprecated top-level `allprojects { repositories { ... } }` block. The deprecated form is silently ignored when `dependencyResolutionManagement` is present.
- Confirm the artifact actually landed in `~/.m2`:
```sh
find ~/.m2/repository -name "mybrownfield*"
```
- Verify the `group` and `libraryName` in the consumer's dependency line match what the plugin config emitted.
## Module name mismatch
**Symptom:** The native view loads but renders a blank screen, with "Application 'X' has not been registered" in the JS logs.
- The `moduleName` passed to `ReactNativeViewController(moduleName: "main")` (iOS) or returned from `getMainComponentName()` (Android) must equal the name passed to `AppRegistry.registerComponent("main", () => App)` in the JS entry point.
- The default Expo template registers `"main"`. If you changed the registration, update every native call site.
## Monorepo: autolinking can't find the Expo project
**Symptom:** Gradle or CocoaPods fails resolving Expo modules even though they are installed.
- **Android (integrated):** set `root = file("../../my-project")` (or the correct relative path) inside the `react { ... }` block in `app/build.gradle`, and explicitly set the project root in `settings.gradle` before `expoAutolinking.useExpoModules()`.
- **iOS (integrated):** set `:app_path` in `use_react_native!` to the absolute path of the Expo project root. Optionally pass `EXPO_PROJECT_ROOT=/abs/path` to `pod install`.
- Confirm `node_modules/` is installed at the workspace root (`yarn install` from the monorepo root, not from the Expo project subdirectory).
## After upgrading Expo SDK
If the brownfield setup stops building after an SDK upgrade:
- Re-run `npx expo install --fix` in the Expo project to align native module versions.
- Rebuild the artifact (isolated) or run `npx expo prebuild --clean` (integrated).
- Compare the new `templates/expo-template-bare-minimum` for the target SDK against your customized native files — Expo occasionally changes Gradle plugin names, Podfile helpers, or AppDelegate entry points across SDKs.