How to Integrate React Native With an Existing App

While there’s plenty of theory surrounding brownfield development, practical examples are harder to find. To bridge this gap, we sought to carry out the integration in a practical setting, looking for open-source projects where we could apply brownfield migration to React Native. We chose the Mastodon app, given its availability and decentralized architecture, as our testing ground for integrating React Native into its mobile apps on both iOS and Android.

A Step-by-Step Guide to the React Native Integration

We aimed to understand how React Native 0.76 could be incorporated into the native iOS and Android apps, providing insights on how to deal with real-world integration challenges.

To explore the possibilities of brownfield integration with the latest React Native, we began by working on Mastodon’s open-source iOS app, written in Swift. The goal was to merge React Native with the native code without rebuilding the app entirely, ensuring that both platforms (iOS and Android) would be compatible with this cross-platform solution.

Fork and Set Up the Projects First

We started by creating a new repository and forking the Mastodon iOS and Android projects. The next step involved linking the fork with our React Native project using git submodules. This allowed us to manage the projects as a sub-repository.

[submodule "ios"]
path = ios
url = git@github.com:TMisiukiewicz/mastodon-ios.git
branch = v-0.76
[submodule "android"]
path = android
url = https://github.com/TMisiukiewicz/mastodon-android
branch = v-0.76
view raw .gitmodules hosted with ❤ by GitHub

Migrating a Brownfield iOS App to React Native

Step 1: Create package.json 

In the root of the project, we need a package.json file. The easiest way to create the file is to follow the steps outlined in the official React Native documentation on integration with existing apps. This approach will help ensure the correct versions of React Native and React will be installed. 

curl -O https://raw.githubusercontent.com/react-native-community/template/refs/heads/<version>-stable/template/package.json

Replace <react-native-version> with the version you want to integrate with your application and run the command.

Step 2: Modify the Podfile 

If a Podfile doesn't exist in your iOS project, run pod init. Then, locate the appropriate Podfile for your React Native version and copy the necessary content into your existing one. This step ensures the proper linkage of React Native’s dependencies. Remember to adjust the minimum deployment target of your app according to the version from the React Native template. You can find the corresponding version in the React Native repository (min_ios_version_supported).

