Guides
Plan-enterprise-icon
Expo Application Services
API Reference

Working with Monorepos

Monorepos, or "monolithic repositories", are single repositories containing multiple apps or packages. It can help speed up development for larger projects, make it easier to share code, and act as a single source of truth. This guide will set up a simple monorepo with an Expo project. We currently have first-class support for Yarn 1 (Classic) workspaces. If you want to use another tool, make sure you know how to configure it.
Warning-icon
Monorepos are not for everyone. It requires in-depth knowledge of the used tooling, adds more complexity, and often requires specific tooling configuration. You can get far with just a single repository.

Example monorepo

In this example, we will set up a monorepo using yarn workspaces without the nohoist option. We will assume some familiar names, but you can fully customize them. After this guide, our basic structure should look like this:
  • apps/ - Contains multiple projects, including React Native apps.
  • packages/ - Contains different packages used by our apps.
  • package.json - Root package file, containing yarn workspaces config.

Root package file

All yarn monorepos should have a "root" package.json file. It is the main configuration for our monorepo and may contain packages installed for all projects in the repository. You can run yarn init, or create the package.json manually. It should look something like this:
package.json
{
  "name": "monorepo",
  "version": "1.0.0"
}

Set up yarn workspaces

Yarn and other tooling have a concept called "workspaces". Every package and app in our repository has its own workspace. Before we can use them, we have to instruct yarn where to find these workspaces. We can do that by setting the workspaces property using glob patterns, in the package.json:
package.json
{
  "private": true,
  "name": "monorepo",
  "version": "1.0.0",
  "workspaces": ["apps/*", "packages/*"]
}
Warning-icon
Yarn workspaces require the root package.json to be private. If you don't set this, yarn install will error with a message mentioning this.

Create our first app

Now that we have the basic monorepo structure set up, let's add our first app.
Before we can create our app, we have to create the apps/ folder. This folder can contain all separate apps or websites that belong to this monorepo. Inside this apps/ folder, we can create a subfolder that contains the React Native app.
Terminal
→ yarn create expo-app apps/cool-app
Info-icon
If you have an existing app, you can copy all those files inside a subfolder.
After copying or creating the first app, run yarn to check for common warnings.

Modify the Metro config

Metro doesn't come with monorepo support by default (yet). That's why we need to configure Metro and tell it where to find certain things. There are three main changes we need to:
  1. Make sure Metro is watching the full monorepo, not just apps/cool-app.
  2. Tell Metro where it can resolve packages. They might be installed in apps/cool-app/node_modules or node_modules.
  3. Force Metro to only resolve (sub)packages from the nodeModulesPaths.
We can configure this by creating a metro.config.js with the following content:
metro.config.js
// Learn more https://docs.expo.dev/guides/monorepos
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');

// Find the project and workspace directories
const projectRoot = __dirname;
// This can be replaced with `find-yarn-workspace-root`
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages and in what order
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
config.resolver.disableHierarchicalLookup = true;

module.exports = config;
Info-icon
Learn more about customizing Metro.
Triangle-down-icon
1. Why do we need to watch all files with the monorepo?
Metro has three separate stages in its bundling process, documented here. During the first phase, Resolution, Metro resolves your app's required files and dependencies. Metro does that with the watchFolders option, which is set to the project directory by default. This default setting works great for apps that don't use a monorepo structure.
When using monorepos, your app dependencies splits up into different directories. Each of these directories must be within the scope of the watchFolders. If a file is outside of that scope, Metro won't be able to find it. Setting this path to the root of your monorepo
As your monorepo increases in size, watching all files within the monorepo becomes slower. You can speed things up by only watching the packages your app uses. Typically, these are the ones that are installed with an asterisk (*) in your package.json. For example:
// Learn more https://docs.expo.dev/guides/monorepos
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');

// Only list the packages within your monorepo that your app uses. No need to add anything else.
// If your monorepo tooling can give you the list of monorepo workspaces linked
// in your app workspace, you can automate this list instead of hardcoding them.
const monorepoPackages = {
  '@acme/api': path.resolve(workspaceRoot, 'packages/api'),
  '@acme/components': path.resolve(workspaceRoot, 'packages/components'),
};

// 1. Watch the local app folder, and only the shared packages (limiting the scope and speeding it up)
// Note how we change this from `workspaceRoot` to `projectRoot`. This is part of the optimization!
config.watchFolders = [projectRoot, ...Object.values(monorepoPackages)];

