A Practical Guide to React Native Monorepo With Yarn Workspaces

Authors
Nazar Sydiaha
Software Engineer
@
Callstack
No items found.

In this article, we’ll walk through setting up a monorepo for a mobile plus web React Native project.

Note: This guide covers a monorepo setup with React Native Community CLI. For Expo-based projects, see the official Expo monorepo documentation.

What is a monorepo?

A monorepo is a single repository that contains multiple projects along with all their code and assets. While those projects are often related, they can still be developed, tested, and released independently by different teams.

Monorepos are especially useful when working on large and complex products, such as super apps. They make it easier to share code between platforms, for example, between a mobile application and a web application, without duplicating logic or reinventing the same solutions in multiple places.

Why use a monorepo in React Native?

As React Native applications grow, it’s common to split code into multiple apps and shared modules:  for example, a mobile app, a web app, and shared packages containing UI components, hooks, or business logic. Managing these pieces across separate repositories quickly becomes painful: duplicated code, mismatched dependency versions, and fragmented tooling.

A monorepo solves this by keeping all applications and shared packages in a single repository. This makes dependency management predictable, reduces CI overhead, and allows teams to collaborate on shared code without publishing intermediate packages or syncing changes across repos.

React Native additionally supports platform-specific file extensions, such as .web.tsx and .native.tsx. This allows us to share package APIs while providing platform-optimized implementations where needed without extra runtime layers. As a result, we can reuse logic and UI contracts across mobile and web while keeping each platform clean and efficient.

Workspace strategy

Rearranging the project structure

To set up Yarn workspaces, we first need to reorganize the project structure. A monorepo requires a clear separation between applications and shared code, with a single package.json at the repository root managing dependencies and workspace configuration.

In this article, we’ll use a structure that has proven to scale well for multi-platform products. It’s also one of the recommended approaches for building universal applications, as described in The Ultimate Guide to React Native TV Development.

your-project/
	├── apps/
	│   ├── mobile/        # React Native app
	│   └── web/           # Next.js web app
	├── packages/
	│   ├── api/            # API clients, SDKs
	│   ├── config/         # Shared config/env
	│   ├── hooks/          # Shared React hooks
	│   ├── ui/             # Cross-platform UI components
	│   └── utils/          # Pure JS helpers

Why separate apps and packages?

This separation helps maintain clear ownership boundaries. Applications depend on packages, but packages never depend on applications. This makes the dependency graph predictable and allows the monorepo to scale as more apps or shared modules are added.

Setting up Yarn workspaces

Preparing the repository

To turn the repository into a Yarn-driven monorepo, we need to run a small sequence of commands at the root level:

corepack enable

Enables Corepack so the project can control which Yarn version is used, regardless of what’s installed globally.

yarn set version stable

Installs and pins a stable Yarn version directly in the repository to ensure the same behavior across machines.

yarn config set nodeLinker node-modules

By default, modern versions of Yarn use Plug’n’Play (PnP). While PnP works well for many projects, React Native tooling is still heavily optimized for the classic node_modules layout, so we go with the regular node-modules linker.

yarn init -w -p

This initializes the project as a Yarn workspace:

  • w (--workspace) marks the root as a workspace root.
  • p (--private) sets "private": true in package.json, which is required because workspace roots are not meant to be published.

At this point, you’ll have a root-level package.json that will manage dependencies for the entire monorepo.

Configuring workspaces

Now open the generated package.json and add “apps” to the workspaces array. The final version should look like the following:

{
  "name": "example-monorepo",
  "packageManager": "yarn@4.12.0",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
}

The workspaces section defines a work tree. We can pass 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.

Defining dependency resolution

In a monorepo, it’s important that core dependencies like react resolve to a single version across all workspaces. Even small mismatches (such as different patch versions of React) can lead to runtime errors and unexpected crashes.

In the same package.json we can explicitly define how these dependencies are resolved at the workspace root. Add a resolutions section:

{
  "resolutions": {
    "react": "19.2.3",
    "react-dom": "19.2.0",
  }
}

These versions reflect the defaults generated by Next.js and React Native at the time of writing, so adjust them to match your project.

Creating the React Native application

