Setting up React Native Monorepo With Yarn Workspaces

Authors
Oskar Kwaśniewski
Software Developer
@
Callstack

A monorepo is a single repository that holds a multitude of projects with all their code and assets. While the projects can be related, they can be used independently by different teams. 

Working with monorepos is very useful, especially when developing big and complex applications like super apps. Monorepos enable sharing the logic between a web app and a mobile app, for example.

In this article, we’re going to set up a basic structure for a monorepo in React Native.

Why use monorepo in React Native?

Thanks to react-native-web we can share every component so there’s no need to duplicate the code many times. This means easier dependency management, shorter CI times, and better collaboration between teams since the code can be shared.

The main advantage though is the possibility of sharing packages between React and React Native. Most of the time we decide to share only business logic, but thanks to React Native Web, we can also share some of the UI components.

Setting up Yarn workspaces

While setting up a monorepo, we have two options: we can either hoist the packages to the root level or prevent them from hoisting. Yarn workspaces have an option named nohoist which allows us to specify packages that aren’t hoisted. It depends on your use case but most of the time it’s better to hoist packages to the root level.

Alternatively, you can use npm workspaces, it should work the same.

Why it’s better to avoid using nohoist

Preventing packages from hoisting takes back the main advantage of monorepos which is sharing node_modules between repositories. When nohoist option is turned on, most of the time packages are duplicated inside the root level node_modules and inside the project level node_modules. Downloading packages multiple times can lead to longer CI/CD runs and higher costs.

Rearranging the project structure

In order to set up yarn workspaces, we need to restructure our project to suit the structure below. Projects need to be divided into separate folders and we should have root level package.json

your-project
packages
web // <- React app
mobile // <- React Native app
shared // <- Shared (includes files shared between mobile/web)
view raw plain1.txt hosted with ❤ by GitHub

Next, you should create a package.json file in the root directory of our project with this command: $ yarn init -y

It should look like this:

{
"private": "true",
"name": "example-app",
"version": "1.0.0",
"workspaces": [
"packages/*"
]
}

Note that private: true is required because workspaces are not meant to be published.

In the workspaces section, we define a work tree. We can pass there an array of glob patterns that should be used to locate the workspaces. So in the example above, every folder inside packages is defined as a workspace.

Creating a React Native project

Use command below to create a React Native project inside the packages folder:

$ npx react-native init ExampleApp --directory mobile --template react-native-template-typescript
view raw plain2.txt hosted with ❤ by GitHub

In order for our app to work in a monorepo structure, we need to make sure that config files are pointing to the root level node_modules due to package hoisting.

iOS setup

  1. Change paths at the top of your Podfile
- require_relative '../node_modules/react-native/scripts/react_native_pods'
- require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
+ require_relative '../../../node_modules/react-native/scripts/react_native_pods'
+ require_relative '../../../node_modules/@react-native-community/cli-platform-ios/native_modules'
target 'ExampleApp' do
...
view raw path1.diff hosted with ❤ by GitHub
  1. Regenerate Pods, by entering the command below in the packages/mobile folder:
$ cd ios && pod install
view raw plain3.txt hosted with ❤ by GitHub
  1. Next open Xcode and inside Project settings > Build Phases open “Bundle React Native code and images”

Change the path of the script:

set -e
- WITH_ENVIRONMENT="../../node_modules/react-native/scripts/xcode/with-environment.sh"
- REACT_NATIVE_XCODE="../../node_modules/react-native/scripts/react-native-xcode.sh"
+ WITH_ENVIRONMENT="../../../node_modules/react-native/scripts/xcode/with-environment.sh"
+ REACT_NATIVE_XCODE="../../../node_modules/react-native/scripts/react-native-xcode.sh"
/bin/sh -c "$WITH_ENVIRONMENT $REACT_NATIVE_XCODE"
view raw path2.diff hosted with ❤ by GitHub

It should look like this:

XCode Build phases settings

Android setup

React Native ≥ 0.71

Uncomment the line with reactNativeDir and point it to root node_modules

