+1 (415) 943-4271

React Native in a Monorepo: pnpm Workspaces, Turborepo, and CI Builds That Finish Before Lunch

Almost every React Native codebase we are brought into has the same shape after two or three years: one app that started as a single src/ folder, a design system somebody extracted into packages/ui, an API client shared with a web dashboard, and a CI pipeline that now takes long enough that developers stop waiting for it. The framework is rarely the bottleneck. The repository layout and the build cache are.

This tutorial covers the monorepo setup we actually recommend for React Native teams, the Metro configuration that makes it work, and how to get CI from "40 minutes and flaky" to "a few minutes on most commits."

When a monorepo is the right call

A monorepo is worth the setup cost when at least one of these is true:

  • You ship more than one app (consumer + internal/ops app, or white-labelled builds per client).
  • You share real code with a web app — API clients, validation schemas, feature flags, analytics event definitions.
  • You have a design system that a second team consumes and you are tired of publishing a private npm package for every button tweak.
  • You want atomic changes: one pull request that changes the API client and every caller of it.

If you have exactly one app and no shared consumers, stay in a single package. A monorepo is not free — you pay for it in resolver configuration, CI complexity, and onboarding.

The layout

We default to pnpm workspaces plus Turborepo. Yarn 4 with nodeLinker: node-modules and Nx are both fine alternatives; the principles below are identical.

repo/
  package.json            # private: true, workspaces via pnpm-workspace.yaml
  pnpm-workspace.yaml
  turbo.json
  apps/
    mobile/               # Expo or bare React Native app
    admin/                # Next.js dashboard (optional)
  packages/
    ui/                   # shared RN components (RN Web friendly)
    core/                 # pure TS: types, zod schemas, formatting
    api/                  # generated client + react-query hooks
    config/               # eslint, tsconfig, prettier presets
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

Two rules that save the most pain later:

  1. Ship source, not builds, for React Native packages. Set "main": "src/index.ts" for internal packages and let Metro/Babel compile them. A prebuild step per package doubles your watch loop and is the single most common cause of "why is my change not showing up?"
  2. React and React Native live in the app, not in the packages. Every shared package declares them as peerDependencies (plus devDependencies for typechecking). Two copies of React in the graph produce hook errors that look like application bugs.
// packages/ui/package.json
{
  "name": "@acme/ui",
  "version": "0.0.0",
  "private": true,
  "main": "src/index.ts",
  "types": "src/index.ts",
  "peerDependencies": {
    "react": "*",
    "react-native": "*"
  }
}

And in the app: "@acme/ui": "workspace:*".

Making Metro behave outside the app folder

Metro only watches the project root by default, and pnpm's symlinked store means dependencies do not sit in one flat node_modules. Both need to be declared explicitly.

// apps/mobile/metro.config.js
const path = require("path");
const { getDefaultConfig } = require("expo/metro-config"); // or @react-native/metro-config

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");

const config = getDefaultConfig(projectRoot);

// 1. Watch the whole workspace so edits in packages/* trigger a rebuild.
config.watchFolders = [workspaceRoot];

// 2. Resolve from the app first, then the workspace root.
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, "node_modules"),
  path.resolve(workspaceRoot, "node_modules"),
];

// 3. With pnpm, do not let Metro walk up past the workspace root.
config.resolver.disableHierarchicalLookup = true;

module.exports = config;

Three follow-ups that come up on nearly every migration:

  • Duplicate React. Add a resolver alias if anything sneaks in a second copy:
    config.resolver.resolveRequest = (ctx, moduleName, platform) => {
      if (moduleName === "react" || moduleName === "react-native") {
        return ctx.resolveRequest(
          { ...ctx, originModulePath: path.join(projectRoot, "index.js") },
          moduleName,
          platform
        );
      }
      return ctx.resolveRequest(ctx, moduleName, platform);
    };
    
    A quicker smoke test: pnpm why react from the repo root should show one version.
  • Symlinks and autolinking. Modern React Native autolinking follows pnpm symlinks, but native modules must be a dependency of the app package, not of a shared package. Put react-native-reanimated in apps/mobile/package.json even if packages/ui imports it (as a peer).
  • Hoisting for stubborn native libs. If a CocoaPods or Gradle plugin insists on a flat layout, a targeted public-hoist-pattern in .npmrc is better than turning off isolation globally:
    public-hoist-pattern[]=*react-native*
    public-hoist-pattern[]=@react-native*
    

Turborepo: a task graph, not just scripts

