Trusted by teams at
In this article
- Why Do Enterprise React Native Setups Break Down at Scale?
- Which Monorepo Tool Fits Your React Native Team: Nx, Turborepo, or Yarn Workspaces?
- How Should You Structure Monorepo Packages for Maximum Code Reuse?
- How Do You Solve Metro Bundler's Monorepo Compatibility Issues?
- How Should You Design CI/CD Pipelines for Multi-App React Native Monorepos?
- How Do You Enforce Team Boundaries and Code Ownership in a Shared Codebase?
- What Does the Three-Layer Model Tell You About Your Next Architecture Decision?
Enterprise teams managing three or more React Native apps without a shared codebase strategy spend a significant portion of mobile engineering time re-implementing the same authentication flows, API clients, and UI components across separate repositories. Based on typical enterprise engagements, this duplication commonly accounts for 30 to 40 percent of mobile engineering capacity. A monorepo solves this by organizing multiple apps and shared libraries in a single repository, using tools like Nx, Turborepo, or Yarn Workspaces to enforce code sharing, consistent builds, and team autonomy. The architecture pays off only when implemented with the correct Metro bundler configuration and team conventions. Both are covered here.
Key findings
A monorepo is best for teams with 2+ React Native apps sharing UI components, authentication logic, or API clients. Below that threshold, the tooling overhead exceeds the benefit.
Turborepo's remote caching skips redundant task execution by caching build, test, and lint outputs by input hash. In practice, this means a CI run touching only one package in a 5-app monorepo rebuilds only that package and its dependents.
Metro bundler's default assumption is that it runs from a single app root. This is the primary technical barrier to React Native monorepos. Every configuration decision in this guide traces back to working around that assumption.
Why Do Enterprise React Native Setups Break Down at Scale?
Enterprise React Native architecture fails in predictable ways before teams reach for a monorepo. The failure mode is not a single breaking point. It is slow accumulation.
Polyrepo setups force teams to manually sync shared logic. Authentication flows, design tokens, and API clients get copied between repositories. When a security fix lands in the consumer app's auth package, the merchant app and internal ops app receive it weeks later, if at all. Version drift is the result: three apps, three slightly different implementations of the same logic, three separate bug backlogs.
Publishing a shared component library via npm introduces a release bottleneck. A developer on the payments team changes a button variant. Before any app can test the integration, someone must bump the version, publish to npm, and update the dependency in each consuming app. In practice, teams skip this cycle and copy the component directly. The shared library becomes the canonical source for no one.
Dependency version conflicts are the hardest problem. React Native 0.72 in one app and 0.74 in another means incompatible native modules and Metro bundler conflicts that surface as cryptic runtime errors rather than clean build failures.
Consider a concrete scenario: a media company running a consumer streaming app, a creator studio app, and an internal content moderation tool. All three share a design system and an API client. Here is what that looks like across architectures:
| Problem | Polyrepo outcome | Monorepo outcome |
|---|---|---|
| Design token update | Manual PR in 3 repos, often missed | Single change, all apps pick it up |
| Auth bug fix | Fixed in consumer app, backlogged in others | Fixed once, tested across all apps in CI |
| React Native version upgrade | Staggered over months, native conflicts | Coordinated upgrade with affected-build scoping |
| New shared component | Published to npm, release lag | Available immediately as source dependency |
| Onboarding a new engineer | Clone 3 repos, configure 3 environments | Clone one repo, one setup script |
Metro bundler's module resolution makes React Native monorepos harder than web monorepos. Webpack and Vite handle symlinked packages without special configuration. Metro does not. Every section from here addresses that gap directly.
Which Monorepo Tool Fits Your React Native Team: Nx, Turborepo, or Yarn Workspaces?
The right tool depends on team size and how much configuration overhead you can absorb. This is not a neutral feature comparison. There is a correct starting point for most enterprise teams.
Yarn Workspaces is the package-linking layer, not a build orchestrator. It symlinks packages from packages/* into the root node_modules, making them importable by name. It does not cache builds, run tasks in parallel, or understand dependency graphs. Use it as the foundation under Turborepo or Nx, not as a standalone solution.
The critical Yarn Workspaces configuration for React Native is nohoist. Native modules, including react-native itself, react-native-camera, and react-native-reanimated, must live in each app's own node_modules, not the root. Xcode and Gradle resolve native dependencies relative to the app directory. Hoisting them to the root breaks native builds silently.
// root package.json
{
"workspaces": {
"packages": ["apps/*", "packages/*"],
"nohoist": [
"**/react-native",
"**/react-native/**",
"**/react-native-reanimated",
"**/react-native-reanimated/**"
]
}
}
Turborepo adds task orchestration and caching on top of Yarn Workspaces. A turbo.json pipeline defines which tasks depend on which, and Turborepo caches outputs by input hash. Remote caching via Vercel (or a self-hosted turbo-remote-cache server for enterprises that cannot use Vercel's cloud) means CI machines share cache with developer machines. A minimal pipeline for a React Native monorepo:
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".expo/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"lint": {
"outputs": []
},
"type-check": {
"dependsOn": ["^build"],
"outputs": []
}
}
}
Turborepo is the right choice for teams of 5 to 20 engineers who want fast CI without heavy configuration. It has no opinions about folder structure and no code generators. That is a feature at this scale, not a limitation.
Nx adds project graph visualization, affected commands (nx affected:test), code generators, and module boundary enforcement via ESLint rules. The Nx React Native plugin integrates directly with Metro. At 20 or more engineers across multiple squads, the ability to enforce that the payments team cannot import from the auth team's internals is worth the configuration investment.
| Dimension | Yarn Workspaces only | Turborepo | Nx |
|---|---|---|---|
| Team size | 1–5 | 5–20 | 20+ |
| Number of apps | 1–2 | 2–5 | 5+ |
| CI complexity | Low | Medium | High |
| Code generators | None | None | Yes |
| Module boundary enforcement | None | None | Yes (ESLint) |
| Setup time | Hours | 1–2 days | 3–5 days |
| Remote caching | No | Yes (Vercel or self-hosted) | Yes (Nx Cloud) |
Most enterprise teams with 2 to 4 apps should start with Yarn Workspaces plus Turborepo. Migrate to Nx when you exceed 5 apps or need enforced module boundaries between squads.
Get a structured review of your current React Native architecture and a prioritized list of changes to reduce CI time and code duplication.
Request an architecture review →How Should You Structure Monorepo Packages for Maximum Code Reuse?
Package structure determines whether code sharing actually happens or whether teams route around the shared libraries because they are inconvenient to use. The three-layer model works in practice.
Layer 1: apps/ contains each standalone React Native project. Each app has its own metro.config.js, android/, ios/, and package.json. Apps import from feature packages and core packages but never from each other.
Layer 2: packages/features/ contains feature modules: auth, payments, notifications. These import from core packages and expose a public API to apps. A feature package can contain React Native components, hooks, and business logic specific to that feature domain.
Layer 3: packages/core/ contains pure shared libraries: ui-components, api-client, design-tokens, utils. No app-specific logic. No feature-specific logic.
monorepo/
├── apps/
│ ├── consumer-app/
│ │ ├── metro.config.js
│ │ ├── package.json
│ │ └── src/
│ ├── creator-studio/
│ └── content-moderation/
├── packages/
│ ├── features/
│ │ ├── auth/
│ │ ├── payments/
│ │ └── notifications/
│ └── core/
│ ├── ui-components/
│ ├── api-client/
│ ├── design-tokens/
│ └── utils/
├── package.json ← workspace root
├── turbo.json
└── tsconfig.base.json
React Native components cannot be transpiled like web libraries. Metro must resolve them as TypeScript source, not compiled JavaScript. Point the main field in each internal package's package.json to the TypeScript source file directly:
// packages/core/ui-components/package.json
{
"name": "@acme/ui-components",
"main": "./src/index.ts",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
}
This skips the build step for internal packages entirely. Metro compiles them as part of the app bundle. External consumers (if you ever publish) get a separate compiled output.
Platform-specific code uses the .ios.ts and .android.ts extension convention. Metro resolves these automatically across package boundaries when watchFolders is configured correctly. A file named Button.ios.ts in packages/core/ui-components will resolve correctly when imported from any app in the monorepo.
The most underused pattern in enterprise React Native monorepos is a packages/core/domain layer written in pure TypeScript with zero React Native imports. Business logic, including validation rules, state machines, and data transformations, lives here. Jest runs these tests in Node without a React Native environment. In our project experience, test cycles for domain logic drop from around 45 seconds to under 5 seconds. This single structural change has the largest measurable impact on developer feedback loop speed of any pattern in this guide.
How Do You Solve Metro Bundler's Monorepo Compatibility Issues?
Metro's default assumption is that it runs from a single app root. Every configuration below is a workaround for that assumption. Apply all of them.
watchFolders: Metro only watches the app's own directory by default. Add the monorepo root and all package directories:
// apps/consumer-app/metro.config.js
const { getDefaultConfig } = require('@react-native/metro-config');
const path = require('path');
const fs = require('fs');
const monorepoRoot = path.resolve(__dirname, '../..');
const packagesDir = path.resolve(monorepoRoot, 'packages');
const workspacePackages = fs
.readdirSync(packagesDir, { withFileTypes: true })
.filter(d => d.isDirectory())
.flatMap(d =>
fs.readdirSync(path.join(packagesDir, d.name), { withFileTypes: true })
.filter(sub => sub.isDirectory())
.map(sub => path.join(packagesDir, d.name, sub.name))
);
const config = getDefaultConfig(__dirname);
config.watchFolders = [monorepoRoot, ...workspacePackages];
config.resolver.nodeModulesPaths = [
path.resolve(__dirname, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
resolver.nodeModulesPaths: The order matters. The app's own node_modules must come first. This ensures the app's pinned version of a native module wins over whatever is at the root.
Symlink resolution: Yarn Workspaces symlinks packages into root node_modules. Metro's symlink handling improved significantly in React Native 0.73 with resolver.unstable_enableSymlinks: true. For older versions, use resolver.extraNodeModules to map package names to their source paths explicitly:
config.resolver.extraNodeModules = {
'@acme/ui-components': path.resolve(monorepoRoot, 'packages/core/ui-components/src'),
'@acme/api-client': path.resolve(monorepoRoot, 'packages/core/api-client/src'),
};
Duplicate React error: Two copies of React loaded is the most common symptom of symlink plus nohoist misconfiguration. The nohoist config shown earlier prevents it. Verify by running yarn why react from the app directory. You should see one resolution path.
Verification checklist after configuring Metro:
- Run Metro from each app directory individually (
cd apps/consumer-app && npx react-native start) and confirm no module resolution errors on startup. Engineers most often test from the monorepo root and miss app-specific resolution failures that only appear when Metro starts from the correct working directory. - Verify no duplicate React warnings in the Metro output or console. The warning appears as a runtime console error, not a build failure, so automated CI passes while the app behaves incorrectly.
- Confirm platform-specific extensions resolve correctly by importing a
.ios.tsfile from a shared package on a simulator. - Confirm TypeScript language server resolves imports from shared packages without errors in your editor.
- Run the full test suite from the monorepo root using Turborepo or Nx to confirm caching works correctly. Teams configure Metro correctly but forget to verify that the build tool's cache hash includes changes to shared packages, leading to stale cache hits in CI.
How Should You Design CI/CD Pipelines for Multi-App React Native Monorepos?
The core value of Nx and Turborepo in CI is affected builds: only rebuild and retest apps that depend on changed packages. A change to packages/core/utils triggers tests for every app and feature package that imports it. A change to apps/consumer-app triggers only that app's pipeline.
GitHub Actions with Turborepo:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn turbo run build test lint --filter=...[HEAD^1]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
The --filter=...[HEAD^1] flag tells Turborepo to run tasks for all packages changed since the previous commit and their dependents. Combined with remote caching, unchanged packages are skipped entirely.
iOS builds require macOS runners, which cost significantly more than Linux runners. Scope them tightly. Use a matrix strategy that only triggers macOS runners for apps whose packages changed:
ios-build:
needs: build-and-test
runs-on: macos-14
if: contains(needs.build-and-test.outputs.affected, 'consumer-app')
steps:
- run: yarn turbo run build --filter=consumer-app
Android builds run on Linux. Separate them from iOS builds in your pipeline definition.
Versioning: internal packages consumed only within the monorepo do not need versioning. They are always consumed at HEAD. For packages you publish externally, use Changesets. The workflow: a developer adds a changeset file describing their change, CI aggregates changesets on merge to main and bumps versions automatically. This is cleaner than Lerna's fixed versioning for enterprises where different teams own different packages at different release cadences.
For teams evaluating whether to build this infrastructure in-house or bring in outside expertise, How To Evaluate React Native Development Vendor Enterprise 2026 covers the specific questions to ask about CI/CD capability and monorepo experience during vendor assessment.
How Do You Enforce Team Boundaries and Code Ownership in a Shared Codebase?
A monorepo creates a shared codebase. Without explicit boundaries, it becomes a shared mess. The payments team starts importing from the auth team's internal utilities. The consumer app team modifies a shared component without notifying the creator studio team. These are organizational failures with technical solutions.
Nx module boundaries use the @nrwl/nx/enforce-module-boundaries ESLint rule. Tag packages by team or layer, then define which tags can import from which:
// .eslintrc.json
{
"rules": {
"@nrwl/nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{
"sourceTag": "scope:payments",
"onlyDependOnLibsWithTags": ["scope:core", "scope:payments"]
},
{
"sourceTag": "scope:consumer-app",
"onlyDependOnLibsWithTags": ["scope:core", "scope:features", "scope:consumer-app"]
},
{
"sourceTag": "scope:core",
"onlyDependOnLibsWithTags": ["scope:core"]
}
]
}]
}
}
This rule runs in CI. A PR that violates a boundary fails the lint check before review.
CODEOWNERS ensures PRs touching shared packages automatically request review from the owning team:
# .github/CODEOWNERS
packages/features/payments/** @org/payments-team
packages/features/auth/** @org/auth-team
packages/core/** @org/platform-team
apps/consumer-app/** @org/consumer-team
apps/creator-studio/** @org/creator-team
TypeScript project references enable incremental compilation and enforce that packages only import from their declared dependencies. Each package's tsconfig.json lists its dependencies in the references array. TypeScript's compiler uses this to build only what changed:
// apps/consumer-app/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"references": [
{ "path": "../../packages/core/ui-components" },
{ "path": "../../packages/features/auth" }
]
}
Onboarding new teams: create a code generator (Nx's nx generate or a custom Plop.js template) that scaffolds a new feature package with the correct tsconfig.json, package.json, ESLint config, and Jest setup pre-wired. A new team should run one command and get a correctly configured package. Without this, each new package is configured slightly differently, and the inconsistencies compound over time.
Document the architecture decision in an ADR (Architecture Decision Record) stored in the repository. The ADR should explain why the monorepo was chosen, which packages belong in which layer, and how to add a new package. Engineers joining six months after the initial setup should not need to reverse-engineer these decisions from the folder structure.
The tooling and team conventions described here interact directly with how AI-assisted development fits into the workflow. Ai Augmented React Native Development Enterprise 2026 covers how code generation tools perform differently in monorepo contexts, particularly around cross-package type inference and context window limitations.
The question of whether to staff this architecture work with a dedicated mobile squad or distribute it across shared resources has a clear answer in the monorepo context. Dedicated Mobile Squad Vs Shared Resources Delivery Comparison 2026 shows the delivery difference in measurable terms.
What Does the Three-Layer Model Tell You About Your Next Architecture Decision?
The three-layer package model (apps/, packages/features/, packages/core/) is the structural decision that everything else in this guide supports. Metro configuration, CI scoping, module boundary enforcement, and TypeScript project references all become simpler when packages have clear, enforced roles.
The five Metro verification steps exist because Metro misconfiguration is the reason most React Native monorepo attempts stall. Teams get the folder structure right and the Turborepo pipeline right, then hit a duplicate React error or a platform extension that fails to resolve, and conclude that monorepos do not work for React Native. They do work. The configuration is specific and requires careful attention to detail, but it is finite and fully documented above.
If your team has two React Native apps sharing any code today, the polyrepo cost is already accumulating. The nohoist configuration and the watchFolders setup described here take one engineer one day to implement correctly. That is the threshold: one day of configuration work against months of duplicated bug fixes and release bottlenecks. Teams that delay past two apps consistently report that the migration cost grows faster than the team does.
Frequently asked questions
Get a structured review of your current React Native setup and a prioritized list of changes to reduce CI time and eliminate cross-app code duplication.
Request an architecture review →About the author
Anurag Rathod
LinkedIn →Technical Lead, Wednesday Solutions
Anurag is a Technical Lead at Wednesday Solutions who specialises in React Native and enterprise AI enablement. He has shipped mobile platforms across logistics, container movement, gambling, esports, and martech, and brings compliance-ready, offline-first architecture to every engagement.
30 minutes with an engineer. You leave with a squad shape, a monthly cost, and a start date.
Get your start date →Shipped for enterprise and growth teams across US, Europe, and Asia