With the workspace configured, we can now create the React Native app inside the apps/mobile.

Initializing React Native inside a workspace

From the repository root, run:

npx @react-native-community/cli init mobile \
  --directory apps/mobile \
  --skip-git-init \
  --skip-install
  • --directory apps/mobile creates the app inside the apps/ workspace.
  • --skip-git-init prevents React Native from creating a nested Git repository.
  • --skip-install defers dependency installation to Yarn workspaces.

Installing dependencies from the workspace root

After the project is generated, install all dependencies once from the root of the repository:

yarn install

From this point on, the yarn install should be run from the workspace root. Running install commands inside individual app or package folders can break workspace dependencies.

React Native configuration for monorepos

Out of the box, React Native assumes a single-project setup with all dependencies located in the app’s own node_modules directory. In a monorepo, this assumption no longer holds, so we need to adjust the configurations.

Updating the Metro configuration

At this stage, we need to make a Metro bundler to import code from shared packages while still working with hoisted dependencies. To do that, open apps/mobile/metro.config.js and update it as follows:

const path = require('path');
const { getDefaultConfig } = require('@react-native/metro-config');

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

const config = getDefaultConfig(projectRoot);

config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];

module.exports = config;
  • watchFolders tells Metro to watch the entire workspace for changes, including shared packages.
  • nodeModulesPaths ensures Metro can resolve dependencies hoisted to the workspace root.

iOS setup

To install CocoaPods, you can use the workspace shortcut from the repository root:

yarn workspace mobile exec bash -c "cd ios && pod install"
  • exec runs a command inside the workspace environment, with access to its dependencies and binaries.
  • bash -c allows us to change directories and execute multiple shell commands in one step.

Alternatively, you can navigate to apps/mobile/ios and run pod install directly from there.

Android setup

Update the settings.gradle file to correctly reference the React Native Gradle plugin from the workspace root:

- pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
+ pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") }  

- includeBuild('../node_modules/@react-native/gradle-plugin')
+ includeBuild('../../../node_modules/@react-native/gradle-plugin')

And add this to your android/build.gradle in buildscript section:

allprojects {
   project.pluginManager.withPlugin("com.facebook.react") {
        react {
           reactNativeDir = rootProject.file("../../../node_modules/react-native/")
           codegenDir = rootProject.file("../../../node_modules/@react-native/codegen/")
       }
    }
}

TypeScript configuration for a monorepo

In a monorepo setup, it’s useful to share a small set of TypeScript defaults across applications and shared packages. Instead of duplicating the same options in every tsconfig.json, we’ll start with a minimal base configuration that others can extend.

First, add TypeScript to the root package.json dev dependencies:

yarn add -D typescript

Creating a shared base config

Create tsconfig.base.json in the project root containing the following:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "jsx": "react-jsx",

    "strict": true,
    "strictNullChecks": true,

    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "exclude": ["node_modules"]
}

This file defines a small set of language-level defaults that work well for both React Native and web projects. It avoids assumptions about the runtime, bundler, or output format, making it safe to extend across the entire monorepo.

Setting up shared packages

Sharing code is one of the main reasons for using a monorepo. Instead of duplicating logic or components across applications, we can extract them into shared packages that are consumed by both mobile and web apps.

Creating the UI package

We’ll start by creating a shared ui package that will contain a cross-platform UI component, a simple Button.

Prepare the following directory structure:

packages/ui/
  ├── src/
  │   ├── Button.tsx           # Default implementation
  │   ├── Button.native.tsx    # Mobile override
  │   ├── Button.types.ts      # Shared props types
  │   └── index.ts             # Package entry
  ├── package.json
  ├── tsconfig.json            # TS config for development 
  └── tsconfig.build.json      # TS config for builds

Next, create packages/ui/package.json with the following config:

{
  "name": "@example/ui",
  "version": "1.0.0",
  "private": true,
  "main": "src/index.ts",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc -p tsconfig.build.json"
  }
}

Here we make a package to expose its compiled output and TypeScript types from the dist directory.

Creating the shared UI component

Inside packages/ui/src/Button.tsx, create a component you use across web:

import { ButtonProps } from './Button.types'

