React Native Monorepo with Turborepo: Managing Multiple Apps
AI generated
RN
native
React Native · Turborepo · Monorepo · Tooling
React Native Monorepo with Turborepo
managing multiple apps in one repository

A React Native monorepo with Turborepo bundles multiple apps, shared packages, and build caching into a single repository. This article walks through the setup with pnpm workspaces, the Turborepo pipeline configuration, and the Metro adjustments a stable monorepo needs.

17 min read pnpm workspaces · Turborepo · Metro · EAS Build React Native 0.74+

1. Why a monorepo for multiple React Native apps

Teams running a consumer app and a partner app, or maintaining several white-label variants of the same base, sooner or later face the same question: separate repositories with copied code, or a React Native monorepo that keeps all apps and shared code in one place. Copied code drifts apart, a bug fix in one app rarely lands synchronously in the other, and design system changes end up maintained twice.

A monorepo solves this structurally: UI components, API clients, and configuration presets live once in shared packages, and every app imports from them. Changes take effect across all apps immediately, version conflicts between repos disappear, and code reviews show the cross-app impact of a change in a single pull request.

The cost is tooling complexity: without a task runner like Turborepo, every CI run rebuilds every app, even when only a single file in a single app changed. That is exactly where the combination of a workspace manager and Turborepo makes a React Native monorepo practical.

2. Workspace basics: pnpm, apps/, and packages/

The foundation of any monorepo is the workspace manager. pnpm workspaces are the most common choice for React Native projects, because pnpm saves disk space through its content-addressable store and enforces stricter node_modules resolution than npm or classic Yarn. Yarn workspaces are an equally valid alternative when a team already relies on Yarn.

The folder convention has become standard across projects: apps/ holds the standalone React Native applications, packages/ holds the shared libraries. Every app and every package has its own package.json, but references shared packages not through a published npm version, rather through the workspace protocol, which points locally to the folder inside the same repository.

This workspace protocol is the crucial difference from separate repositories with a private npm registry: changes in a package are immediately visible in all apps, with no publish step and no version number bump. For a React Native monorepo with frequent design system changes, that is a significant speed advantage over a multi-repo setup.


# Initialize a pnpm workspace at the repository root
pnpm init

# pnpm-workspace.yaml defines which folders are workspace packages
cat > pnpm-workspace.yaml << 'YAML'
packages:
  - "apps/*"
  - "packages/*"
YAML

# Create two React Native apps and one shared UI package
mkdir -p apps/consumer-app apps/partner-app packages/ui packages/config

# Install a shared package into an app using the workspace protocol
pnpm --filter consumer-app add @acme/ui@workspace:*

How the workspace protocol concretely shows up in an app's package.json is illustrated below. Instead of a fixed version number like 1.2.0, there is a reference that pnpm resolves at install time to the local package folder, regardless of whether that package was ever published to npm.


{
  "name": "consumer-app",
  "version": "1.4.0",
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/api": "workspace:*",
    "@acme/config": "workspace:*",
    "react-native": "0.75.2"
  }
}

Another advantage of this structure shows up when onboarding new team members into a React Native monorepo: a single pnpm install at the repository root installs every dependency for all apps and packages at once, including correct linking between the internal packages themselves. There is no extra step to manually link local packages the way npm link often requires in separate repositories.

3. Turborepo basics: pipeline and task graph

Turborepo adds a task runner to the workspace manager that understands dependencies between tasks and caches results. The central configuration file turbo.json defines pipelines: which task depends on which other task, which outputs should be cached, and which environment variables influence a task's result.

The dependsOn operator with the ^ prefix (as in ^build) means a task only runs after the same task has completed in every package the current package depends on. For a React Native monorepo, that means concretely: an app's build task automatically waits for the ui package and the config package to be built first, without any manual orchestration in a script.

Caching is the real speed gain: Turborepo hashes a task's input files and skips execution entirely if nothing has changed since the last run, including reuse across different branches and developers with remote caching enabled. A CI run that previously took ten minutes can shrink to a few seconds when the relevant packages are unchanged.