// Add the monorepo workspaces as `extraNodeModules` to Metro.
// If your monorepo tooling creates workspace symlinks in the `node_modules` folder,
// you can either add symlink support to Metro or set the `extraNodeModules` to avoid the symlinks.
// See: https://facebook.github.io/metro/docs/configuration/#extranodemodules
config.resolver.extraNodeModules = monorepoPackages;

// 2. Let Metro know where to resolve packages and in what order
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
config.resolver.disableHierarchicalLookup = true;
Triangle-down-icon
2. Why do we need to tell Metro how to resolve packages?
This option is important to resolve libraries in the correct node_modules directories. Monorepo tooling, like Yarn, usually creates two different node_modules directories which are used for a single workspace.
  1. apps/mobile/node_modules - The "project" folder
  2. node_modules - The "root" folder
Yarn uses the root folder to install packages used in multiple workspaces. If a workspace uses a different package version, it installs that different version in the project folder.
We have to tell Metro to look in these two folders. The order is important here because the project folder node_modules can contain specific versions we use for our app. When the package does not exist in the project folder, it should try the shared root folder.
Triangle-down-icon
3. Why do we need to disable the hierarchical lookup?
This option is important for certain edge cases, such as a monorepo that includes multiple versions of the react package. For example, let's say you have the following monorepo:
  1. apps/marketing - A simple Next.js website to attract new users. (uses react@17.x.x)
  2. apps/mobile - Your Expo app. (uses react@18.x.x)
  3. apps/web - Your Next.js website. (uses react@17.x.x)
With monorepo tooling like Yarn, React is installed in two different node_modules folders.
  1. node_modules - The root folder, contains react@17.x.x.
  2. apps/mobile/node_modules - The app's folder, contains react@18.x.x.
Expo modules and React Native libraries usually don't add react as a peer dependency. As a result, monorepo tooling, like Yarn, will install these dependencies to the root node_modules directory, for example:
  1. node_modules - The root folder, contains expo@... and react@17.x.x.
  2. apps/mobile/node_modules - The app's folder, contains react@18.x.x.
With hierarchical lookup enabled, whenever expo imports react, Metro will resolve to react@17.x.x and not react@18.x.x. This causes "multiple React versions" errors in your app.
By disabling hierarchical lookup, we can force Metro to resolve only folders from the nodeModulesPaths = [...] order we defined in #2. This option is documented in the Metro Resolution Algorithm documentation, under step 5.
When we disable this hierarchical lookup, it should not matter where the React Native library is installed. Whenever a library imports react, or any other library, Metro always resolves the library from the nodeModulesPaths we defined. As long as the apps/mobile/node_modules path has the correct library version and is listed as the first nodeModulesPaths entry, we should always get the correct version of that library.

Change default entrypoint

In monorepos, we can't hardcode paths to packages anymore since we can't be sure if they are installed in the root node_modules or the workspace node_modules folder. If you are using a managed project, we have to change our default entrypoint to node_modules/expo/AppEntry.js.
Open our app's package.json, change the main property to index.js, and create this new index.js file in the app directory with the content below.
index.js
import { registerRootComponent } from 'expo';

import App from './App';

// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
Info-icon
This new entrypoint already exists for bare projects. You only need to add this if you have a managed project.

Create a package

Monorepos can help us group code in a single repository. That includes apps but also separate packages. They also don't need to be published. The Expo repository uses this as well. All the Expo SDK packages live inside the packages/ folder in our repo. It helps us test the code inside one of our apps/ before we publish them.
Let's go back to the root and create the packages/ folder. This folder can contain all the separate packages that you want to make. Once you are inside this folder, we need to add a new subfolder. The subfolder is a separate package that we can use inside our app. In the example below, we named it cool-package.
Terminal
# Create our new package foldermkdir -p packages/cool-packagecd packages/cool-package
# And create the new packageyarn init
We won't go into too much detail in creating a package. If you are not familiar with this, please consider using a simple app without monorepos. But, to make the example complete, let's add an index.js file with the following content:
index.js
export const greeting = 'Hello!';

Using the package