export const Button = ({ label }: ButtonProps) => (
  <button style={{padding: '16px', cursor: 'pointer'}}>
    {label}
  </button>
);

Inside packages/ui/src/Button.native.tsx, add a React Native component:

import { TouchableOpacity, Text } from 'react-native';
import { ButtonProps } from './Button.types'

export const Button = ({ label }: ButtonProps) => (
  <TouchableOpacity>
    <Text>{label}</Text>
  </TouchableOpacity>
);

Add shared ButtonProps type into the packages/ui/src/Button.types.ts:

export type ButtonProps = { label: string };

Export Button component from the package in the packages/ui/src/index.ts:

export { Button } from './Button';

TypeScript setup for shared packages

Shared packages extend the root tsconfig.base.json configuration and define their own development and build settings.

Create packages/ui/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "noEmit": true,
  },
  "include": ["src"],
  "exclude": ["dist"]
}

This configuration is used during development, limiting scope to the src directory. The noEmit: true ensures TypeScript performs type-checking without generating output. Next, create packages/ui/tsconfig.build.json:

{
	"extends": "./tsconfig.json",
  "compilerOptions": {
	  "noEmit": false,
    "emitDeclarationOnly": true,
    "outDir": "dist",
    "declaration": true,
    "declarationMap": false
  },
  "include": ["src"]
}

This configuration is used only when building the package and emits only TypeScript declaration files into the dist directory. JavaScript output is intentionally skipped, as runtime bundling is handled by the consuming applications. Setting "noEmit": false ensures TypeScript emits build artifacts for this package.

Now you need to add a TypeScript dev dependency to the package:

yarn workspace @example/ui add -D typescript

Building the shared package

The shared package is all set. Run this command from the repository root to build the package:

yarn workspace @example/ui build

Don’t forget to add the dist/ directory to your .gitignore file, as build artifacts should not be committed to the repository.

Setting up a web project

With the shared packages in place, we can now add a web application that consumes the same code as the React Native app. In this guide, we’ll use Next.js, a natural choice for building modern web applications.

Create a Next.js app and complete the setup steps:

npx create-next-app@latest apps/web --ts --use-yarn

Add shared@example/ui to the web app:

yarn workspace web add @example/ui

Start the web server:

yarn workspace web dev

Using shared UI across platforms

Now we are ready to verify that the same shared UI component works in both the web and mobile applications.

Using the shared component on the web

Open apps/web/src/pages/index.tsx and import the shared Button component:

import { Button } from '@example/ui';

export default function Home() {
  return (
    <main
      style={{
        minHeight: '100vh',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
	    <div style={{backgroundColor: '#eee'}}>
	      <Button label="Hello from Web" />
          </div>
    </main>
  );
}

Using the shared component on mobile

We already added the @example/ui package to the web, so it’s time to do the same for the mobile app:

yarn workspace mobile add @example/ui

Now, open apps/mobile/App.tsx and update it as follows:

import { View, StyleSheet } from 'react-native';
import { Button } from '@example/ui';

export default function App() {
  return (
    <View style={styles.container}>
      <Button label="Hello from Mobile" />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

Summary and next steps

We’ve built a working foundation for a React Native monorepo that supports mobile and web applications, with shared UI and logic. From here, you can extend your monorepo in several directions: adding more shared packages, adding more supported platforms, or integrating CI workflows tailored to a multi-app repository.

If you’re interested in how this approach scales in real-world scenarios, you may also find these related articles useful:

Good luck!

Table of contents
Need help with React or React Native projects?

We support teams building scalable apps with React and React Native.

Let’s chat

//

//
React Native

We can help you move
it forward!

At Callstack, we work with companies big and small, pushing React Native everyday.

React Native Performance Optimization

Improve React Native apps speed and efficiency through targeted performance enhancements.

New Architecture Migration

Safely migrate to React Native’s New Architecture to unlock better performance, new capabilities, and future-proof releases.

Code Sharing

Implement effective code-sharing strategies across all platforms to accelerate shipping and reduce code duplication.

Mobile App Development

Launch on both Android and iOS with single codebase, keeping high-performance and platform-specific UX.

//
Insights

Learn more about React Native

Here's everything we published recently on this topic.