React Native Monorepo with Turborepo and Expo: A Platform Team's Guide for 2026
A platform team's guide to running a React Native monorepo on pnpm, Turborepo, and Expo SDK 55 in 2026: Metro config, EAS Build, remote caching, and the footguns to dodge.
A React Native monorepo with Turborepo and Expo lets one repository hold your iOS app, Android app, web app, and shared packages, with a single dependency graph, type-safe imports across packages, and incremental builds that skip unchanged work. In 2026 the stack I run in production is pnpm workspaces + Turborepo + Expo SDK 55 + Metro's built-in monorepo support, and the setup is small enough to fit on one screen once Metro's watchFolders trap is out of the way.
Use pnpm workspaces for hoisting control and Turborepo for the task graph. npm and yarn classic still work, but they cost you in CI minutes.
Metro needs explicit watchFolders and nodeModulesPaths entries pointing at the workspace root, or you get "Unable to resolve module" on the first cold start.
Expo SDK 53 added first-class monorepo support, and SDK 55 ships the --workspace-root flag for EAS Build. Manual patches are no longer required.
Internal packages should be plain TypeScript with "main": "./src/index.ts", not pre-built. Metro transforms them and Turborepo handles the cache.
Turborepo remote cache on Vercel (or a self-hosted S3 bucket) cuts CI runs by 40-70% once your team is over five engineers.
EAS Build needs cli.requireCommit off in a monorepo, or every other engineer's PR fails on uncommitted Turbo cache writes.
Why a monorepo for React Native?
I've run polyrepo setups at three different companies, and migrated all of them. The story's always the same: the iOS app pins React Native 0.74, the web app pins 0.79, the shared UI package gets copy-pasted between them, and three months later the design tokens drift by a hex value. A monorepo collapses that drift into a single dependency graph. One package.json in the root pins React, React Native, TypeScript, and the linter for every workspace.
The platform team I lead runs a single repo with seven workspaces: two Expo apps (consumer and ops dashboard), a Next.js marketing site, a shared design system package, a typed API client, a domain models package, and an ESLint config package. Every package imports from @acme/ui or @acme/api by name, and TypeScript's project references catch breaking changes at tsc --build time. Cross-cutting refactors that used to take two PRs and a deployment dance are now one PR with one CI run.
Honestly, the cost is real. Metro configuration is finicky, EAS Build needs the right workspace root, and you have to teach junior engineers to think in terms of dependency graphs instead of folders. But that cost is one-time. The benefits compound every sprint.
The tooling stack I run in 2026
Here's the exact stack on our production repo as of June 2026, with the version numbers we pin:
Layer
Tool
Why this one
Package manager
pnpm 9.x
Strict hoisting prevents phantom dependencies; symlinked node_modules works with Metro out of the box on SDK 53+.
Task runner
Turborepo 2.x
Dependency-aware task graph, content-addressed cache, remote cache works on Vercel free tier.
Mobile framework
Expo SDK 55 (RN 0.81)
First-class monorepo support, EAS Build understands --workspace-root natively.
Bundler
Metro 0.83
Ships with Expo SDK 55. Monorepo resolution is built in, so no metro-config-monorepo hack.
TypeScript
5.6 with project references
Incremental builds across packages, catches API breaks at the boundary.
Web app
Next.js 15 (App Router)
Shares the UI package via react-native-web.
If you're still on Expo SDK 52 or earlier, walk through our Expo SDK 55 migration guide before touching the monorepo work. SDK 53 was the inflection point. Anything older needs the legacy Metro patches, and they're not worth the time.
How do you set up a React Native monorepo with Expo?
This is the minimal walkthrough I use when onboarding a new platform engineer. Every step is required and the order matters. The end state is a workspace root holding apps/mobile (Expo) and packages/ui (shared components), with Turborepo orchestrating the lint/typecheck/build tasks.
1. Initialize the workspace root
mkdir acme && cd acme
pnpm init
git init
Edit the root package.json to declare workspaces and set "private": true:
{
"name": "acme",
"private": true,
"packageManager": "[email protected]",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck"
}
}
Add pnpm-workspace.yaml at the root:
packages:
- "apps/*"
- "packages/*"
2. Scaffold the Expo app inside apps/mobile
mkdir -p apps && cd apps
pnpm create expo-app mobile --template default
cd mobile
# Strip the lockfile and node_modules, they belong at the root
rm -rf node_modules pnpm-lock.yaml
cd ../..
pnpm install
3. Create a shared package
mkdir -p packages/ui/src
cd packages/ui
pnpm init
Set the package.json to point at source TypeScript, not a build output:
Create turbo.json at the root (see the pipelines section for the full version). At this point a sibling import { Button } from '@acme/ui' from apps/mobile/App.tsx still fails. That's Metro, and it's the next section.
Configuring Metro for monorepos
Metro defaults assume the project root is the npm root. In a monorepo it's not, so by default Metro refuses to watch files outside apps/mobile and refuses to resolve modules from the workspace root's hoisted node_modules. The fix is six lines in apps/mobile/metro.config.js:
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// 1. Watch all files in the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
// 3. Force resolving nested modules to the folders below
config.resolver.disableHierarchicalLookup = true;
module.exports = config;
The Expo docs ship a near-identical snippet. For the canonical version, read the official Expo monorepo guide. Three things go wrong here in practice:
The wrong workspaceRoot path. If your app lives at apps/mobile the resolve is '../..'. If it lives at apps/native/mobile, it's '../../..'. I've seen this off-by-one cost a team two days.
Hierarchical lookup left on. If disableHierarchicalLookup stays false, Metro will sometimes resolve react-native twice (once from the app, once from a transitive dependency), and you get the dreaded "Invariant Violation: new NativeEventEmitter() requires a non-null argument" at runtime.
Mixed package managers. If anyone on the team has yarn.lock and you're on pnpm, Metro's symlink resolution diverges between machines. Add a preinstall hook with npx only-allow pnpm.
Sharing code: internal packages and TypeScript paths
The temptation when you first set up a monorepo is to build packages: run tsc in each one, output to dist, point main at dist/index.js. Don't do this. Metro can transform TypeScript directly, and Turborepo's cache makes "rebuild on change" pointless work. Internal packages should point main at ./src/index.ts and let Metro and Next.js handle the transform.
The exception is anything published to npm. Those need a real build step. For internal-only packages, source-as-entry is what every Expo platform team I've audited in 2026 is running.
For TypeScript across packages, use project references rather than a single tsconfig.json. A root tsconfig.base.json holds compiler options. Each package has a tiny tsconfig.json that extends it and declares references:
Running tsc --build from the root then typechecks the whole graph in dependency order and reuses cached results. This composes well with the type-safe forms patterns we've written about. The shared Zod schemas live in a package that both the app and the API import from, so a schema change fails CI in both at once.
The "barrel file" trap
If your package's src/index.ts re-exports 200 components, every consumer imports all 200. Metro is smart enough to tree-shake in production, but it slows dev cold-start measurably. For UI libraries with more than ~30 exports, expose subpath imports:
EAS Build has supported monorepos since 2023, but the ergonomics changed in 2025. In SDK 55 the canonical pattern is to run eas build from inside the app directory and let it auto-detect the workspace root. From apps/mobile:
eas build --platform ios --profile production
EAS reads eas.json in the app directory and resolves the monorepo by walking up until it finds pnpm-workspace.yaml. The two settings I always check in eas.json:
requireCommit: false matters because Turborepo writes to .turbo during the build, and if EAS rejects the build for an uncommitted file your engineers will rage-quit. Add .turbo to .gitignore and you're clean. For OTA updates, the patterns in our EAS Update guide apply unchanged in a monorepo. The channel name lives in the app's app.json, not the root.
Turborepo task pipelines and remote caching
Turborepo's value is the task graph. You declare what depends on what, and it figures out the order, the parallelism, and whether it can skip a task because the inputs haven't changed. A minimal turbo.json for a Native + Web monorepo:
The ^ prefix means "for every package this depends on, run that task first." So turbo run typecheck from the root typechecks @acme/api before @acme/ui before apps/mobile, with everything that has no dependency relationship running in parallel.
Remote caching is where this earns its keep on a real team. Sign up at Vercel's remote cache (free for open source, paid for teams), or self-host with the open-source turborepo-remote-cache server backed by S3. Run turbo login && turbo link, and every CI runner shares a cache keyed by file content. The first push of a refactor builds; every push after that reuses the cache. On our repo, average CI time dropped from 11 minutes to 3.
This is the most common question in interviews when I'm hiring platform engineers. Both work. The honest answer is that they optimise for different things, and the right pick depends on your team size and how much convention you want imposed from outside.
Dimension
Turborepo
Nx
Mental model
Task runner with a cache
Workspace generator + task runner + plugins
Setup complexity
Add one config file
Generators rewrite your repo layout
React Native plugin
None needed, works via the task graph
First-party plugin, opinionated structure
Code generation
None
Built-in (nx g component foo)
Dependency graph visualization
Basic (turbo run --graph)
Excellent (nx graph UI)
Remote cache
Vercel-hosted or self-host
Nx Cloud (paid SaaS) or self-host
Best for
Small platform teams, "we just want caching"
Large enterprises wanting strong conventions
I pick Turborepo for teams under 30 engineers and Nx for teams over 100. Between those sizes the call is taste. The platform team I run picked Turborepo because we already had a strong opinion on file layout and didn't want a generator second-guessing it. Read the Turborepo repository-structuring docs for their take on layout if you're starting fresh.
Common monorepo footguns I have seen ship
Five years of monorepo migrations gives you a list of things that always go wrong. Here's the curated set:
Two copies of React. A package declares react as a dependency instead of a peer dependency. pnpm hoists both copies, and you get "Invalid hook call" at runtime. Audit with pnpm why react. There must be exactly one.
Metro symlink errors on Windows. pnpm uses symlinks heavily. Windows requires Developer Mode enabled for unprivileged symlinks. If half your team is on Windows, document this in the README.
EAS picking up the wrong app config. If you have two Expo apps in apps/, eas build resolves the one in your current directory. CI scripts need cd apps/mobile first, or --app-name set explicitly.
Shared package importing app code. A reverse dependency where @acme/ui imports from apps/mobile compiles silently in TypeScript and breaks the build graph. Turborepo can't detect it. Forbid it with an eslint-plugin-import/no-restricted-paths rule.
Jest configs that resolve the wrong React. Jest doesn't respect Metro's resolver. If you run unit tests on shared packages, point moduleDirectories at the workspace root too.
For broader testing strategy across the monorepo, our React Native testing guide covers Jest, RNTL, and Maestro setup. They all play well with the workspace structure above.
Frequently Asked Questions
Does Expo support monorepos?
Yes. Expo has shipped first-class monorepo support since SDK 53 (2025) and refined it in SDK 55. The Metro config still needs a watchFolders entry, and EAS Build auto-detects the workspace root from pnpm-workspace.yaml or the root package.json.
Should I use pnpm, yarn, or npm workspaces for React Native?
pnpm 9 is my default in 2026. Its strict hoisting model catches phantom dependencies that yarn classic and npm silently allow, and Metro's resolver works with pnpm symlinks out of the box on Expo SDK 53+. Yarn Berry (with PnP off) also works, but the broader ecosystem moved toward pnpm.
Why is Metro bundler slow in a monorepo?
Almost always because watchFolders points at too large a directory and Metro is watching every file in the repo. Restrict watchFolders to the workspace root, add a .watchmanconfig with sensible ignores, and check that disableHierarchicalLookup is true so the resolver doesn't walk up the tree on every import.
Can I share code between a React Native app and a Next.js app in a monorepo?
Yes, and that's one of the strongest reasons to set one up. Put your UI in a package using react-native APIs, let react-native-web handle the browser side, and configure Next.js with the transpilePackages option to compile your workspace packages. Pure-logic packages (types, validation, API clients) share without any extra config.
Do I need to build internal packages or can Metro consume the source directly?
For internal packages, point main at ./src/index.ts and let Metro transform the TypeScript on the fly. There's no benefit to a build step. It slows the dev loop and adds a cache invalidation surface. The exception is anything published to npm, which needs a real dist output.
Config plugins keep native iOS and Android changes in code with Expo's Continuous Native Generation. A working 2026 guide to withXcodeProject, withAndroidManifest, prop validation, idempotency, and shipping plugins as npm packages.
A hands-on guide to building React Native native modules in 2026. Compare Turbo Modules, Expo Modules API, and Nitro Modules with Swift, Kotlin, and TypeScript examples — plus benchmarks and a decision framework.