+require Pod::Executable.execute_command('node', ['-p',
+ 'require.resolve(
+ "react-native/scripts/react_native_pods.rb",
+ {paths: [process.argv[1]]},
+ )', __dir__]).strip
+
source 'https://cdn.cocoapods.org/'
-platform :ios, '15.0'
+platform :ios, '15.1'
inhibit_all_warnings!
+config = nil
+
target 'Mastodon' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
# Pods for Mastodon
+ config = use_native_modules!
+
+ puts "config: #{config}"
+
+ use_react_native!(
+ :path => config[:reactNativePath],
+ # An absolute path to your application root.
+ :app_path => "#{Pod::Config.instance.installation_root}/.."
+ )
# UI
pod 'XLPagerTabStrip', '~> 9.0.0'
@@ -32,6 +49,13 @@ target 'Mastodon' do
end
post_install do |installer|
+ react_native_post_install(
+ installer,
+ config[:reactNativePath],
+ :mac_catalyst_enabled => false,
+ # :ccache_enabled => true
+ )
view raw Podfile.diff hosted with ❤ by GitHub

Step 3: Install Pods

Once the Podfile is updated, run pod install to install the required dependencies for React Native. With this done, you're ready to move forward with the integration.

Step 4: Add the React Native Component 

Create an index.js file with a basic React Native component using AppRegistry.registerComponent. The registered component name will be used later for navigation.

Step 5: Modify AppDelegate.swift 

Since iOS blocks HTTP resource loading by default, update the Info.plist file to allow communication with the Metro server. In AppDelegate.swift, start with inheriting from RCTAppDelegate and setting automaticallyLoadReactNativeWindow to false. It will instruct React Native that the app is handling the UIWindow. You should also override all the application functions.

The last step here is adding sourceURL and bundleURL methods used to tell React Native where it can find the JS bundle that needs to be rendered. Declare the window property to manage and display the React Native view.

Info.plist diff:

<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
+ <key>NSAppTransportSecurity</key>
+ <dict>
+ <key>NSExceptionDomains</key>
+ <dict>
+ <key>localhost</key>
+ <dict>
+ <key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
+ <true/>
+ </dict>
+ </dict>
+ </dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
view raw Infoplist.diff hosted with ❤ by GitHub

AppDelegate.swift:

import UserNotifications
import AVFoundation
import MastodonCore
import MastodonUI
+import React
+import React_RCTAppDelegate
@main
-class AppDelegate: UIResponder, UIApplicationDelegate {
+class AppDelegate: RCTAppDelegate {
let appContext = AppContext()
- func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
AppSecret.default.register()
@@ -40,31 +42,45 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
count += 1 // Int64. could ignore overflow here
UserDefaults.shared.processCompletedCount = count
- return true
+ self.automaticallyLoadReactNativeWindow = false;
+
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// MARK: UISceneSession Lifecycle
+
+ override func sourceURL(for bridge: RCTBridge) -> URL? {
+ self.bundleURL()
+ }
+
+ override func bundleURL() -> URL? {
+ #if DEBUG
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
+ #else
+ Bundle.main.url(forResource: "main", withExtension: "jsbundle")
+ #endif
+ }
- func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+ override func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
- func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
+ override func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
- func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
+ override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
return true
}
}
extension AppDelegate {
- func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
appContext.notificationService.deviceToken.value = deviceToken
}
}
@@ -97,7 +113,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
// notification present in the background (or resume from background)
- func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult {
+ override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult {
let shortcutItems = try? await appContext.notificationService.unreadApplicationShortcutItems()
UIApplication.shared.shortcutItems = shortcutItems
return .noData

Step 6: Create ReactNativeView class

Next, create a ReactNativeView class that uses RootViewFactory to create React Native views. You can read more about it in our dedicated article on brownfield integration with RootViewFactory. 

+import UIKit
+import React_RCTAppDelegate
+
+class ReactNativeView: UIViewController {
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ let factory = (RCTSharedApplication()?.delegate as? RCTAppDelegate)?.rootViewFactory
+ self.view = factory?.view(withModuleName: "ReactNativeScreen")
+ }
+}

Step 7: Link the Button to the React Native Component 

Find the method triggered by pressing the “Join http://mastodon.social” button and replace its content with code to navigate to your React Native component. Make sure the module name matches the one registered in index.js.

import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonSDK
+import React
+import React_RCTAppDelegate
final class WelcomeViewController: UIViewController, NeedsDependency {
private enum Constants {
static let topAnchorInset: CGFloat = 20
}
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
+ var reactNativeView: ReactNativeView?
private(set) lazy var authenticationViewModel = AuthenticationViewModel(
context: context,
@@ -275,109 +277,9 @@ extension WelcomeViewController {
//MARK: - Actions
@objc
private func joinDefaultServer(_ sender: UIButton) {
+ let reactNativeView = ReactNativeView()
+ self.navigationController?.pushViewController(reactNativeView, animated: true);
}

Migrating a Brownfield Android App to React Native

With the iOS integration complete, the next step was to repeat the process for the Android version of the Mastodon app.

Step 1: Align the project’s build gradle with the React Native template

Let’s start with aligning the compileSdk, minSdk, ndk and buildToolsVersion to match the React Native template. To find out which versions you need, go to libs.versions.toml file in the React Native repository. We should also use autolinkLibrariesWithApp in app/build.gradle.

Step 2: Add React Native Gradle Plugin 

In the top-level build.gradle, add the React Native Gradle Plugin dependency to make it available throughout the project.

dependencies {
- classpath "com.android.tools.build:gradle:7.4.2"
+ classpath "com.android.tools.build:gradle"
classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1"
+ classpath "com.facebook.react:react-native-gradle-plugin"

Step 3: Modify settings.gradle 

To configure dependencies and support React Native's autolinking feature, update settings.gradle by adding the React Native Gradle plugin. Sync the project with the Gradle files.

+pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
+plugins { id("com.facebook.react.settings") }
+extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
+
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
@@ -8,3 +11,5 @@ dependencyResolutionManagement {
}
rootProject.name = "Mastodon"
include ':mastodon'
+
+includeBuild('../node_modules/@react-native/gradle-plugin')

Step 4: Update AndroidManifest.xml 

Ensure the AndroidManifest includes the required permissions for Internet access and Metro server communication. Add the usesCleartextTraffic attribute and the Dev Settings Activity. It’s also important to add a new Activity with a name that is equivalent to the class name you’ll create in the next step.

You also need to set the theme of MyReactActivity to Theme.AppCompat.Light.NoActionBar (or to any non-ActionBar theme) as otherwise your application will render an ActionBar on top of your React Native screen.

<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
+ <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature"/>
@@ -29,8 +30,8 @@
android:localeConfig="@xml/locales_config"
android:icon="@mipmap/ic_launcher"
android:theme="@style/Theme.Mastodon.AutoLightDark"
+ android:usesCleartextTraffic="true"
android:largeHeap="true">
-
<activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize" android:windowSoftInputMode="adjustResize" android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -69,7 +70,11 @@
<data android:mimeType="*/*"/>
</intent-filter>
</activity>
-
+ <activity
+ android:name=".MyReactActivity"
+ android:theme="@style/Theme.AppCompat.Light.NoActionBar"
+ android:label="@string/app_name">
+ </activity>
<service android:name=".AudioPlayerService" android:foregroundServiceType="mediaPlayback"/>
<receiver android:name=".PushNotificationReceiver" android:exported="true" android:permission="com.google.android.c2dm.permission.SEND">

Step 5: Modify the MainApplication

Update the class to integrate React Native functionality by implementing the ReactApplication interface. You’ll also need to add a ReactNativeHost, which is responsible for managing the React Native instance lifecycle, along with a few additional methods. Additionally, pass OpenSourceMergedSoMapping as a second parameter to SoLoader.init() function. It’s needed because starting from 0.76, React Native introduces libreactnative.so, which merges multiple dynamic libraries into one library, reducing app size and startup time. If you are upgrading to lower version, you don’t have to do that.

...
+import com.facebook.react.PackageList;
+import com.facebook.react.ReactApplication;
+import com.facebook.react.ReactHost;
+import com.facebook.react.ReactNativeHost;
+import com.facebook.react.ReactPackage;
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
+import com.facebook.react.defaults.DefaultReactHost;
+import com.facebook.react.defaults.DefaultReactNativeHost;
+import com.facebook.react.soloader.OpenSourceMergedSoMapping;
+import com.facebook.soloader.ExternalSoMapping;
+import com.facebook.soloader.SoLoader;
+import java.io.IOException;
+import java.util.List;
-public class MastodonApp extends Application{
+public class MastodonApp extends Application implements ReactApplication {
+
+ @Override
+ public ReactNativeHost getReactNativeHost(){
+ return new DefaultReactNativeHost(this){
+ @Override
+ protected List<ReactPackage> getPackages(){
+ return new PackageList(this).getPackages();
+ }
+
+ @Override
+ protected String getJSMainModuleName(){
+ return "index";
+ }
+
+ @Override
+ public boolean getUseDeveloperSupport(){
+ return BuildConfig.DEBUG;
+ }
+
+ @Override
+ protected boolean isNewArchEnabled(){
+ return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
+ }
+
+ @Override
+ protected Boolean isHermesEnabled(){
+ return BuildConfig.IS_HERMES_ENABLED;
+ }
+ };
+ }
+
+ @Override
+ public ReactHost getReactHost() {
+ return DefaultReactHost.getDefaultReactHost(getApplicationContext(), getReactNativeHost());
+ }
...
public class MastodonApp extends Application{
@Override
public void onCreate(){
super.onCreate();
+ try {
+ ExternalSoMapping externalSoMapping = OpenSourceMergedSoMapping.INSTANCE;
+ SoLoader.init(this, externalSoMapping);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
+ DefaultNewArchitectureEntryPoint.load();
+ }
context=getApplicationContext();
V.setApplicationContext(context);
ImageCache.Parameters params=new ImageCache.Parameters();

Step 6: Create React Activity 

Create a new ReactActivity class that hosts the React Native code. It’s responsible for starting a new React Native runtime and rendering the component. This activity should use the component name registered in your index.js file. After adding the activity to the AndroidManifest, sync the project with Gradle:

// package <your-package-here>;
import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
public class MyReactActivity extends ReactActivity {
@Override
protected String getMainComponentName() {
return "ReactNativeScreen";
}
@Override
protected ReactActivityDelegate createReactActivityDelegate() {
return new DefaultReactActivityDelegate(this, getMainComponentName(), DefaultNewArchitectureEntryPoint.getFabricEnabled());
}
}

Step 7: Link the Button to the React Native Component 

Similarly to iOS, find the method tied to the “Join http://mastodon.social” button and overwrite it to navigate to the React Native screen. This completes the integration on Android.

package org.joinmastodon.android.fragments;
+import android.content.Intent;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.view.LayoutInflater;
@@ -11,6 +12,7 @@ import android.widget.ProgressBar;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
+import org.joinmastodon.android.MyReactActivity;
import org.joinmastodon.android.api.requests.catalog.GetCatalogDefaultInstances;
import org.joinmastodon.android.api.requests.instance.GetInstance;
import org.joinmastodon.android.fragments.onboarding.InstanceCatalogSignupFragment;
@@ -113,36 +115,8 @@ public class SplashFragment extends AppKitFragment{
}
private void onJoinDefaultServerClick(View v){
+ Intent intent = new Intent(getActivity(), MyReactActivity.class);
+ startActivity(intent);
}

Step 8: Enable the New Architecture

To enable New Architecture, add the following lines to the gradle.properties file:

+newArchEnabled=true
+hermesEnabled=true

Run the App on iOS and Android

To test the integration on both iOS and Android, start by running the Metro server using npm start in the project root. 

For iOS, open the Mastodon.xcworkspace file in Xcode and build the app. If everything is set up correctly, you should be able to navigate to the React Native screen. You’ll be able to switch between screens and access the Dev Menu for debugging. 

For Android, open the app in Android Studio and run it from there. If properly configured, the app should display the React Native screen and allow smooth transitions between native and React Native views on both platforms.

Conclusions

Integrating React Native into the Mastodon app offered valuable lessons in brownfield development. It demonstrated how React Native can modernize an app’s architecture while maintaining its core native performance, making it a practical solution for future scaling in both open-source and proprietary projects.

Insights

Learn more about Brownfield

cover

Aug 27Article

React Native in .NET MAUI: A Practical Brownfield Integration Guide

Learn how to embed React Native inside a .NET MAUI app with a brownfield setup. This guide covers runtime packaging, lifecycle wiring, navigation, and shared state across both runtimes.

cover

Aug 3Article

Migration to React Native in 2026 Starts With a Delivery Question

Choosing between brownfield and greenfield React Native migration? Learn where risk sits, when to use a checkpoint, and what to prove before committing.

cover

Jul 8Article

How we made Hybrid iOS apps compile 39% faster with React Native Brownfield

See how React Native Brownfield uses iOS prebuilts to cut packaging times by up to 39% for hybrid apps, with details on setup, internals, and benchmark results.

cover

Jun 18Article

How We Made the Brownfield Gradle Plugin 22% Faster

See how task-driven Gradle optimization made the Brownfield Gradle Plugin 22% faster for clean Android release builds.

cover

Jun 3Article

AI-Supported Brownfield Migration to React Native

See how AI-supported workflows cut brownfield React Native evaluation from weeks to days by speeding up setup, planning, implementation, and QA.

cover

May 18Article

Unlocking Expo Updates in an Isolated Brownfield Architecture with SDK 55

Learn how to enable Expo Updates in an Isolated Brownfield setup with Expo SDK 55, including iOS packaging, Android patching, and OTA testing.

cover

Apr 23Talk

State of Brownfield in React Native

A practical look at Brownfield React Native adoption in mature native apps, architecture, tooling, and real-world tradeoffs.

cover

Apr 2Webinar

AI-Assisted Migrations to React Native: From Months to Days

Reduce migration time from months to days by using AI in real React Native workflows. Learn when to migrate incrementally and when starting fresh is the better option.

cover

Nov 17Talk

Vega Developer Meetup

Learn how to integrate React Native into Android TV apps at the Vega Developer Meetup. Workshop, roadmap, and community talks, live in Munich.

cover

May 16Podcast

You Don’t Need a Rewrite, You Need React Native Brownfield

Discover how modern React Native Brownfield integration enables cross-platform power in native apps, without rewriting from scratch.

cover

Jan 13Podcast

Lessons from Shopify's Migration to React Native with Mustafa Ali

Learn how Shopify's migration to React Native led to 86% code unification, reduced build times, and seamless feature parity across platforms.

cover

Jun 14Podcast

Migration to React Native

The 13th episode of React Universe On Air (formerly known as The React Native Show) is fully focused on migrating from different technologies to React Native, the two most popular approaches to this process (greenfield and brownfield software development), and our state-of-the-art brownfield approach.

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.