// packages/mobile/android/app/build.gradle
react {
+ hermesCommand = "../../node_modules/react-native/sdks/hermesc/%OS-BIN%/hermesc"
}
view raw android 1.diff hosted with ❤ by GitHub

In top-level build.gradle add this:

allprojects {
project.pluginManager.withPlugin("com.facebook.react") {
react {
reactNativeDir = rootProject.file("../../../node_modules/react-native/")
codegenDir = rootProject.file("../../../node_modules/react-native-codegen/")
}
}
}
view raw android 2.shell hosted with ❤ by GitHub

and inside: packages/mobile/android/settings.gradle

+ apply from: file("../../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
+ includeBuild('../../../node_modules/react-native-gradle-plugin')
view raw android 3.diff hosted with ❤ by GitHub

‍
React Native ≤ 0.71

  1. Fix paths inside packages/mobile/android/build.gradle

Change url of the React Native Android binaries, and Android JSC in the allprojects section:

allprojects {
repositories {
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
- url("$rootDir/../node_modules/react-native/android")
+ url("$rootDir../../../../node_modules/react-native/android")
}
maven {
// Android JSC is installed from npm
- url("$rootDir/../node_modules/jsc-android/dist")
+ url("$rootDir../../../../node_modules/jsc-android/dist")
}
...
}
}
  1. Fix packages/mobile/android/settings.gradle

Here we also need to change paths to the root node_modules folder.

