React Native Monorepo With pnpm Workspaces

Authors
Nazar Sydiaha
Software Engineer
@
Callstack

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

corepack enable

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

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:

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:

{
  "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/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:

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:

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:

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:

- 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 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 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 typescript

Building the shared package

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

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:

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:

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

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 --workspace

Then 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!

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 18Article

A Practical Guide to React Native Monorepo With Yarn Workspaces

Learn how to set up a React Native monorepo with Yarn workspaces for mobile and web, including Metro, Gradle, 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.