Zurück zu Skills
expo/skillsVor der Ausführung prüfen

SKILL DETAIL

expo-brownfield

expo/skills/expo-brownfield

Framework (OSS). Integrate Expo and React Native into an existing native iOS or Android app. Use for brownfield, embedding a React Native screen in SwiftUI/UIKit or Kotlin, or AAR/XCFramework packaging. Covers isolated and integrated approaches. For building or distributing a purely native app with EAS, use eas-app-stores.

Installationen · 277Quelle ansehen

Installation

npx skills add https://github.com/expo/skills --skill expo-brownfield

Skill-Dateien

SKILL.md

Zuletzt synchronisiert · 11.09.2026

agents/openai.yaml
interface:
  display_name: "Expo Brownfield"
  short_description: "Embed Expo screens in existing native iOS and 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 SwiftUI/UIKit screens or 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 JS bundling and native compilation in the host's existing build pipeline. Both approaches support Metro during development.
- 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

- **An Expo/React Native version pair compatible with the host** — check [version compatibility](./version-compatibility.md) before editing the host. Use the selected SDK's native template and installed APIs; do not infer the minimum version of integrated brownfield from the separate `expo-brownfield` package.
- **Node.js (LTS)** — runs JavaScript and the Expo CLI.
- The existing package manager and lockfile (commands below use Yarn as an example).
- **Xcode and CocoaPods** (iOS) — use the host's Gemfile/Bundler setup when available.

---

## 1) Create an Expo project

Create the Expo project inside or alongside the existing native project, using the SDK selected during host inspection. For a new small feature on the current stable SDK:

```sh
npx create-expo-app@latest my-project --template blank@latest
```