{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".expo/**"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

4. Sharing code: UI, utils, and config packages

A typical React Native monorepo shares code across at least three package categories. A ui package holds the design system components, buttons, cards, form elements, meant to look visually consistent across every app. A utils or api package bundles the API client, authentication logic, and shared hooks. A config package holds ESLint, TypeScript, and Tailwind presets, so every app inherits the same quality rules without maintaining them individually.

Each of these packages exports a clearly defined public API through its package.json main and exports fields, rather than exposing internal implementation files directly. That prevents apps from accidentally coupling to internal details of a package that might change later without warning.

For a monorepo with multiple React Native apps, the ui package is usually the area with the largest maintenance burden, since styling decisions (colors, spacing, typography scales) should be anchored directly in the Tailwind or NativeWind configuration inside the config package, instead of being defined separately in every app.


// apps/consumer-app/screens/ProfileScreen.jsx
import { Button, Card } from '@acme/ui';
import { useCurrentUser } from '@acme/api';

export function ProfileScreen() {
  const { user, isLoading } = useCurrentUser();

  if (isLoading) return null;

  return (
    <Card>
      <Button label={"Edit profile for " + user.name} onPress={() => {}} />
    </Card>
  );
}

5. Metro configuration for monorepos

Metro, React Native's bundler, assumes by default that every relevant file lives inside a single project folder. In a React Native monorepo, Metro instead needs to watch both the app's own folder and the repository root and shared packages, otherwise changes to an imported package are not recognized as a file update.

Every app's Metro configuration therefore extends watchFolders with the repository root and adjusts resolver.nodeModulesPaths so that Metro searches for node_modules both inside the app folder and in the shared root node_modules created by pnpm's workspace hoisting. A common pitfall is a duplicate resolution of React or React Native itself when multiple copies end up in nested node_modules, which leads to cryptic "invalid hook call" runtime errors.

The most reliable countermeasure is to explicitly force React and React Native to a single instance via resolver.extraNodeModules pointing at the root node_modules path, instead of relying on pnpm's automatic hoisting logic. This configuration is necessary in almost every production React Native monorepo as soon as more than one app exists.

6. Native code in a monorepo context

An important difference from pure web monorepos: every React Native app keeps its own native ios/ and android/ projects despite shared JavaScript packages. A shared ui package can provide JavaScript components, but if it contains native modules itself, every app has to link them through autolinking in its own Podfile and its own Gradle configuration.

For Podfile and Gradle, that means concretely: the relative paths to react-native scripts have to be adjusted for the app's position inside the monorepo, since the app no longer sits directly at the repository root. Most current React Native templates already account for this through a node --print resolution that determines the actual react-native install path independent of directory depth.

Autolinking itself works exactly the same in a React Native monorepo as in a single project, as long as the native library is resolved as a dependency of the respective app, not just of the shared package. A common mistake is declaring a native library only inside the ui package without also making it visible as a transitive dependency of the app itself, which leads to missed autolinking detection.

With two apps that have different native feature footprints, for instance when only the partner app needs a camera library, control over native dependencies per app is fully preserved. The monorepo does not force any app to bundle native modules it does not actually use, as long as each app's package.json precisely lists only the native libraries it genuinely needs.

7. Remote caching: faster CI across branches

Local caching speeds up repeated builds on the same machine, but the real productivity gain of a React Native monorepo comes from remote caching. Vercel Remote Cache or a self-hosted cache server let you share task results between CI runs, branches, and even between different developers' local machines.

In practice, that means: if developer A has already built a package locally and uploaded the cache, developer B does not need to run the same build again, Turborepo downloads the cached result instead. For CI pipelines with many parallel feature branches, this effect adds up to noticeably shorter waits until a green build.

Cache tokens for remote caching are security-critical and should be treated like any other CI secret: a compromised token potentially allows injecting manipulated build artifacts into the cache. Rotating tokens and a separate, less privileged token for pull request pipelines reduce this risk in a monorepo with external contributors.

8. Versioning and release strategy

Apps in a React Native monorepo typically have independent version numbers and release cycles, while shared packages are best versioned through Changesets, a tool that captures changes to packages and automatically generates version bumps and changelogs from them. That prevents a package from accidentally shipping incompatible breaking changes into multiple apps at once without it being visibly documented.

EAS Build fits into a monorepo setup through a per-app eas.json, where the working directory must be explicitly set to the respective app folder so the build server knows which app should actually be packaged. Without this configuration, EAS Build otherwise tries to interpret the entire repository as a single app.

For teams with white-label variants, it also pays to establish a clear convention for where app-specific environment variables are maintained, so a build failure in the partner app does not accidentally pick up configuration from the consumer app.

9. CI/CD integration with filtered tasks

Without filtering, a CI pipeline in a React Native monorepo rebuilds every app on every commit, even when only a single file in a single app changed. Turborepo's --filter flag solves this by running only the tasks of the packages actually affected, based on the dependency graph and the files changed since the last commit shared with the target branch.

A typical CI configuration splits jobs per app, each job calling turbo run build --filter=consumer-app..., where the three dots also include every package consumer-app transitively depends on. This significantly reduces CI runtime, especially in monorepos with four or more apps where a typical pull request usually only affects a single one.

The biggest pitfall with this filtering is an incorrectly configured base branch for the diff comparison: if the comparison point is set wrong, Turborepo either builds too much (performance loss) or too little (a broken build that misses a change). Both cases are reliably avoided with turbo run build --filter=...[origin/main], provided the CI environment has access to the full Git history.

Criterion Separate repositories React Native monorepo with Turborepo
Code sharing Copy-paste or private npm registry Direct import via the workspace protocol
CI runtime Isolated per repo, but redundant Filtered and cached, only affected work builds
Design system updates Manually replicated in every repo Immediately visible in all apps
Onboarding effort One repo per task, simple entry More initial tooling complexity
Version consistency Drifts easily over time One source of truth per package

# CI job: only build and test what the changed files actually affect
# Compares against the main branch merge base for an accurate diff

pnpm install --frozen-lockfile

# Build only the consumer-app and its dependency graph
pnpm turbo run build --filter=consumer-app...[origin/main]

# Run tests only for packages affected since the base branch
pnpm turbo run test --filter=...[origin/main]

Mironsoft

React Native architecture and monorepo tooling for multi-app teams

Multiple apps, one repository, no CI chaos?

We build React Native monorepos with Turborepo, clean package boundaries, and CI pipelines that only build what has actually changed.

Monorepo setup

pnpm workspaces, Turborepo pipelines, and Metro configuration

Package architecture

UI, API, and config packages with clean public interfaces

CI optimization

Remote caching and filtered pipelines for shorter build times

10. Summary

A React Native monorepo with Turborepo solves the structural problem of multiple apps with shared code without accepting the typical downsides of separate repositories. pnpm workspaces provide the foundation through apps/ and packages/, the workspace protocol connects apps and shared packages without a publish step. Turborepo orchestrates task dependencies and caches results so a CI run only rebuilds what is actually affected.

The critical pitfalls sit outside the obvious configuration: correct Metro configuration against duplicate React instances, native autolinking paths adjusted for the actual position inside the monorepo, and a clean base branch comparison for filtered CI tasks. Anyone who accounts for these three points from the start avoids the most common causes of cryptic failures in production multi-app monorepos.

For teams that start today with a single app but plan a second variant in the medium term, it pays to set up the monorepo structure with the very first project instead of migrating later. The extra effort for apps/ and packages/ from the start is small, retrofitting an existing single project is considerably more work.

React Native Monorepo with Turborepo: Key Takeaways

Workspace structure

apps/ for standalone apps, packages/ for shared code, connected through the workspace protocol.

Turborepo pipeline

turbo.json defines task dependencies via dependsOn and caches outputs for faster repeated runs.

Metro adjustment

watchFolders and extraNodeModules prevent duplicate React instances and resolution errors.

CI filtering

--filter with a correct base branch comparison builds only actually affected apps and packages.

11. FAQ: React Native Monorepo with Turborepo

1When is a monorepo worth it?
Once at least two apps share significant code, such as a design system or API client.
2pnpm or Yarn workspaces?
pnpm is more common due to its content-addressable store, Yarn is an equally valid alternative.
3What does ^build mean?
The task only runs once the same task has finished in every dependent package.
4invalid hook call in the monorepo?
Usually duplicate React instances, forcing extraNodeModules to the root instance fixes it.
5Own native projects per app?
Yes, every app keeps its own ios/ and android/ folders despite shared JavaScript.
6Benefit of remote caching?
Task results are shared across CI runs, branches, and developer machines and skipped when unchanged.
7Versioning shared packages?
Changesets captures changes and automatically generates version bumps and changelogs.
8EAS Build in a monorepo?
Through a per-app eas.json with an explicit working directory.
9Avoiding rebuilding every app?
--filter=app-name...[origin/main] builds only actually affected packages and apps.
10Turborepo or Nx?
Turborepo is more lightweight, Nx offers more generators. Turborepo is enough for most teams.