Like standard packages, we need to add our cool-package as a dependency to our cool-app. The main difference between a standard package, and one from the monorepo, is you'll always want to use the "current state of the package" instead of a version. Let's add cool-package to our app by adding "cool-package": "*" to our app package.json file:
package.json
{
  "name": "cool-app",
  "version": "1.0.0",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web"
  },
  "dependencies": {
    "cool-package": "*",
    "expo": "~43.0.2",
    "expo-status-bar": "~1.1.0",
    "react": "17.0.1",
    "react-dom": "17.0.1",
    "react-native": "0.64.3",
    "react-native-web": "0.17.1"
  },
  "devDependencies": {
    "@babel/core": "^7.12.9"
  }
}
Info-icon
After adding the package as a dependency, run yarn install to install or link the dependency to your app.
Now you should be able to use the package inside your app! To test this, let's edit the App.js in our app and render the greeting text from our cool-package.
App.js
import { greeting } from 'cool-package';
import { StatusBar } from 'expo-status-bar';
import React from 'react';
import { Text, View } from 'react-native';

export default function App() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>{greeting}</Text>
      <StatusBar style="auto" />
    </View>
  );
}

Common issues

As mentioned earlier, using monorepos is not for everyone. You take on increased complexity and need to solve issues you most likely will run into. Here are a couple of common issues you might encounter.

Can I use another monorepo tool instead of Yarn workspaces?

There are a lot of monorepo tools available, and each of these tools has its benefits. It's hard for us to keep up with the latest tools and methods, and because of that, we can't officially support new monorepo tools. That being said, if the tool follows these three rules, it should work fine.
Triangle-down-icon
1. All dependencies must be installed in a node_modules directory
React Native dependencies contain many other files besides JavaScript, like Gradle files such as react-native/react.gradle. These native files are referenced from different sources other than Node.js, and because of that, it makes it fundamentally incompatible with concepts like Plug'n'Play modules.
Triangle-down-icon
2. Dependencies used in multiple workspaces can be installed in the root node_modules directory
Whenever multiple workspaces use the same version of a single dependency, they can be installed in a root node_modules directory. Monorepo tools usually do this to remove duplicate tasks, like installing the same dependency twice in different places. This rule isn't necessary but does set us up for rule #3.
Triangle-down-icon
3. Different versions of dependencies must be installed in the app node_modules directory
In the Modify the Metro config step, we instructed Metro to do a couple of this, specifically:
  • #2 - Resolve dependencies in the order of the local /apps/<name>/node_modules and root /node_modules directories.
  • #3 - Disable resolving dependencies using the hierarchical lookup strategy.
If a workspace uses a different library version than the one installed in the root /node_modules, that different library version must be installed in the workspace /apps/<name>/node_modules directory.
When Metro resolves a library, for example, react, from the workspace, it should find that different version in /apps/<name>/node_modules and not look inside the root /node_modules directory.
When importing a dependency from the root /node_modules folder that also imports react, react should still resolve to the different version installed in /apps/<name>/node_modules. That's what the disabled hierarchical lookup option does for Metro. Without this, some libraries might import the wrong react version and cause "multiple React versions found" errors.
The default settings of tools like pnpm do not follow these rules. You can change that by adding a .npmrc file with node-linker=hoisted (see docs). That config option will change the behavior to match these rules.

Script '...' does not exist

React Native uses packages to ship both JavaScript and native files. These files also need to be linked, like the react-native/react.Gradle file from android/app/build.Gradle. Usually, this path is hardcoded to something like:
Android (source)
apply from: "../../node_modules/react-native/react.gradle"
iOS (source)
require_relative '../node_modules/react-native/scripts/react_native_pods'
Unfortunately, this path can be different in monorepos because of hoisting. It also doesn't use the Node module resolution. You can avoid this issue by using Node to find the location of the package instead of hardcoding this:
Android (source
apply from: new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../react.gradle")
iOS (source)
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
In the snippets above, you can see that we use Node's own require.resolve() method to find the package location. We explicitly refer to package.json because we want to find the root location of the package, not the location of the entry point. And with that root location, we can resolve to the expected relative path within the package. Learn more about these references here.
All Expo SDK modules and templates, starting from SDK 43, have these dynamic references and work with monorepos. But, occasionally, you might run into packages that still use the hardcoded path. You can manually edit it with patch-package or mention this to the package maintainers.

Remove expo-yarn-workspaces

Before SDK 43, expo-yarn-workspaces was the recommended way to use Yarn workspaces with your Expo project. It was used to symlink all required dependencies back to the app's node_modules folder. Although this works for most apps, it has some flaws. For example, it doesn't work well with multiple versions of the same package.
We made some significant changes with Expo SDK 43 to improve support for monorepos. The auto linker in the newer Expo modules now also looks for packages in parent node_modules folders. None of our native files inside our template contain hardcoded paths to packages.
If you are following this guide, you should remove that package from your project's dependencies.
  • Message-iconAsk a question on the forums
  • Edit-iconEdit this page

Was this doc helpful?