This documentation is available as Markdown for AI agents and LLMs. See the full Markdown index or append .md to any documentation URL.

Expo Brownfield

Toolkit and APIs for integrating Expo into existing native applications.

Android
iOS

expo-brownfield is a toolkit for adding React Native views to existing native Android and iOS applications. It provides:

  • Built-in APIs for bi-directional communication and navigation between native and React Native apps
  • Config plugin for automatic setup of brownfield targets in your Expo project
  • CLI for building and publishing artifacts to Maven repositories (Android) and XCFrameworks (iOS)

Installation

Terminal
- npx expo install expo-brownfield
- yarn expo install expo-brownfield
- pnpm expo install expo-brownfield
- bun expo install expo-brownfield

If you are installing this in an existing React Native app, make sure to install expo in your project.

Usage

Communication API

The Communication API enables bi-directional, message-based communication between the native (host) app and React Native.

Sending messages from React Native to native

import * as Brownfield from 'expo-brownfield'; Brownfield.sendMessage({ type: 'MyMessage', data: { language: 'TypeScript', expo: true, platforms: ['android', 'ios'], }, });

Receiving messages from native in React Native

import * as Brownfield, { type MessageEvent } from 'expo-brownfield'; import { useEffect } from 'react'; function MyComponent() { useEffect(() => { const handleMessage = (event: MessageEvent) => { console.log('Received message:', event); }; Brownfield.addMessageListener(handleMessage); return () => { Brownfield.removeMessageListener(handleMessage); }; }, []); // ... }

Sending messages from native to React Native

import expo.modules.brownfield.BrownfieldMessaging BrownfieldMessaging.sendMessage(mapOf( "type" to "MyAndroidMessage", "timestamp" to System.currentTimeMillis(), "data" to mapOf( "platform" to "android" ) ))

Receiving messages from React Native in native

import expo.modules.brownfield.BrownfieldMessaging val listenerId = BrownfieldMessaging.addListener { event -> println("Message from React Native: $event") } // Later, to remove the listener: BrownfieldMessaging.removeListener(listenerId)

Configuration in app config

The expo-brownfield package provides a config plugin that can be used to configure the brownfield integration when using Continuous Native Generation (CNG). This plugin allows you to customize how your Expo project is packaged and integrated into your existing native app.

Example app.json with config plugin

app.json
{ "expo": { "plugins": [ [ "expo-brownfield", { "ios": { "targetName": "MyBrownfieldTarget", "bundleIdentifier": "com.example.brownfield" }, "android": { "group": "com.example", "libraryName": "brownfield", "package": "com.example.brownfield", "version": "1.0.0" } } ] ] } }

Configurable properties

NameDefaultDescription
ios.targetName"<scheme>brownfield" or "<slug>brownfield"
Only for: 
iOS

Name of the Xcode target for the brownfield integration. This is used to create a separate target in your Xcode project for the React Native code.

ios.bundleIdentifier"<ios.bundleIdentifier base>.<targetName>" or "com.example.<targetName>"
Only for: 
iOS

Bundle identifier for the brownfield target. This should be unique and different from your main app bundle identifier.

ios.buildReactNativeFromSourcefalse
Only for: 
iOS

Build React Native from source instead of using prebuilt frameworks. Turning this on significantly increases the build times.

android.group"<package without last segment>"
Only for: 
Android

Maven group ID for the generated Android library. This is used when publishing the library to a Maven repository.

android.libraryName"brownfield"
Only for: 
Android

Name of the generated Android library module.

android.package"<android.package>.brownfield" or "com.example.brownfield"
Only for: 
Android

Java/Kotlin package name for the generated Android library code.

android.version"1.0.0"
Only for: 
Android

Version string for the generated Android library. This is used when publishing to a Maven repository.

android.publishing[{ type: "localMaven" }]
Only for: 
Android

Publishing configuration for the generated Android library. Supports localMaven, localDirectory, remotePublic, and remotePrivate publication types. Each type has different configuration options for specifying where and how the library is published.

Publishing artifacts

The expo-brownfield CLI pushes Android artifacts to one or more Maven repositories, declared via android.publishing in your app config. The supported repository types are:

typeDescription
localMavenPublishes to ~/.m2/repository. Default. Useful for local host-app integration during development.
localDirectoryPublishes to a local filesystem path (path field). Useful for checking artifacts into a sibling git repository.
remotePublicPublishes to a public Maven repository with no authentication.
remotePrivatePublishes to an authenticated remote Maven repository. Credentials can be inline strings or environment-variable references.

url, username, and password on remotePrivate accept either a plain string or an EnvValue object ({ "variable": "SOME_ENV_VAR" }) that resolves the value at build time via Gradle's providers.environmentVariable(...). Pin secrets to env vars so they don't end up in your committed app config.

Publishing to GitHub Packages

GitHub Packages hosts a private Maven repository scoped to a GitHub repo. Authentication uses GITHUB_ACTOR (the user or bot name) and a personal access token (PAT) with the write:packages and read:packages scopes. In GitHub Actions, the workflow's GITHUB_TOKEN already has the right scopes for the workflow's own repo.

Add a remotePrivate entry to android.publishing pointing at the GitHub Packages URL for your test repository:

app.json
{ "expo": { "plugins": [ [ "expo-brownfield", { "android": { "group": "com.example", "libraryName": "brownfield", "publishing": [ { "type": "localMaven" }, { "type": "remotePrivate", "name": "GitHubPackages", "url": "https://maven.pkg.github.com/<owner>/<repo>", "username": { "variable": "GITHUB_ACTOR" }, "password": { "variable": "GITHUB_TOKEN" } } ] } } ] ] } }

The name field controls both the Gradle Maven repo name and the --repo CLI flag value. With "name": "GitHubPackages", the release publish task becomes publishBrownfieldReleasePublicationToGitHubPackagesRepository (and publishBrownfieldDebugPublicationToGitHubPackagesRepository for the debug sibling). The default invocation runs both:

Terminal
- npx expo-brownfield build:android --fused --repo GitHubPackages

Publishing only the fused artifacts

The non-fused publish flow emits one Maven coordinate per autolinked Expo Android module (typically 20 to 40 artifacts per release). To publish a small, curated set instead, use --fused. The fused mode short-circuits the publish plugin's per-module re-publish loop and only emits the fat AAR sibling(s). A repository (--repo) or an explicit task (-t) is always required, as there is no default:

InvocationCoordinates published
--fused --release --repo <name><group>:<libraryName>-fused-release:<version>
--fused --debug --repo <name><group>:<libraryName>-fused-debug:<version>
--fused --all --repo <name> (default with --fused)Both of the above (two Gradle invocations under the hood, one per variant)

That's at most two Maven coordinates published per release, independent of how many Expo modules your project autolinks. Everything else (per-module AARs, transitive Maven deps) stays out of your remote repo. Foundational libraries (AndroidX, RN runtime, Kotlin stdlib, Glide alternatives the host already has) stay external in the POM rather than fusing into the AAR.

Fused mode

--fused publishes through two extra Gradle subprojects that prebuild always generates — :<libraryName>-fused-release and :<libraryName>-fused-debug — each producing one fat AAR via AGP's Fused Library plugin (a Preview feature; the CLI temporarily forces AGP 8.13 for these builds). The subprojects are inert during normal builds: their build scripts only activate when the CLI passes -Pbrownfield.fused=true, so npx expo run:android and IDE sync pay no extra configuration cost. If you invoke a fused Gradle task directly instead of through the CLI, pass -Pbrownfield.fused=true yourself.

Not everything is fused into the AAR. The build keeps three kinds of dependencies external and declares them as ordinary Maven dependencies in the published POM and Gradle Module Metadata, where the host app resolves them itself:

  • The brownfield host baseline — the React Native runtime (react-android, hermes-android, fbjni, soloader, yoga), the Kotlin stdlib, and host-provided commons (Material Components, Guava, Fresco, OkHttp, Okio). Fusing these would duplicate classes the host already ships.
  • androidx.* libraries — AGP Fused Library's class rewriter can't resolve android: framework attributes in their styleables, so all of AndroidX stays external except chains that must be fused for validation reasons (androidx.camera, androidx.media3 by default).
  • Auto-detected KMP umbrella modules — pom-only coordinates whose variants all redirect to a platform module.

The published metadata also annotates the react-android and hermes-android dependency edges with the sibling's build type, so a host app always resolves the same React Native variant the fused AAR's native libraries were linked against, even when a debug host consumes the release AAR.

Five Gradle properties tune this behavior when a project's dependency graph needs it:

PropertyEffect
brownfield.fused.skipComma-separated Gradle project names to leave out of the fat AAR entirely.
brownfield.fused.strip-packagesComma-separated package prefixes to remove from the generated ExpoModulesPackageList. Pair with skip to avoid NoClassDefFoundError for skipped modules at startup.
brownfield.fused.androidx-fuseExtra androidx.* group prefixes to fuse into the AAR instead of keeping external.
brownfield.fused.exclude-transitiveExtra dependency groups to keep external (declared in the POM instead of fused).
brownfield.fused.host-providedDependency groups the host app already ships (Glide, Compose, and so on). Kept out of the AAR like exclude-transitive, but also not declared in the POM, so the host's own version is untouched.

Pass them as -P Gradle properties when invoking a fused publish task directly from the android directory:

Terminal
- ./gradlew :brownfield-fused-release:publishBrownfieldReleasePublicationToMavenLocal \
-Pbrownfield.fused=true \ -Pbrownfield.fused.skip=expo-camera \ -Pbrownfield.fused.strip-packages=expo.modules.camera.

Sample GitHub Actions workflow

.github/workflows/publish-brownfield.yml
name: Publish brownfield fused AAR on: push: tags: - 'brownfield-v*' workflow_dispatch: permissions: contents: read packages: write jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - uses: actions/setup-java@v4 with: distribution: temurin java-version: 17 - run: npm ci - run: npx expo prebuild --platform android - run: npx expo-brownfield build:android --fused --release --repo GitHubPackages env: GITHUB_ACTOR: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Trigger via git tag brownfield-v1.0.0 && git push --tags (or manually via the Actions tab). The workflow's built-in GITHUB_TOKEN is scoped to the workflow's own repo, so publishing to a third-party repo requires a PAT in a repo secret instead.

Consumer setup (host Android app)

The host app's settings.gradle.kts (or per-module build.gradle.kts) declares the same GitHub Packages URL and credentials. The host app's CI/local builds must have the GITHUB_ACTOR and GITHUB_TOKEN env vars set or pulled from ~/.gradle/gradle.properties.

android/settings.gradle.kts
dependencyResolutionManagement { repositories { google() mavenCentral() maven { url = uri("https://maven.pkg.github.com/<owner>/<repo>") credentials { username = providers.environmentVariable("GITHUB_ACTOR").orNull ?: providers.gradleProperty("gpr.user").orNull password = providers.environmentVariable("GITHUB_TOKEN").orNull ?: providers.gradleProperty("gpr.token").orNull } } } }
android/app/build.gradle.kts
dependencies { releaseImplementation("com.example:brownfield-fused-release:1.0.0") debugImplementation("com.example:brownfield-fused-debug:1.0.0") }

releaseImplementation/debugImplementation pair the variants automatically. Gradle picks the matching sibling per host build type. If your host only ships release builds, you can drop the debugImplementation line and publish only the release sibling with --fused --release.

A few host app requirements and tips:

  • minSdk must be at least 24 (React Native's floor). Hosts targeting a lower API level fail at manifest merge when they consume the AAR.
  • Permissions merge in from the fused modules. For example, media-related Expo modules declare storage permissions. If your host enforces a permission allowlist, strip unwanted entries in the host manifest with tools:node="remove" (or reconcile attribute conflicts with tools:replace).
  • The AAR ships native libraries for every ABI enabled at publish time. Without filtering, a host APK grows by all four ABIs' worth of React Native libraries. Constrain ABIs when publishing (reactNativeArchitectures=arm64-v8a in the Expo project's gradle.properties) or filter in the host with ndk.abiFilters/APK splits.

CLI

The expo-brownfield library includes a CLI for building and publishing to Maven repositories (Android) and XCFrameworks (iOS).

Terminal
- npx expo-brownfield [command] [options]

Commands

build:android

Builds and publishes the brownfield library and its dependencies to Maven repositories.

Terminal
- npx expo-brownfield build:android [options]
OptionDescription
-d, --debugBuild in debug mode
-r, --releaseBuild in release mode
-a, --allBuild in both debug and release mode (default)
--fusedPublish a single fat AAR per variant via AGP Fused Library. See Fused mode
-l, --librarySpecify brownfield library name
--repo, --repositorySpecify Maven repositories to publish to
-t, --taskSpecify Gradle publish tasks to run
--verboseInclude all logs from subprocesses

build:ios

Builds the brownfield XCFramework and copies the Hermes XCFramework to the artifacts directory.

Terminal
- npx expo-brownfield build:ios [options]
OptionDescription
-d, --debugBuild in debug mode
-r, --releaseBuild in release mode (default)
-a, --artifactsPath to the artifacts directory (default: ./artifacts)
-s, --schemeXcode scheme to build
-x, --xcworkspaceXcode workspace path
-p, --packageShip artifacts as Swift Package (with optional name)
--verboseInclude all logs from subprocesses

tasks:android

Lists all available publish tasks and Maven repositories.

Terminal
- npx expo-brownfield tasks:android

API

import * as Brownfield from 'expo-brownfield';

No API data file found, sorry!