A Practical Guide to React Native Monorepo With Yarn Workspaces

Authors
Nazar Sydiaha
Software Engineer
@
Callstack

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 className="container">
      <div className="bg-gray-100">
        <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!

Insights

Learn more about React Native

cover

Aug 4Podcast

VisionCamera V5 With Marc Rousavy

Marc Rousavy joins Mike Grabowski to unpack VisionCamera v5, Nitro Modules, the constraints API, migration work, and what changed for React Native camera apps.

cover

Jul 1Podcast

Redraw for Effects Beyond Skia

William Candillon talks about where Redraw fits next to Skia, from physics-based rendering to camera effects, AR, and new UI patterns.

cover

Jun 29Article

Less Boilerplate, More Type Safety: React Navigation 8's Typed Hooks

React Navigation 8 improves typed hooks with screen-aware types, less boilerplate, and fewer unsafe casts in shared UI and nested navigators.

cover

Apr 30Live Stream

Build a React Native App With the TanStack Stack

Join Callstack’s live stream on building a React Native app from the ground up using the TanStack toolbelt.

cover

Apr 28Article

React Native production lessons from React Universe Meetup x Zalando

Lessons from React Universe Meetup x Zalando on React Native in production, covering brownfield performance, feed video, native API access, and on-device AI.

cover

Apr 23Talk

Shipping iOS Live Activities with React: The Voltra Story

How Voltra enables Live Activities and Dynamic Island views using React without writing Swift. A deep dive into iOS constraints and architecture.

cover

Apr 16Live Stream

React Native 0.85: Animation Backend and Key Updates

A recap of React Native 0.85, covering the new animation backend, DevTools improvements, and ecosystem takeaways.

cover

Mar 5Article

Keeping Screens Visible With <Activity mode="hidden">

A cross‑platform workaround to keep screens visible while <Activity mode="hidden"> unmounts effects on inactive screens by setting mode="hidden".

cover

Feb 25Article

React Native Monorepo With pnpm Workspaces

Set up a React Native monorepo with pnpm workspaces for mobile and Next.js web, including Metro fixes, hoisted node-linker, and shared packages.

cover

Feb 17Podcast

From Teddy Bears to Voice Agents: Kraen Hansen on Voice AI, Local-First & App Security

What happens when you combine voice agents and teddy bears? A hands-on look at local-first systems, voice AI, and security lessons from poking real apps.

cover

Jan 28Article

Bringing CSS Clipping to React Native

A deep look at how a single CSS feature moves through React Native’s New Architecture, from JavaScript styles to native rendering on iOS and Android.

cover

Jan 13Article

Live Activities and Widgets with React: Say Hello to Voltra

Voltra lets you build iOS Live Activities and widgets for React Native using JSX instead of Swift.

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.