A Practical Guide to React Native Monorepo With Yarn Workspaces
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 helpersWhy 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 enableEnables Corepack so the project can control which Yarn version is used, regardless of what’s installed globally.
yarn set version stableInstalls and pins a stable Yarn version directly in the repository to ensure the same behavior across machines.
yarn config set nodeLinker node-modulesBy 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 -pThis initializes the project as a Yarn workspace:
w(--workspace) marks the root as a workspace root.p(--private) sets"private": trueinpackage.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/mobilecreates the app inside theapps/workspace.--skip-git-initprevents React Native from creating a nested Git repository.--skip-installdefers 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 installFrom 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;watchFolderstells Metro to watch the entire workspace for changes, including shared packages.nodeModulesPathsensures 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"execruns a command inside the workspace environment, with access to its dependencies and binaries.bash -callows 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 typescriptCreating 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 buildsNext, 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 typescriptBuilding the shared package
The shared package is all set. Run this command from the repository root to build the package:
yarn workspace @example/ui buildDon’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-yarnAdd shared@example/ui to the web app:
yarn workspace web add @example/uiStart the web server:
yarn workspace web devUsing 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/uiNow, 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!

Learn more about React Native
Here's everything we published recently on this topic.


















