---
title: "React Native Monorepo With pnpm Workspaces"
description: "Set up a React Native monorepo with pnpm workspaces for mobile and Next.js web, including Metro fixes, hoisted node-linker, and shared packages."
url: https://www.callstack.com/blog/react-native-monorepo-with-pnpm-workspaces
updated: 2026-10-24T08:39:35.982Z
---

# 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.

_By Nazar Sydiaha, Software Engineer @ Callstack · Published 2026-02-25 · Category: React Native_

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](https://github.com/react-native-community/cli). If you’re using Expo, refer to the official [Expo monorepo documentation](https://docs.expo.dev/guides/monorepos/) 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](https://www.callstack.com/blog/why-have-a-super-app).  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](https://reactnative.dev/docs/platform-specific-code#platform-specific-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](https://www.callstack.com/ebooks/the-ultimate-guide-to-react-native-tv-development).

```javascript
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
```

### Separating 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:

```bash
corepack enable
```

This command activates Corepack, allowing the project to define which pnpm version should be used, independent of any globally installed version.

```bash
corepack prepare pnpm@latest --activate
```

This 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:

```plaintext
node-linker=hoisted
```

pnpm 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:

```json
{
  "name": "monorepo",
  "private": true,
}
```

### Setting up workspaces

At the root of the repository, add a `pnpm-workspace.yaml` file and all needed packages:

```yaml
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:

```json
{
  "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:

```bash
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`  avoids creating a nested Git repository.
- `--skip-install`  postpones 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:

```bash
pnpm install
```

From 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:

```javascript
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;
```

- `watchFolders` ensures Metro observes the entire workspace, including shared packages.
- `nodeModulesPaths` enables resolution of dependencies hoisted to the root.
- `disableHierarchicalLookup` tells Metro to stop searching for dependencies in parent directories, forcing it to resolve modules only from the configured `node_modules` paths, which prevents accidental duplicate package resolution.

### iOS configuration

From the repository root, install CocoaPods using:

```bash
pnpm --filter 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` makes 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:

```plaintext
- 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:

```plaintext
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:

```bash
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:

```json
{
  "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:

```plaintext
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:

```json
{
  "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:

```javascript
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:

```javascript
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:

```javascript
export type ButtonProps = { label: string };
```

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

```javascript
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`:

```javascript
{
  "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`:

```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:

```bash
pnpm --filter @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:

```bash
pnpm --filter @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.

## 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:

```bash
npx create-next-app@latest apps/web --ts --use-pnpm --skip-install
```

With `--skip-install`, we defer dependency installation so pnpm can later apply workspace-wide hoisting rules correctly.

Add shared `@example/ui` to the web app:

```bash
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:

```bash
pnpm --filter web dev
```

## Verifying 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:

```javascript
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:

```bash
pnpm --filter mobile add @example/ui --workspace
```

Then open apps/mobile/App.tsx and update it like this:

```javascript
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:

- [**Optimize Development Efficiency for Super Apps**](https://www.callstack.com/blog/optimize-development-efficiency-when-building-super-app)
- [**Adding an Example App to Your React Native Library**](https://www.callstack.com/blog/adding-an-example-app-to-your-react-native-library)

Good luck!