For TypeScript, install `typescript` and `@types/react` with `npx expo install`, then use the explicit entry point in [feature integration](./feature-integration.md#register-the-component-that-receives-input). 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) Establish the project layout

Keep the existing repository layout when possible and configure paths explicitly. If consolidating into an Expo root, the resulting native build roots should be `my-project/ios/<Host>.xcodeproj` and `my-project/android/settings.gradle`, not an extra nested `android/android-project/` directory. Preserve source files, targets, signing, schemes, and relative resource paths; verify the native host still builds after relocation.

Ensure hand-maintained `ios/` and `android/` files are tracked and included in any EAS upload. A create-expo-app `.gitignore` may exclude them by default. **Do not run prebuild on this host.** Apply native configuration and SDK upgrade diffs directly.

### 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/sdk-57/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/sdk-57/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()
  hermesCommand = new File(["node", "--print", "require.resolve('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"
  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()
}
```

The Hermes compiler resolution above follows SDK 57; use the selected SDK's template for other versions.

> **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/sdk-57/templates/expo-template-bare-minimum/ios/Podfile). The following excerpt follows SDK 57. For another SDK, adapt its matching template; preserve the host's other targets and Podfile hooks:

```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 {}

ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
ENV['RCT_USE_RN_DEP'] ||= podfile_properties['ios.buildReactNativeFromSource'] == 'true' ? '0' : '1'
ENV['RCT_USE_PREBUILT_RNCORE'] ||= podfile_properties['ios.buildReactNativeFromSource'] == 'true' ? '0' : '1'
ENV['RCT_HERMES_V1_ENABLED'] ||= '0' if podfile_properties['expo.useHermesV1'] == 'false'
ENV['EXPO_USE_PRECOMPILED_MODULES'] = '0' if podfile_properties['EXPO_USE_PRECOMPILED_MODULES'] == 'false'
ENV['EXPO_USE_PRECOMPILED_MODULES'] ||= '1'

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
```

The `16.4` fallback and prebuilt settings match the SDK 57 template; retain a higher host/module requirement and use the selected SDK's minimum for other versions. 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

Merge these settings into the existing target. Compare against the native template for the selected SDK rather than copying the moving `main` template wholesale.

Configure script execution and Release bundling, then reconcile status-bar ownership with the native host.

#### 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
# Configure NODE_BINARY in ios/.xcode.env for the machine running Xcode.
# For example: export NODE_BINARY=$(command -v node)
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`

Expo templates set `UIViewControllerBasedStatusBarAppearance` to `NO` for React Native status-bar control. This is an app-wide setting: preserve the host's existing controller-based behavior when required, and adapt the embedded screen's status-bar handling. If the host adopts the template behavior, use:

```xml
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
```

### Own the runtime without replacing the native window

Keep the existing `AppDelegate`, `SceneDelegate`, SwiftUI `App`, and navigation stack. Add one retained runtime owner and pass it to RN screens. This keeps the factory and its delegate alive across presentations without creating a competing `@main` or a new root window.

```swift
import UIKit
internal import Expo
import React
import ReactAppDependencyProvider

@MainActor
final class ReactNativeRuntime {
  private let delegate: ReactNativeDelegate
  let factory: ExpoReactNativeFactory
  let launchOptions: [UIApplication.LaunchOptionsKey: Any]?

  init(launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) {
    self.launchOptions = launchOptions
    let delegate = ReactNativeDelegate()
    delegate.dependencyProvider = RCTAppDependencyProvider()
    self.delegate = delegate
    self.factory = ExpoReactNativeFactory(delegate: delegate)
  }
}

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
  }
}
```

Keep Swift import access levels consistent with the generated Expo module provider (SDK 55 and 57 providers use `internal import Expo`).

Create and retain `ReactNativeRuntime(launchOptions: launchOptions)` from the existing app delegate's launch callback. Forward Expo module lifecycle callbacks as described in [feature integration](./feature-integration.md#forward-lifecycle-events). If your delegate can inherit from `ExpoAppDelegate`, call `super` from its overrides; otherwise use the subscriber manager while preserving the existing superclass and host behavior.

The code above selects Metro in Debug and the embedded bundle in Release. It does not configure an Updates controller or a development-client launcher. Those integrations need their own SDK-matched delegate setup.

### Present a React Native screen

```swift
import UIKit

final class ReactNativeScreenViewController: UIViewController {
  private let runtime: ReactNativeRuntime
  private let initialProps: [AnyHashable: Any]?

  init(runtime: ReactNativeRuntime, initialProps: [AnyHashable: Any]? = nil) {
    self.runtime = runtime
    self.initialProps = initialProps
    super.init(nibName: nil, bundle: nil)
  }

  @available(*, unavailable)
  required init?(coder: NSCoder) {
    fatalError("Use init(runtime:initialProps:)")
  }

  override func loadView() {
    view = runtime.factory.rootViewFactory.view(
      withModuleName: "main",
      initialProperties: initialProps,
      launchOptions: runtime.launchOptions
    )
  }
}
```

From the existing UIKit controller, using the runtime supplied by the app:

```swift
let screen = ReactNativeScreenViewController(
  runtime: runtime,
  initialProps: ["userId": "123"]
)
navigationController?.pushViewController(screen, animated: true)
```

For SwiftUI, retain the existing `@main struct HostApp: App`. If it has no delegate, attach one with `@UIApplicationDelegateAdaptor(AppDelegate.self)`; if it already has one, extend that delegate. Pass the retained runtime through the host's view hierarchy and wrap the controller:

```swift
import SwiftUI

struct EmbeddedReactScreen: UIViewControllerRepresentable {
  let runtime: ReactNativeRuntime
  let userId: String

  func makeUIViewController(context: Context) -> ReactNativeScreenViewController {
    ReactNativeScreenViewController(
      runtime: runtime,
      initialProps: ["userId": userId]
    )
  }

  func updateUIViewController(_ controller: ReactNativeScreenViewController, context: Context) {}
}
```

Present `EmbeddedReactScreen` from the host's sheet or navigation destination. Initial props are creation-time input; use messaging/shared state for subsequent changes. For input registration, result handling, and dismissal, read [feature integration](./feature-integration.md).

Only use `factory.startReactNative(withModuleName:in:launchOptions:)` with a window when the task explicitly calls for making RN the app's root. It is not necessary for this embedded-screen recipe.

> **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. The configured Gradle/Xcode build phases invoke `export:embed`. Stop Metro and run the host in Release; verify input, result, dismissal, and reopening as in [feature integration](./feature-integration.md#acceptance-scenario).

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 (depends on source/prebuilt settings and package version), or a Swift Package via `--package`; see [iOS](#ios) | `./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 for this Expo toolkit** — `expo-brownfield` was introduced in SDK 55. Match package versions and native requirements to the selected SDK; this is not a minimum for historical integrated setups.
- **Node.js (LTS)** — runs JavaScript and the Expo CLI.
- The existing package manager and lockfile. Yarn is not required.

Node and the JS package manager 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 blank@latest
```

Use this current stable blank template for a small embedded feature after the [version/toolchain checks](./version-compatibility.md). If host constraints require another SDK, select its published template tag instead. Keep an existing producer and its entry point when present. 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
```

Check that the plugin registered in `app.json`; add it explicitly if the install command did not update the config (for example, with dynamic app configuration). Defaults derive from your app config.

### Check what the host app already ships

Before picking Expo modules, audit the host app's dependencies. The artifact's libraries meet the host's at build time, and version clashes surface as duplicate-class errors or forced upgrades.

- **Jetpack Compose** — `@expo/ui` re-declares recent Compose and Material3 versions, and is pulled transitively by `expo-router`. A host pinned to older Compose gets force-upgraded. Exclude it with `expo.autolinking.android.exclude` if the RN screens don't need it.
- **OkHttp, Kotlin stdlib, Material Components** — arrive as ordinary Maven dependencies of the artifact; Gradle resolves the highest version, which can bump the host's copies.

When a shared library must stay at the host's version, exclude the Expo module that brings it, or (with fused publishing, below) mark the group as host-provided.

### 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

SDK 57 enables precompiled Expo modules by default. For a supported SDK where an explicit opt-in is needed, install `expo-build-properties` with `npx expo install expo-build-properties` and enable its `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
```

#### Fused publishing (single fat AAR)

> **Version note:** requires minimum SDK 56. Earlier versions only support the per-module publishing above.

The default publish flow emits one Maven coordinate per autolinked Expo module. For remote distribution, `--fused` collapses everything into one fat AAR per build variant:

```sh
npx expo-brownfield build:android --fused --repo MavenLocal
```

This publishes two coordinates — `{group}:{libraryName}-fused-release` and `{group}:{libraryName}-fused-debug`, which the host wires per build type:

```kotlin
dependencies {
  releaseImplementation("com.example:mybrownfield-fused-release:1.0.0")
  debugImplementation("com.example:mybrownfield-fused-debug:1.0.0")
}
```

The debug AAR contains debug-compiled modules (dev menu, Metro reload); the release AAR embeds the JS bundle. Published metadata pins the matching React Native variant, so a debug host consuming only the release AAR still resolves release RN correctly.

Not everything is fused: the React Native runtime, Kotlin stdlib, host-common libraries (Material, Guava, OkHttp, Fresco), `androidx.*`, and detected KMP umbrella modules stay external and are declared as ordinary POM dependencies. Gradle properties tune the behavior for unusual dependency graphs:

| Property                              | Effect                                                                                                                               |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `brownfield.fused.skip`               | Gradle project names to leave out of the AAR (pair with `strip-packages`).                                                           |
| `brownfield.fused.strip-packages`     | Package prefixes to remove from `ExpoModulesPackageList` — avoids `NoClassDefFoundError` for skipped modules.                        |
| `brownfield.fused.androidx-fuse`      | Extra `androidx.*` groups to fuse instead of keeping external.                                                                       |
| `brownfield.fused.exclude-transitive` | Extra groups to keep external (still declared in the POM).                                                                           |
| `brownfield.fused.host-provided`      | Groups the host already ships (e.g. Glide, Compose): excluded from the AAR **and** from the POM, so the host's version is untouched. |

### iOS

```sh
npx expo-brownfield build:ios
```

Outputs to `./artifacts`. Set `ios.buildReactNativeFromSource` on the **`expo-brownfield` plugin**; it applies the build-properties configuration itself and can override a separate `expo-build-properties` entry. The set depends on that setting and the installed package version:

- **`buildReactNativeFromSource: false`** (default on SDK 56+) — React Native is consumed as a prebuilt binary. A typical set includes: `{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, typically leaving: `{TargetName}.xcframework` and `hermesvm.xcframework`.

To force source builds, configure the brownfield plugin directly in `app.json`:

```json
{
  "expo": {
    "plugins": [
      [
        "expo-brownfield",
        { "ios": { "buildReactNativeFromSource": true } }
      ]
    ]
  }
}
```

Use the actual output and generated package manifest as the dependency inventory; precompiled modules can add more binaries. Link all required frameworks and **Embed & Sign dynamic frameworks**. Static binaries are linked, not embedded. Do not mix source-linked React Native with another copy already in the host. The Swift Package output below describes the produced dependencies.

> **iOS deployment target:** compare the host's supported OS versions with the selected Expo/RN version and every produced binary. If the artifact requires a higher floor, resolve that product constraint before integration; changing a build setting cannot make a newer binary support older iOS releases.

#### Ship as a Swift Package (recommended)

Pass `--package [name]` to generate a local Swift Package around the XCFramework output. Add it with **Add Package Dependencies → Add Local**, then inspect `Package.swift` and select the products the host requires. Packaging layout and products vary by CLI version.

```sh
npx expo-brownfield build:ios --release --package MyAppPackage
```

Confirm `--package` in the installed CLI's help before using it. It accepts an optional package name. Inspect the printed output directory and its `Package.swift` instead of assuming the package name also changes the framework/module name (`MyBrownfield` in this guide).

Use separate output directories for Debug and Release. The CLI can clear its selected artifacts directory before writing a package, so changing only the package name is not a safe way to preserve the previous flavor:

```sh
npx expo-brownfield build:ios --debug --artifacts ./artifacts-debug --package MyAppPackage
npx expo-brownfield build:ios --release --artifacts ./artifacts-release --package MyAppPackage
```

Build Debug and Release artifacts separately when the producer requires a single flavor per package. A host built in Debug with a Release binary still contains Release RN code; it does not become a Metro-enabled artifact. Swift Package Manager does not select `.binaryTarget(path:)` by Xcode configuration. Select the matching package before each host build, or use explicit build-system wiring that supplies matching binaries; do not link both packages with duplicate module names into one target.

### Generate native projects for debugging

To inspect the generated native code, run prebuild **from the separate Expo producer whose native directories are CNG-owned**, never from the consuming host:

```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.

#### Host app requirements

- **`minSdk` 24 or higher** — React Native's floor. Hosts below it fail at manifest merge with `uses-sdk:minSdkVersion XX cannot be smaller than version 24`.
- **Permissions merge in from the Expo modules** (e.g. storage permissions from media modules). Hosts that enforce a permission allowlist can strip unwanted entries in their manifest with `tools:node="remove"` or reconcile attribute conflicts with `tools:replace`.
- **Native libraries ship for every ABI enabled at publish time.** Left unfiltered, this can multiply the host APK size. Constrain ABIs when publishing (`reactNativeArchitectures=arm64-v8a` in the Expo project's `gradle.properties`) or filter in the host with `ndk.abiFilters` / APK splits.

#### 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/`).
- In `[email protected]`, precompiled-module builds generate a configuration-suffixed package/product such as `MyAppPackage-release`; select that aggregate product, which includes the binary targets. The Swift import remains the configured framework module (`MyBrownfield`), not the package name. Without precompiled modules, the CLI generates separate library products: add all required products from `Package.swift`. SDK 55.0.28 likewise exposes separate `MyBrownfield` and `hermesvm` products.
- If you produced Debug and Release packages, explicitly select the matching dependency before building the host; Xcode does not switch local binary packages automatically.

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**, embed and sign the dynamic frameworks; link static binaries without embedding them. For missing runtime dependencies, see [./troubleshooting.md](./troubleshooting.md#ios-xcframework-signing-isolated-approach).

#### Initialize React Native at app launch

Merge `ReactNativeHostManager.shared.initialize()` into the existing launch callback **before any React Native view is created**. Keep the native window and navigation. This delegate example uses the generated framework's `ExpoBrownfieldAppDelegate` to forward lifecycle callbacks; if a custom superclass prevents that, use the delegate-forwarding path in [feature integration](./feature-integration.md#forward-lifecycle-events).

```swift
import UIKit
import MyBrownfield // The configured framework target, not the Swift Package name

// Merge into the existing delegate. Keep its existing @main only for a UIKit entry point.
class AppDelegate: ExpoBrownfieldAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    ReactNativeHostManager.shared.initialize()
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}
```

#### Present a React Native view (UIKit)

```swift
import UIKit
import MyBrownfield

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)

Keep the existing `@main struct HostApp: App`. Add `@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate` there only if the host has no delegate adaptor yet. Extend an existing delegate instead of introducing a second one. This connects initialization and lifecycle forwarding without changing the `WindowGroup`.

```swift
import SwiftUI
import MyBrownfield

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 a Debug artifact (`npx expo-brownfield build:ios --debug` on iOS), select it in the host, then 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)

Build/select the Release artifact and build the host in Release. Stop Metro and verify JS and image assets load, then exercise input, result, dismissal, and reopening using the [acceptance scenario](./feature-integration.md#acceptance-scenario).

---

## 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.
- Both approaches can use **Metro and Fast Refresh** in Debug; choose based on artifact/build ownership.
- **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                               | Artifact dependency plus its required runtime libraries               | 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 the generated Swift Package or complete XCFramework set 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.** Keep the host layout or place its Gradle root directly at `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 the Expo config plugin and bundled into the artifact.
- **Integrated** uses the standard React Native runtime — `ReactActivity`, `ReactActivityDelegate`, `RCTReactNativeFactory`, `ExpoReactNativeFactory` — exposed by `react-native` and `expo` directly.
references/feature-integration.md
# Integrate a complete native-hosted feature

Read after choosing the build approach. Packaging a framework and displaying its first view are only part of integration: the host also owns presentation, input, results, and lifecycle events.

## Define the boundary

- Use initial props for a presentation's input, including a fresh `requestId` to correlate replies. Use JSON-compatible values rather than Swift objects or JS callbacks.
- Use messages for events such as context requests, completed, and cancelled. Subscribe before mounting the feature. Keep required startup data in initial props; a message sent during mounting is not a durable inbox or an acknowledgement that the receiving native emitter is ready. Correlate and acknowledge later updates if delivery matters.
- Keep long-lived business state in its existing owner. If both sides must observe mutable state, inspect the installed `expo-brownfield` shared-state APIs (`useSharedState`, `setSharedStateValue`, and `deleteSharedState`) and native facade. Namespace per-feature keys and clear session data when its owner ends the session. Messages alone do not provide persistent state or delivery guarantees.

## Register the component that receives input

For a small standalone producer, set `package.json`'s `main` to `index.ts` and register a component explicitly. If the scaffold has no TypeScript setup, first run `npx expo install typescript @types/react`:

```ts
import { registerRootComponent } from 'expo';
import Feature from './Feature';

registerRootComponent(Feature); // Registers "main", matching the native examples.
```

Do not overwrite an existing app entry point blindly. A Router-based producer needs an explicit adapter to carry root props into the feature; native `initialProps` are not automatically route parameters.

`Feature.tsx`:

```tsx
import { useEffect, useState } from 'react';
import { Button, Text, View } from 'react-native';
import * as Brownfield from 'expo-brownfield';

export default function Feature({ requestId, userId, greeting: initialGreeting }: {
  requestId: string; userId: string; greeting: string;
}) {
  const [greeting, setGreeting] = useState(initialGreeting);

  useEffect(() => {
    const subscription = Brownfield.addMessageListener((event) => {
      if (event.requestId === requestId && event.type === 'feature.context') {
        setGreeting(String(event.greeting));
      }
    });
    return () => subscription.remove();
  }, [requestId]);

  return (
    <View style={{ flex: 1, justifyContent: 'center', padding: 24 }}>
      <Text>{greeting}: {userId}</Text>
      <Button title="Refresh greeting" onPress={() => Brownfield.sendMessage({
        type: 'feature.request-context', requestId,
      })} />
      <Button title="Done" onPress={() => Brownfield.sendMessage({
        type: 'feature.completed', requestId, selectedId: 'item-42',
      })} />
      <Button title="Cancel" onPress={() => Brownfield.sendMessage({
        type: 'feature.cancelled', requestId,
      })} />
    </View>
  );
}
```

## SwiftUI host: receive the result and dismiss

This example uses **Expo's isolated generated framework**, configured as `MyBrownfield`, with `ReactNativeHostManager.shared.initialize()` already connected to the host's app delegate as in [isolated setup](./brownfield-isolated.md). It adds a feature to the existing SwiftUI view hierarchy; it introduces no app entry point.

```swift
import SwiftUI
import MyBrownfield

private struct FeatureRequest: Identifiable {
  let id = UUID().uuidString
  let userId: String
}

struct FeatureLauncher: View {
  @State private var request: FeatureRequest?
  @State private var listenerId: String?
  @State private var selectedId: String?

  var body: some View {
    VStack {
      Text(selectedId ?? "No selection")
      Button("Open feature") { openFeature(userId: "123") }
    }
    .sheet(item: $request, onDismiss: stopListening) { request in
      ReactNativeView(
        moduleName: "main",
        initialProps: ["requestId": request.id, "userId": request.userId, "greeting": "Hello"]
      )
    }
  }

  private func openFeature(userId: String) {
    guard request == nil else { return }
    stopListening()
    let next = FeatureRequest(userId: userId)
    listenerId = BrownfieldMessaging.addListener { message in
      // Messaging callbacks are not a promise of execution on the UI thread.
      DispatchQueue.main.async {
        guard request?.id == next.id,
              message["requestId"] as? String == next.id,
              let type = message["type"] as? String else { return }
        switch type {
        case "feature.request-context":
          BrownfieldMessaging.sendMessage([
            "type": "feature.context", "requestId": next.id, "greeting": "Updated hello"
          ])
        case "feature.completed":
          guard let value = message["selectedId"] as? String else { return }
          selectedId = value
          closeFeature()
        case "feature.cancelled":
          closeFeature()
        default:
          break
        }
      }
    }
    request = next
  }

  private func closeFeature() {
    stopListening()
    request = nil
  }

  private func stopListening() {
    if let listenerId {
      BrownfieldMessaging.removeListener(id: listenerId)
      self.listenerId = nil
    }
  }
}
```

The same message contract works with UIKit: the presenting coordinator owns the subscription, passes the request as `initialProps`, handles the result on the main thread, then pops or dismisses its controller. Remove the subscription on interactive cancellation and coordinator teardown too. Remove only the listener you own, not every feature's listeners.

For **integrated** builds, install/autolink `expo-brownfield` only if using these APIs. The isolated plugin's generated `BrownfieldMessaging` facade is not automatically present in the host. In `expo-brownfield` 55.0.28 and 57.0.18, `internal import ExpoBrownfield` (matching the generated module provider) exposes `BrownfieldMessagingInternal.shared` with instance `addListener`, `sendMessage`, and `removeListener(id:)` methods. Confirm the installed Swift interface before adapting the example, or keep a small host adapter around that SDK-specific surface. Use the [integrated controller](./brownfield-integrated.md#present-a-react-native-screen) instead of the isolated `ReactNativeView`.

Navigation behavior depends on the native container. In the SDK 55 and 57 wrappers, the generated UIKit controller handles `popToNative()` by popping its navigation controller, while the generated SwiftUI `ReactNativeView` also listens for that event and calls SwiftUI `dismiss()`. A custom integrated controller does not acquire these handlers automatically. The example above lets the host consume a result before closing its sheet. Use either that result/close contract or the wrapper's navigation API for a given action; do not trigger both.

## Forward lifecycle events

Some modules require launch, URL, notification, and application-state callbacks even when a basic RN view renders successfully.

- **iOS:** use `ExpoAppDelegate` forwarding when compatible with the host's delegate. The isolated generated framework exports `ExpoBrownfieldAppDelegate`; the integrated app uses `ExpoAppDelegate` from `Expo`. Preserve existing override behavior and call the superclass. With a required custom superclass, an isolated host can retain a generated `ExpoBrownfieldAppDelegate` helper and forward the relevant delegate methods to it. An integrated host can use `ExpoAppDelegateSubscriberManager` from `ExpoModulesCore` directly, following the installed SDK's interface. Forward each event once. For scene-based URL handling, connect the existing scene/SwiftUI handler to the module's required callback; an app-delegate adaptor alone does not replace scene delivery.
- **Android:** preserve `ApplicationLifecycleDispatcher` calls and the activity lifecycle/back handling from the chosen approach. Exercise configuration changes and native back navigation in the host.

Use the [Expo lifecycle guide](https://docs.expo.dev/brownfield/lifecycle-listeners/) and the installed delegate source to choose callbacks. Test the actual modules in use, including warm/cold deep links or push registration where applicable; a first-screen render does not verify these paths.

## Acceptance scenario

1. Build the existing host and record its native launch/navigation behavior before integration.
2. Build/select the matching Debug artifact (isolated), run Metro in the producer, and open the feature from the native host with identifiable input.
3. Confirm initial input on screen, then use **Refresh greeting** to verify a later native reply. Complete once: native receives one matching result and closes the feature.
4. Reopen with a fresh request ID and different input. Verify no stale result or duplicate callback. Cancel through both the RN button and native swipe/back dismissal; repeat.
5. Build/select the Release artifact and host Release configuration. Stop Metro, launch afresh, and repeat the interaction, including any bundled images/fonts. Check the original native screens and relevant lifecycle callbacks.

For runnable examples of both SwiftUI hosts, see the [iOS brownfield playgrounds](https://github.com/expo/skills/tree/main/tests/fixtures/expo-brownfield).

EAS Build/Submit can distribute the host after this integration works; they do not implement the runtime boundary. EAS Update requires an updates-enabled RN runtime and separate brownfield setup, not just an EAS project ID. Consult the [existing-native-app Update guide](https://docs.expo.dev/eas-update/integration-in-existing-native-apps/) if requested; use the chosen toolchain's setup for isolated artifacts. Updates cannot replace compiled Swift code or add a native module absent from the shipped binary.

Select the matching SDK/API using [version compatibility](./version-compatibility.md). Current reference: [Brownfield API](https://docs.expo.dev/versions/latest/sdk/brownfield/); implementation: [Expo SDK 57 brownfield](https://github.com/expo/expo/tree/sdk-57/packages/expo-brownfield).
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.

First inspect the failing build step and the dependency/configuration diff.

- **Integrated approach:** keep the hand-maintained host intact. Run `npx expo install --check` in the JS project, apply SDK-matched native template changes selectively, then run `bundle exec pod install` (or `pod install` without Bundler) in the host's Podfile directory. Open the `.xcworkspace`. Neither `prebuild` nor `prebuild --clean` is a recovery step for this host: [clean prebuild deletes native directories](https://docs.expo.dev/workflow/continuous-native-generation/#optionality).
- **Isolated approach:** rebuild the affected platform in the separate Expo producer, then replace the consumer's artifact and its accompanying dependencies. CNG regeneration belongs only in that producer, after checking that its native files are generated and reproducible.
- For stale iOS build products, clean the affected target's build folder/DerivedData. Preserve `Podfile.lock`; deleting it changes dependency resolution and can hide the cause. Reinstall pods only when the error points to the pod installation.
- For Android, clean the affected project's build outputs with its Gradle wrapper. Inspect publication coordinates and dependency resolution before removing a specific stale local Maven artifact; do not clear unrelated caches.

## 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.

- Inspect the actual output and generated `Package.swift`; the framework set depends on package version and source/prebuilt settings, not only the SDK major. Link all required binaries and embed/sign dynamic frameworks. Do not apply **Embed & Sign** to static binaries. See the [artifact instructions](./brownfield-isolated.md#ios).
- The frameworks must be added to the _app target_, not a framework or extension target.
- With Swift Package output (`build:ios --package`), inspect the manifest and link all required products. Precompiled builds on SDK 57 expose an aggregate product; other configurations and older packages can expose separate products. See [version compatibility](./version-compatibility.md).

## 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

First check the selected SDK's Node, Xcode, and minimum OS requirements in [version compatibility](./version-compatibility.md). An unsupported compiler or older deployment target is not repaired by clearing caches. 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.
- Isolated: regenerate only the CNG-owned producer if needed, rebuild its artifact, and update the host dependency. Integrated: apply native upgrade diffs to the existing host and reinstall pods; preserve its source files and project configuration.
- 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.

## Result missing, duplicate callbacks, or a sheet that will not close

- Check the module registration and per-presentation request ID. Root props need an explicit JS entry point; do not assume a Router route receives native `initialProps` directly.
- Attach the host listener before mounting RN and supply required startup data through initial props. For live updates, verify subscription readiness and use acknowledgements where delivery matters; messages are not a durable queue.
- Remove only this feature's listeners on completion, cancellation, and host dismissal. Dispatch UI changes to the main thread.
- `popToNative()` depends on the native wrapper: the SDK 55 UIKit controller pops navigation, and its SwiftUI wrapper separately calls `dismiss()`. Custom integrated containers need their own handler. Check which wrapper is actually mounted, or let the host close it on a result/close message. See [feature integration](./feature-integration.md).
references/version-compatibility.md
# Select versions before integrating

Keep the workflow version-aware rather than pinning every host to one SDK. Existing projects retain their SDK unless an upgrade is part of the task. New producers should use the current stable Expo release when the host and toolchain can support it.

## Inspect and select

1. Read the producer's `package.json` and lockfile; record resolved `expo`, `react-native`, and `expo-brownfield` versions. Check the host's deployment targets, Xcode/Node versions, and native dependency graph, including any existing RN runtime.
2. For new work, check the [Expo releases](https://expo.dev/changelog/) and [SDK compatibility table](https://docs.expo.dev/versions/latest/#each-expo-sdk-version-depends-on-a-react-native-version). `npm view expo dist-tags --json` can confirm the current stable `latest` version. Do not infer stability from the highest SDK number or select `canary`/`next` by default.
3. Select an SDK compatible with the host's supported OS versions, modules, and build tools. Do not silently raise the host's minimum OS or swap its toolchain to satisfy a tutorial. If a constraint conflicts, explain the concrete choices before changing that product requirement.
4. Use that SDK's versioned Brownfield API docs and `sdk-<major>` native template. Install modules with `npx expo install`; run `npx expo install --check` before native builds. For an SDK upgrade, use `expo-upgrade`, preserve the host, and apply native diffs selectively.
5. Inspect the **installed** CLI's `build:ios --help` / `build:android --help`, plugin schema, generated Swift/Kotlin wrappers, and `Package.swift`. Flags, prebuilt defaults, import names, and binary products can change within a major release.

To scaffold a small new feature after those checks:

```sh
npx create-expo-app@latest my-project --template blank@latest
cd my-project
npx expo install expo-brownfield typescript @types/react
```

This avoids adding a Router shell just to export one component. For an existing Router app, preserve it and add the root-props adapter described in [feature integration](./feature-integration.md). If selecting an older SDK, use a verified template tag such as `blank@sdk-55`; the scaffolder's own `@latest` version does not determine the template's SDK. Commit the producer's lockfile for repeatable builds.

## SDK requirements and build defaults

| Surface | SDK 55 | SDK 57 |
| --- | --- | --- |
| React Native family | 0.83 | 0.86 |
| iOS minimum in the Expo template | 15.1 | 16.4 |
| Documented minimum Node / Xcode | 20.19.x / 26.2 | 22.13.x / 26.4 |
| Brownfield React Native build default | Source | Prebuilt |
| Precompiled Expo modules | Version/configuration dependent | Enabled by default in the native template |
| Swift Package products | 55.0.28: separate feature and Hermes products | 57.0.18: one aggregate product when precompiled modules are detected; otherwise separate products |

React Native prebuilt binaries and precompiled Expo modules are separate settings. Set React Native source mode on `expo-brownfield`'s `ios.buildReactNativeFromSource`. Expo module precompilation is controlled by `expo-build-properties`' `ios.usePrecompiledModules`. Do not disable defaults as a generic build fix; first inspect the failing dependency and toolchain requirement.

In 57.0.18, precompiled builds suffix the generated package/product with its configuration (`MyAppPackage-release` or `MyAppPackage-debug`). They do not rename the generated Swift module: continue to import the configured target, such as `MyBrownfield`. Inspect the emitted manifest instead of constructing an assumed package path.

SDK 56 introduced additional brownfield capabilities carried into SDK 57, including experimental multiple isolated frameworks and registering host Turbo Module classes. Load the selected SDK's API and installed interfaces only when the task needs them. Do not enable experimental multi-framework support for a single feature or assume two independently packaged RN runtimes can be linked together without collision handling.

Sources: [SDK requirements](https://docs.expo.dev/versions/latest/), [SDK 57 Brownfield API](https://docs.expo.dev/versions/v57.0.0/sdk/brownfield/), [SDK 57 native template](https://github.com/expo/expo/tree/sdk-57/templates/expo-template-bare-minimum), [SDK 56 brownfield additions](https://expo.dev/changelog/sdk-56), [published Brownfield package](https://www.npmjs.com/package/expo-brownfield).
SKILL.md
---
name: expo-brownfield
description: Framework (OSS). Integrate Expo and React Native into an existing native iOS or Android app. Use for brownfield, embedding a React Native screen in SwiftUI/UIKit or Kotlin, or AAR/XCFramework packaging. Covers isolated and integrated approaches. For building or distributing a purely native app with EAS, use eas-app-stores.
---

# Expo Brownfield

A **brownfield** app is an existing native iOS or Android app that adopts React Native incrementally, as opposed to a **greenfield** app that is React Native from day one.

## Inspect the host first

Identify the existing app entry point, navigation owner, native build system, deployment targets, and any React Native runtime already linked. Record the installed Expo, React Native, and brownfield package versions from the lockfile. Adding EAS Build or Submit to a Swift app alone does not require React Native; route that task to `eas-app-stores`.

Preserve the host's SwiftUI `App` / UIKit window and native screens when embedding a feature. **Do not run prebuild in a manually maintained native host**, including during troubleshooting. An isolated Expo producer may use CNG; keep its generated `ios/` and `android/` separate from the consuming app.

Expo supports two distinct ways to add React Native to a brownfield project:

| Approach       | What ships to the native app                                        | When to choose                                                                   |
| -------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| **Isolated**   | Prebuilt AAR / XCFramework                                          | Native team doesn't need Node or RN tooling; RN code can live in a separate repo |
| **Integrated** | React Native sources added to the existing Gradle / CocoaPods build | One team owns everything; comfortable with RN tooling; wants a single build      |

For the full decision matrix, see [./references/comparison.md](./references/comparison.md).

## Pick an approach

Use these quick rules — fall through to `comparison.md` for anything ambiguous.

- **Choose isolated** if the iOS/Android team must consume RN as a regular library dependency (AAR or XCFramework), without installing Node, Yarn, or the React Native build toolchain.
- **Choose isolated** if RN code and native code live in separate repositories or release on independent cadences.
- **Choose integrated** if a single team owns both the native and RN code and is willing to add React Native + Expo to the native project's Gradle and CocoaPods setup.
- Both approaches support Metro and Fast Refresh in Debug. Choose integrated for shared build ownership, not because isolated lacks live JS iteration.

## References

- ./references/brownfield-isolated.md -- Build RN as AAR/XCFramework and consume from the native app (BrownfieldActivity, ReactNativeViewController, ReactNativeView)
- ./references/brownfield-integrated.md -- Add RN and Expo directly to existing Gradle and CocoaPods builds, preserving the native app shell
- ./references/feature-integration.md -- Pass input, return results, dismiss, clean up listeners, and forward lifecycle events; includes a SwiftUI host example
- ./references/comparison.md -- Decision criteria, trade-offs, and scenario mapping for choosing an approach
- ./references/troubleshooting.md -- Metro connection, build, signing, and module-resolution issues common to both approaches

More information available at https://docs.expo.dev/brownfield/overview/

## Shared prerequisites

Both approaches require, in the environment that _builds_ the React Native side:

- **Node.js (LTS)** — runs the Expo CLI and JavaScript code.
- The project's package manager and lockfile — npm, Yarn, pnpm, or Bun. Do not switch package managers just to follow an example.

The iOS build environment needs Xcode and CocoaPods (use the project's Gemfile/Bundler setup when present). The isolated consuming app needs Xcode but no CocoaPods or RN tooling just to consume the artifacts.

## Select compatible versions

For an existing Expo/RN project, keep its selected SDK and use `npx expo install` to align dependencies. Do not upgrade it just to follow this skill. For a new producer, use the **current stable SDK** compatible with the host's OS support, dependencies, and build toolchain; confirm the release is stable before selecting it.

Before native setup, read [./references/version-compatibility.md](./references/version-compatibility.md) for matching native templates, toolchain/OS requirements, and build defaults across SDK versions. A purely native consumer has no Expo SDK version to pin, but must satisfy the artifact's requirements.

## Verify the feature in the host

Open the RN screen with input, return a result to native, dismiss, and reopen with fresh input. Check listener cleanup and the host's original navigation. Then build the host in Release with a Release artifact and Metro stopped. Rendering only in Expo Go or the producer's example app does not validate the integration. See [./references/feature-integration.md](./references/feature-integration.md) for the complete acceptance scenario.

## Submitting Feedback
If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve:
```bash
npx --yes submit-expo-feedback@latest --category skills --subject "expo-brownfield" "<actionable feedback>"
```
Only submit when you have something specific and actionable to report. Include as much relevant context as possible.
If an AI agent repeatedly failed or the user had to take over an Expo task, load the expo-skill-feedback skill and follow its eval-candidate flow instead of reusing the command above.
expo-brownfield · Trendende Agent Skills | Mengbi