rootProject.name = 'example-app'
- apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
- includeBuild('../node_modules/react-native-gradle-plugin')
+ apply from: file("../../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
+ includeBuild('../../../node_modules/react-native-gradle-plugin')
include ':app'
if (settings.hasProperty("newArchEnabled") && settings.newArchEnabled == "true") {
include(":ReactAndroid")
- project(":ReactAndroid").projectDir = file('../node_modules/react-native/ReactAndroid')
+ project(":ReactAndroid").projectDir = file('../../../node_modules/react-native/ReactAndroid')
include(":ReactAndroid:hermes-engine")
- project(":ReactAndroid:hermes-engine").projectDir = file('../node_modules/react-native/ReactAndroid/hermes-engine')
+ project(":ReactAndroid:hermes-engine").projectDir = file('../../../node_modules/react-native/ReactAndroid/hermes-engine')
}
view raw path3.diff hosted with ❤ by GitHub
  1. Next go to packages/mobile/android/app/build.gradle

Inside the project.ext.react we need to add property cliPath so it will override the default location of the cli.

project.ext.react = [
+ cliPath: "../../../../node_modules/react-native/cli.js",
]
view raw plain4.txt hosted with ❤ by GitHub

In the same file, change this line: apply from: "../../node_modules/react-native/react.gradle" to point to root level node_modules as well.

- apply from: "../../node_modules/react-native/react.gradle"
+ apply from: "../../../../node_modules/react-native/react.gradle"
view raw path4.diff hosted with ❤ by GitHub

And also change: inside packages/mobile/android/app/build.gradle

- apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
+ apply from: file("../../../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
view raw path5.diff hosted with ❤ by GitHub

If you are using or planning to use the new architecture, we also need to change a few lines in the isNewArchitectureEnabled() if statement**.**

if (isNewArchitectureEnabled()) {
// We configure the NDK build only if you decide to opt-in for the New Architecture.
externalNativeBuild {
ndkBuild {
arguments "APP_PLATFORM=android-21",
"APP_STL=c++_shared",
"NDK_TOOLCHAIN_VERSION=clang",
"GENERATED_SRC_DIR=$buildDir/generated/source",
"PROJECT_BUILD_DIR=$buildDir",
- "REACT_ANDROID_DIR=$rootDir/../node_modules/react-native/ReactAndroid",
- "REACT_ANDROID_BUILD_DIR=$rootDir/../node_modules/react-native/ReactAndroid/build",
- "NODE_MODULES_DIR=$rootDir/../node_modules"
+ "REACT_ANDROID_DIR=../../node_modules/react-native/ReactAndroid",
+ "REACT_ANDROID_BUILD_DIR=../../node_modules/react-native/ReactAndroid/build",
+ "NODE_MODULES_DIR=../../node_modules"
cFlags "-Wall", "-Werror", "-fexceptions", "-frtti", "-DWITH_INSPECTOR=1"
cppFlags "-std=c++17"
// Make sure this target name is the same you specify inside the
// src/main/jni/Android.mk file for the `LOCAL_MODULE` variable.
targets "okwasniewskionboarding_appmodules"
}
}

Configure Metro

We’re almost done with setting up the project. The last thing in the React Native app is to add watchFolders so metro knows where the linked node_modules are. The shared modules are symlinked by Yarn, and Metro started supporting symlinks from version 0.75.1, effectively being shipped with React Native 0.72. If you want to turn on symlinks right now, you can pass the experimental flag to your metro config:

module.exports = {
// ...
resolver: {
unstable_enableSymlinks: true // Turn on symlink support
}
};
view raw metro config hosted with ❤ by GitHub

Regardless of whether you are using symlinks, metro still requires us to list symlinked modules in the config, but this might change in the future. You can follow the discussion on GitHub.

const path = require("path");
// packages/mobile/metro.config.js
module.exports = {
// ...
watchFolders: [
path.resolve(__dirname, '../../node_modules'),
path.resolve(__dirname, '../../node_modules/@example-app/shared'),
],
};
view raw metro config 2 hosted with ❤ by GitHub

Setting up a shared package

To keep it simple, we’re going to share only one function across React Native and React.

Inside the packages/shared, create a new package.json file with the command:

$ yarn init -y

The shared library is in typescript, so we need to set up a build step with the typescript compiler (tsc).

The build step uses rimraf which is a small utility for node that allows us to remove a folder, and then build the app with tsc.

postinstall indicates that our shared dependency will build automatically after installing.

{
"name": "@example-app/shared",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"build": "rimraf dist && tsc",
"postinstall": "yarn build",
"watch": "tsc --watch"
},
"dependencies": {
"react-native": "0.69.1",
"react": "18.0.0"
},
"devDependencies": {
"rimraf": "^3.0.2",
"typescript": "^4.4.4"
},
"keywords": [],
"author": "",
"license": "ISC"
}

Note: The package name is really important, because it will indicate from where we will import our module.

It’s also important to add all packages and keep the exact same version used in a shared project and target projects.

Next we need to configure tsconfig.json where we can define our build settings.

Here is an example config file:

// packages/shared/tsconfig.json
{
"compilerOptions": {
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "commonjs",
"outDir": "dist", // <- Directory of generated output
"moduleResolution": "node",
"resolveJsonModule": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"declaration": true,
"jsx": "react"
},
}

Next, let’s create an index.ts file inside the packages/shared folder.

export const add = (a: number, b: number) => {
return a + b;
}

Run this command to build the package:

$ yarn build

Next, we need to add this package as a dependency in our react native project. So inside packages/mobile/package.json add @example-app/shared as a dependency.

Note that the version of the package must match the version specified inside packages/shared/package.json. Previously we defined that @example-app/shared version is 1.0.0.

// packages/mobile/package.json
"dependencies": {
"@example-app/shared": "1.0.0",
...
},

And also inside the metro.config.js

// packages/mobile/metro.config.js
module.exports = {
watchFolders: [
path.resolve(__dirname, '../../node_modules'),
+ path.resolve(__dirname, '../../node_modules/@example-app/shared'),
],
};

Now, run this command in the root directory, so yarn will set up symlinks for your shared package.

$ yarn

Right now we should be able to import the add function inside React Native app.

import React from 'react';
import {Text, TouchableOpacity, View} from 'react-native';
import {add} from '@example-app/shared';
const App = () => {
return (
<View>
<TouchableOpacity
accessibilityRole="button"
onPress={() => {
console.log(add(1, 2));
}}>
<Text>Run Add function</Text>
</TouchableOpacity>
</View>
);
};
export default App;

Setting up a React project

In order to set up a React project, execute the command below inside packages/web:

$ npx create-react-app . ExampleApp --template typescript

Add @example-app/shared dependency:

// packages/web/package.json
"dependencies": {
"@example-app/shared": "1.0.0",
...
},

And we can import and use the shared package:

import React from 'react';
import {add} from '@example-app/shared';
const App = () => {
return (
<div>
<button onClick={() => console.log(add(1,2))}>Run add function</button>
</div>
);
};
export default App;

Sharing UI components with React Native Web

In order to share UI components, we need React Native Web which provides a compatibility layer between React DOM and React Native.

Installation steps

Install react-native-web:

$ yarn add react-native-web

Install babel-plugin-react-native-web

$ yarn add -D babel-plugin-react-native-web

Modify shared package to export React Native component

First inside packages/shared, create a new file called TestComponent.tsx

import { Text, View } from "react-native";
import React from "react";
const TestComponent = () => {
return (
<View>
<Text>TestComponent</Text>
</View>
);
};
export default TestComponent;

Next export it from the index.ts file :

export const add = (a: number, b: number) => {
return a + b;
}
export { default as TestComponent } from './TestComponent.tsx'

Rebuild the shared package inside packages/shared folder:

$ yarn build

And now we should be able to use TestComponent inside a React app.

import React from 'react';
import {add, TestComponent} from '@example-app/shared';
const App = () => {
return (
<div>
<button onPress={() => console.log(add(1,2))}>Run add function</button>
<TestComponent/> // <- React Native component inside React app!
</div>
);
};
export default App;

To sum up

Setting up a monorepo can be tricky, but it pays off in the long run! You can easily share all of your code and assets between React and React Native.

Using a monorepo is growing in popularity - there is even an open proposal to migrate react-native repository to monorepo. Find out more about React Native monorepo on github.

Insights

Learn more about Developer Experience

cover

May 5Article

Codex Ambassador: Q1 Recap Across 5 Cities

A short recap of Codex events in Berlin, Wroclaw, Warsaw, Sopot, and Miami, plus what we learned from each format and where we want to go next.

cover

Feb 5Live Stream

Storybook Explained: From Basics to Advanced Workflows

Core Storybook concepts, stories and args, interaction testing, and how Storybook works in React Native projects.

cover

Jan 29Article

Announcing AI SDK Profiler for React Native

Announcing the AI SDK Profiler plugin for Rozenite DevTools. Capture OpenTelemetry spans from the Vercel AI SDK and inspect AI requests across on-device and cloud providers.

cover

Nov 28Talk

How I Hacked React Native DevTools (And Built a Plugin Framework)

Learn how React Native DevTools work under the hood, and how to inject custom plugins with clean hooks and no forks.

cover

Sep 16Talk

How I Hacked React Native DevTools (And Built a Plugin Framework)

Learn how React Native DevTools work under the hood, and how to inject custom plugins with clean hooks and no forks.

cover

Jul 28Article

Introducing Rozenite: A Plugin Framework for React Native DevTools

Build custom debugging panels inside React Native DevTools with Rozenite: a modular, TypeScript-ready plugin framework with full device-scoped support.

cover

Feb 12Article

How to Easily Create Native Libraries With Nitro and Turbo Modules

Learn how to optimize React Native performance with Turbo and Nitro Modules. Ditch the legacy bridge and master modern native module development.

cover

Dec 4Podcast

Radon IDE: Better Developer Experience for React Native Teams

Find out how Radon IDE can help increase team velocity and rid you of the common React Native app development pain points.

cover

Sep 19Podcast

Exploring Bun: Jarred Sumner on Hyperfast JavaScript Toolkit

Is Bun the future of React Native package management? Join us as we talk with Jarred Sumner, the creator of this lightning-fast JavaScript toolkit.

cover

Sep 11Podcast

Vim: from Zero to Hero

Discover the power of Vim, the legendary text editor, as we explore its origins, standout features, and practical tips to boost your coding efficiency.

cover

May 15Article

Add-Ons for Web Browsers With React

At times you may need to create an add-on to use a web browser more effectively. Find out how to build one with React.

cover

Jan 27Article

Why Is It Worth to Automate Your Dependency Management with `Autolinking`?

When you automate your dependency management, you can fully focus on developing your app. Find out how you can do it with autolinking.

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.