The win is not the syntax, it is that lint/typecheck/test only run for packages affected by a commit, and their results are cached.

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "lint": { "dependsOn": ["^lint"] },
    "typecheck": { "dependsOn": ["^typecheck"], "outputs": ["tsconfig.tsbuildinfo"] },
    "test": { "dependsOn": ["^typecheck"], "outputs": ["coverage/**"] },
    "bundle:rn": {
      "dependsOn": ["^typecheck"],
      "outputs": ["dist/**"],
      "env": ["APP_ENV", "API_URL"]
    }
  }
}

List every environment variable a task reads in env. If you don't, Turbo will happily hand you a cached staging bundle on a production build — a bug that is extremely hard to spot in a store review queue.

On CI, turbo run lint typecheck test --filter=...[origin/main] runs only what the diff touched. On a repo with two apps and five packages, that alone usually cuts the JS portion of the pipeline by more than half.

The part that actually costs 30 minutes: native builds

JS tasks cache easily. The native compile is where the time goes, and it needs its own strategy.

Split the pipeline by what changed.

  • Touched only JS/TS? You do not need a new binary. Run lint/typecheck/test and, for QA, ship an OTA update onto the previous build.
  • Touched native dependencies, app config, or a config plugin? Rebuild.

A turbo-friendly way to detect this is a fingerprint of the native inputs — lockfile, app.config.ts/app.json, Podfile.lock, Gradle files, plugin sources. Expo's npx expo-updates fingerprint:generate gives you exactly this; hash it, use it as a cache key, and skip the native build when it is unchanged.

Cache the expensive directories, keyed on the lockfile:

CacheKey
pnpm store (~/.pnpm-store)hash of pnpm-lock.yaml
CocoaPods (ios/Pods, ~/Library/Caches/CocoaPods)hash of ios/Podfile.lock
Gradle (~/.gradle/caches, ~/.gradle/wrapper)hash of **/*.gradle*, gradle-wrapper.properties
Turbo remote cachetask graph hash

Turn on the Gradle and Xcode caches properly. org.gradle.caching=true plus org.gradle.configuration-cache=true in gradle.properties, and on iOS, use a warm ccache/derived-data cache if you self-host runners. On EAS Build, custom build config lets you add cache paths; on GitHub Actions, actions/cache with the keys above.

Build only one architecture for PR builds. Debug simulator builds do not need arm64 device slices, and reactNativeArchitectures=arm64-v8a on Android PR builds shaves several minutes.

A CI shape that works

jobs:
  js:
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }     # needed for --filter=...[origin/main]
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run lint typecheck test --filter=...[origin/main]

  native:
    needs: js
    if: needs.js.outputs.native_fingerprint_changed == 'true'
    steps:
      - run: pnpm dlx eas-cli@latest build --profile preview --platform all --non-interactive

  ota:
    needs: js
    if: needs.js.outputs.native_fingerprint_changed == 'false'
    steps:
      - run: pnpm dlx eas-cli@latest update --branch preview --auto

Every pull request gets something installable or updatable. Nobody waits on a native compile for a copy change.

Failure modes we see most often

  • "It works locally, fails on CI." Almost always a dependency that is hoisted on a developer machine (because someone ran npm install once) and isolated on CI. Run pnpm install --frozen-lockfile everywhere and commit the lockfile.
  • Two versions of a native module. pnpm will happily install react-native-svg twice if two packages pin different ranges. Use a root pnpm.overrides block for anything with native code.
  • Typecheck passes, bundle fails. A shared package imported a Node-only module (fs, path) behind a utility. Keep packages/core platform-agnostic and enforce it with an ESLint no-restricted-imports rule.
  • Stale Turbo cache on release builds. Missing env declarations, or caching a task whose output directory isn't listed. Verify with turbo run bundle:rn --dry=json and read the resolved inputs.
  • Design-system package pulls the world in. If packages/ui depends on the navigation library and the API client, every app inherits them. Keep shared UI leaf-level and dependency-light.

What good looks like

After a migration like this, a healthy React Native monorepo has: one React version in the graph, JS checks finishing in a few minutes on affected packages only, native builds triggered by a fingerprint rather than by every commit, and OTA updates covering the JS-only majority of pull requests. The developer-visible effect is that changing a shared component and seeing it in the app is a single reload, not a publish step.

If you are staring at a repo that grew into this shape and are not sure whether to split it, merge it, or leave it alone, that is a conversation we have often — get in touch and we can look at your build timings and dependency graph with you.