Embedding React Native in a Flutter app as an Android AAR A developer shipped a React Native journey inside an existing Flutter Android app by publishing the React Native UI, Expo modules, JavaScript bundle, and their native libraries as a single fused Android AAR. The Flutter app consumes the artifact as a Maven dependency and launches a second Activity that subclasses BrownfieldActivity, keeping Flutter as the launcher without adding the React Native Gradle plugin or Metro to the host build. The approach is Android-only, with the iOS Flutter runner unable to load React Native. I know this is a stupid idea but here it goes. This is the write-up of how I shipped a React Native journey inside an existing Flutter Android app, without turning the Flutter project into a React Native app. Flutter stays the launcher. React Native is a second Activity. The React Native UI, its Expo modules, its JavaScript bundle, and the native libraries those modules need are published as one fused Android AAR. The Flutter app depends on that AAR the same way it would depend on any other Maven artifact. This is Android-only. The Flutter iOS runner does not load React Native. If you call the method channel on iOS it will fail, and that is called out below. Figure 1. Flutter host opening a React Native Activity from a fused AAR. flowchart LR subgraph plugin hybrid bridge plugin Fuse ":hybridbridge-fused-release" end subgraph host hybrid bridge shell Maven "android/app/libs/hybridbridge" Main MainActivity Hybrid HybridBridgeActivity Frag "React Native fragment" Main -- |"MethodChannel open"| Hybrid Maven -- Hybrid Hybrid -- |"showReactNativeFragment "| Frag end Fuse -- |"publish fused AAR"| Maven Frag -- Bundle "assets/index.android.bundle" Two projects sit next to each other: | Project | Role | |---|---| | hybrid bridge plugin | Expo app that also builds a brownfield Android library and publishes a fused AAR | | hybrid bridge shell | Flutter app that consumes com.hybridbridge:hybridbridge-fused-release:1.0.0 | Pressing Open Hybrid Bridge in Flutter starts HybridBridgeActivity . That activity subclasses BrownfieldActivity from the AAR, passes a JSON config in, and shows the Expo Router app. The React Native screen can send a result back. Flutter shows that result in a SnackBar . flowchart TB subgraph plugin hybrid bridge plugin JS Expo Router screens App ":app export:embed" Lib ":hybridbridge library" Fat ":hybridbridge-fused-release" JS -- App App -- |copyHostAppAssetsRelease| Lib Lib -- Fat end subgraph host hybrid bridge shell Maven "app/libs/hybridbridge" Dart "MethodChannel hybrid bridge shell/sdk" Act HybridBridgeActivity Maven -- Act Dart -- Act end Fat -- |local Maven publish| Maven Act -- RN "ReactHost plus assets/index.android.bundle" These are the versions in the tree, not a generic "latest" guess. If you bump one of them, re-read the fused-library and new-architecture notes before you assume the same Gradle rules still apply. | Piece | Version / flag | |---|---| | React Native | 0.86.3 | | React | 19.2.3 | | Expo SDK | ~57.0.24 | | expo-brownfield | ~57.0.22 | | Expo Router entry | expo-router/entry , root component name main | | Hermes | hermesEnabled=true | | New Architecture | newArchEnabled=true | | Library minSdk | 24 | | Library compileSdk | 36 | | Fused Library plugin | AGP fused-library preview. The build forces Android Gradle Plugin 8.13.0 only when --fused is on | | Flutter host Android Gradle Plugin | 9.0.1 | | Flutter host Kotlin | 2.3.20 | | Host compileSdk | maxOf flutter.compileSdkVersion, 36 | | Host minSdk | maxOf flutter.minSdkVersion, 24 | | Host Java / Kotlin target | 17 | I did not use Flipper. I did not add the React Native Gradle plugin to the Flutter app. I wanted the Flutter team to integrate React Native the way they integrate any other Android SDK: a Maven coordinate, a repository, and a small Activity. I did not want them to clone node modules , run Metro, or apply com.facebook.react inside the Flutter Gradle build. expo-brownfield already knows how to turn an Expo app into an Android library that a native host can open. That library is the thin AAR com.hybridbridge:hybridbridge:1.0.0 . The thin AAR is enough only if the host can still resolve every autolinked native module Reanimated, Screens, Gesture Handler, Expo modules, and their .so files on its own classpath. A Flutter app cannot do that. It does not run React Native autolinking. The fused AAR is the one I ship. com.android.fused-library merges the brownfield library plus the autolinked Android modules into a single AAR. JavaScript and module native libraries go inside the AAR. The React Native runtime stays outside the AAR, as normal Maven dependencies, because those artifacts are variant-specific debug versus release .so files and fusing them either duplicates classes or pins the wrong native build. What goes where: | Inside the fused AAR | Left as Maven dependencies of the AAR | |---|---| | Brownfield classes com.hybridbridge.plugin. | com.facebook.react:react-android | | Expo module classes that were autolinked | com.facebook.hermes:hermes-android | | assets/index.android.bundle and assets/app.config | fbjni , SoLoader, Yoga | | Module .so files Reanimated, Worklets, Screens, Gesture Handler, expo-modules-core, codegen | Kotlin stdlib, OkHttp, Fresco, Material | Material stays outside on purpose. The fused-library class rewriter cannot resolve framework attributes such as AppBarLayout android background , and the fuse fails in rewriteClasses if Material is pulled in. The Flutter host never applies com.facebook.react . It only implements the fused coordinate. Gradle pulls react-android and hermes-android transitively from the published POM. hybrid bridge plugin/ Expo app and AAR producer app.json expo-brownfield Maven coordinates package.json npm run aar / npm run fat-aar src/app/ Expo Router screens android/ settings.gradle :app, :hybridbridge, fused siblings build.gradle publish URL app/ standalone Expo application expo run:android hybridbridge/ Android library module src/main/java/com/hybridbridge/plugin/ BrownfieldActivity.kt ReactNativeHostManager.kt ReactNativeFragment.kt ReactNativeViewFactory.kt hybridbridge-fused-release/ fat AAR, release variant hybridbridge-fused-debug/ fat AAR, debug variant hybrid bridge shell/ Flutter host lib/main.dart MethodChannel android/ build.gradle.kts local Maven repository app/build.gradle.kts dependency + variant rules app/libs/hybridbridge/ published AAR, POM, and module metadata app/src/main/kotlin/com/example/hybrid bridge shell/ MainActivity.kt HybridBridgeActivity.kt android/ inside the Expo project is generated by expo prebuild and is gitignored in this repo. I still keep it on disk because the AAR build runs from it. If you delete it, expo-brownfield will offer to prebuild again. After a prebuild, confirm the publish path and the package name still match this document. A config plugin sync can rewrite android/build.gradle from app.json . If you copy this setup, keep these strings aligned. A mismatch between the shared-state key, the Maven group, or the Gradle module name fails in a different layer each time, and the error rarely says "you renamed one side". | Kind | Value | |---|---| | Expo package name | hybrid bridge plugin | | Standalone Android application id | com.anonymous.hybrid bridge plugin | | URL scheme | hybridbridgeplugin | | Maven group | com.hybridbridge | | Library module | :hybridbridge | | Java package of the library | com.hybridbridge.plugin | | Fused artifact | com.hybridbridge:hybridbridge-fused-release:1.0.0 | | Fused modules | :hybridbridge-fused-release , :hybridbridge-fused-debug | | Flutter package | hybrid bridge shell | | Flutter Android namespace / applicationId | com.example.hybrid bridge shell | | Method channel | hybrid bridge shell/sdk | | Shared state key | hybridbridge.config | | Host activity | HybridBridgeActivity | | Local Maven directory | hybrid bridge shell/android/app/libs/hybridbridge | The publish path in app.json is relative ../hybrid bridge shell/... so the two folders can live anywhere as siblings. Do not hard-code a machine-specific absolute path. I did that first, and the next laptop could not publish. From hybrid bridge plugin : npm install expo-brownfield@~57.0.22 The version should match the Expo SDK. This project uses Expo 57, so the brownfield package is 57 as well. This block is the source of truth. expo prebuild and expo-brownfield read it when they generate the library module, the fused modules, and the Maven publication. "plugins": "expo-router", "expo-splash-screen", { "backgroundColor": " 208AEF", "image": "./assets/images/splash-icon.png", "imageWidth": 76 } - + , + + "expo-brownfield", + { + "android": { + "group": "com.hybridbridge", + "libraryName": "hybridbridge", + "package": "com.hybridbridge.plugin", + "version": "1.0.0", + "publishing": + { + "type": "localDirectory", + "name": "shellLibs", + "path": "../hybrid bridge shell/android/app/libs/hybridbridge" + } + + } + } + , libraryName becomes the Gradle project name :hybridbridge and the thin artifact id. package is the Java/Kotlin namespace. group + artifact + version is what the Flutter app will implementation ... . name: "shellLibs" becomes the Gradle repository name inside the publish plugin. The npm scripts call the task publishBrownfieldReleasePublicationToShellLibsRepository . If you rename shellLibs , those task names change and npm run fat-aar will not find the task. The relative path is resolved from the Expo project root. Sibling layout: parent/ hybrid bridge plugin/ hybrid bridge shell/ If android/ does not exist yet: npx expo prebuild --platform android After prebuild you should see: android/settings.gradle includes them: rootProject.name = 'hybrid bridge plugin' include ':app' +include ':hybridbridge' +include ':hybridbridge-fused-release' +include ':hybridbridge-fused-debug' The fused modules are inert unless you pass -Pbrownfield.fused=true . expo run:android does not build the fat AAR. That matters, because fused-library configuration is slow and it is still a preview plugin. android/hybridbridge/build.gradle.kts is a library, not an application. The plugins are the whole trick: Android library + Kotlin + React Native + the brownfield setup plugin. plugins { id "com.android.library" id "org.jetbrains.kotlin.android" id "com.facebook.react" id "expo-brownfield-setup" } group = "com.hybridbridge" version = "1.0.0" react { autolinkLibrariesWithApp } android { namespace = "com.hybridbridge.plugin" compileSdk = 36 buildFeatures { buildConfig = true } defaultConfig { minSdk = 24 consumerProguardFiles "consumer-rules.pro" buildConfigField "boolean", "IS NEW ARCHITECTURE ENABLED", properties "newArchEnabled" .toString buildConfigField "boolean", "IS HERMES ENABLED", properties "hermesEnabled" .toString buildConfigField "String", "REACT NATIVE RELEASE LEVEL", "\"${findProperty "reactNativeReleaseLevel" ?: "stable"}\"", buildConfigField "boolean", "IS EDGE TO EDGE ENABLED", "true" } } dependencies { api "com.facebook.react:react-android" api "com.facebook.hermes:hermes-android" compileOnly "androidx.fragment:fragment-ktx:1.6.1" } api instead of implementation is intentional. Consumers of the thin AAR need react-android and Hermes on their compile and runtime classpaths. The fused publication keeps those as POM dependencies rather than shading them into the AAR. compileOnly on fragment-ktx avoids shipping a second copy of AndroidX Fragment. The host already has it. The library still compiles against the fragment APIs it uses to commit ReactNativeFragment . autolinkLibrariesWithApp points codegen and native autolinking at the :app project. The library does not have its own package.json . The Expo app does. minSdk 24 is a hard floor. I raised the Flutter app to the same floor. If the host stays lower, the manifest merger fails because the AAR metadata requires 24. A release brownfield build cannot download JavaScript from Metro. ReactNativeHostManager checks that index.android.bundle is in the Android assets and throws if it is missing. Getting that file into the AAR is a three-step pipeline. Step 1. The standalone :app module bundles with Expo CLI, not the stock React Native CLI. In android/app/build.gradle : react { - // bundleCommand = "bundle" + cliFile = new File "node", "--print", "require.resolve '@expo/cli', { paths: require.resolve 'expo/package.json' } " .execute null, rootDir .text.trim + bundleCommand = "export:embed" } export:embed is what makes Expo Router, app.config , and asset hashing match a normal Expo release build. If you leave the default bundle command, the embedded app can miss Expo's generated assets. Step 2. :app:mergeReleaseAssets collects the JS bundle and the other release assets. Step 3. expo-brownfield-setup registers copyHostAppAssetsRelease . That task depends on mergeReleaseAssets , copies the merged output into hybridbridge/build/generated/assets/hostApp/release/ , and adds that directory as the library release assets source set. preReleaseBuild depends on the copy, so you do not have to call the task yourself. The same plugin also copies selected application