React Native Monorepo With pnpm Workspaces
This publication outlines a practical monorepo setup for React Native projects spanning mobile and web.
Note: The setup described here is based on the React Native Community CLI. If you’re using Expo, refer to the official Expo monorepo documentation instead.
Understanding monorepos
A monorepo is a single codebase that hosts multiple projects together with their source code and assets. Although these projects usually belong to the same product or ecosystem, they can still be built, tested, and shipped independently.
This approach is particularly effective for large, multifaceted products, including super apps. One of the main advantages of monorepos is easier cross-platform code sharing: for instance between a mobile and a web app, without copying logic or reinventing the same solutions in multiple places.
Why monorepos matter in a React Native setup
As React Native codebases evolve, they often grow into multiple applications and shared packages. A typical setup might include a mobile app, a web app, and common modules for UI components, hooks, or domain logic. Managing these pieces across separate repositories quickly becomes painful: duplicated code, mismatched dependency versions, and tooling configs.
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 approach
Rearranging the project structure
Before configuring pnpm workspaces, the project structure needs to be adjusted. A monorepo works best when applications and shared packages are clearly separated, with a single root-level package.json that is responsible for dependency management and workspace definitions.
For this walkthrough, we’ll use a layout that has proven reliable for scaling multi-platform products. It also aligns with one of the recommended patterns for universal app development outlined 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 helpersSeparating apps and packages
Keeping applications and shared packages in separate directories clarifies ownership. Applications rely on packages, but packages don’t rely on applications. This one-way relationship keeps the dependency graph easy to predict and makes it more simple to scale the monorepo as additional apps or shared modules are introduced.
Setting up pnpm workspaces
Getting the repository ready
To convert the repository into a monorepo managed by pnpm, execute a short set of commands from the project root:
corepack enableThis command activates Corepack, allowing the project to define which pnpm version should be used, independent of any globally installed version.
corepack prepare pnpm@latest --activateThis installs and locks a stable pnpm release within the repository so that every environment behaves consistently.
Next, manually create an .npmrc file in the root directory with the following configuration:
node-linker=hoistedpnpm normally relies on an isolated, symlink-based dependency layout. Because React Native tooling expects a flat node_modules structure, switching to the hoisted node linker makes the setup behave more predictably.
Create a package.json file with the minimal required fields:
{
"name": "monorepo",
"private": true,
}Setting up workspaces
At the root of the repository, add a pnpm-workspace.yaml file and all needed packages:
packages:
- "apps/*"
- "packages/*"With this configuration, pnpm recognizes which folders belong to the workspace, links local packages together, and manages dependencies across the repository as a single unit.
Defining dependency resolution
Within a monorepo, shared dependencies such as react should resolve to the same version in every workspace. Even minor differences, like mismatched patch versions, may result in runtime issues.
pnpm allows you to enforce consistent versions through the pnpm.overrides field in the root package.json. This mechanism guarantees that all workspaces use the specified versions, regardless of their individual declarations.
Update the root package.json as follows:
{
"pnpm": {
"overrides": {
"react": "19.2.3",
"react-dom": "19.2.3"
}
}
}These versions match the defaults generated by Next.js and React Native at the time of writing. Adjust them to align with your setup if needed.
How to build the React Native app inside the workspace
Once the workspace is configured, generate the React Native application within apps/mobile.
Initializing the app
Run the following command from the repository root:
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-initavoids creating a nested Git repository.--skip-installpostpones dependency installation to pnpm workspaces.
How to install dependencies from the workspace root
After the project is generated, install dependencies once from the workspace root:
pnpm installFrom now on, only run pnpm install at the root. Installing dependencies inside individual packages or apps may disrupt workspace linking.
Adapting React Native for a monorepo layout
By default, React Native expects a single-project structure where dependencies live in the app’s local node_modules. In a monorepo, that assumption changes, so configuration updates are required.
Adjusting the Metro configuration
To allow Metro to resolve shared packages and hoisted dependencies, update apps/mobile/metro.config.js as shown below:
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'),
];
config.resolver.disableHierarchicalLookup = true;
module.exports = config;watchFoldersensures Metro observes the entire workspace, including shared packages.nodeModulesPathsenables resolution of dependencies hoisted to the root.disableHierarchicalLookuptells Metro to stop searching for dependencies in parent directories, forcing it to resolve modules only from the configurednode_modulespaths, which prevents accidental duplicate package resolution.
iOS configuration
From the repository root, install CocoaPods using:
pnpm --filter mobile exec bash -c "cd ios && pod install"execruns a command inside the workspace environment, with access to its dependencies and binaries.bash -cmakes it possible to change directories and execute the installation in one step.
You can also move to apps/mobile/ios and run pod install manually from there.
Android configuration
Modify 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')Then, in android/build.gradle , add the following inside the 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/")
}
}
}Sharing TypeScript settings across the monorepo
In a monorepo, centralizing common TypeScript options avoids repeating the same configuration in every project. Instead of duplicating settings, define a minimal base configuration and extend it where needed.
Add TypeScript as a development dependency at the workspace root:
pnpm add typescript -D -w-w (--workspace-root) flag ensures the dependency is added to the root rather than to an individual package.
Set up 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 configuration defines shared language-level defaults suitable for both React Native and web projects, without imposing runtime-specific assumptions. Thanks to this you can extend across the entire monorepo safely.
Creating shared packages
A primary advantage of a monorepo is extracting reusable logic and components into shared packages instead of duplicating them across apps.
Creating the UI package
Start creating a shared ui package that 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 first 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 configuration for the UI package
Each shared package extends the base configuration of the root tsconfig.base.json and defines its 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 applied exclusively during the build process and generates only TypeScript declaration files inside the dist directory. No JavaScript files are produced at this stage, since runtime bundling is performed by the applications that consume the package. By setting "noEmit": false, we explicitly allow TypeScript to produce the necessary build artifacts for this package.
Now you need to add a TypeScript dev dependency to the package:
pnpm --filter @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:
pnpm --filter @example/ui buildDon’t forget to add the dist/ directory to your .gitignore file, as build artifacts should not be committed to the repository.
Adding a web application
Now that the shared packages are set up, we can introduce a web application that reuses the same code as the React Native app. For this walkthrough, we’ll rely on Next.js, which fits well for developing modern web applications.
Create a Next.js app and complete the setup steps:
npx create-next-app@latest apps/web --ts --use-pnpm --skip-installWith --skip-install, we defer dependency installation so pnpm can later apply workspace-wide hoisting rules correctly.
Add shared @example/ui to the web app:
pnpm --filter web add @example/ui --workspace--workspace tells pnpm to link the dependency from the local workspace instead of trying to fetch it from the npm registry.
Start web server:
pnpm --filter web devVerifying shared UI on both platforms
At this point, confirm that the shared component works in both environments.
Using the component in the web app
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 component in the mobile app
We already added the @example/ui package to the web, so it’s time to do the same for the mobile app:
pnpm --filter mobile add @example/ui --workspaceThen open apps/mobile/App.tsx and update it like this:
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',
},
});Wrap-up
You now have a React Native monorepo that supports both mobile and web applications, with shared UI and logic organized into reusable packages. From here, you can expand the setup by introducing additional shared modules, supporting more platforms, or integrating CI workflows tailored to multi-application repositories.
For further reading, consider these related resources:
Good luck!

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


















