monorepo-metro · git:20260728.436a435 · 2026-07-28 · sha256 08e0ffc6d2bac032

monorepo-metro git:20260728.436a435A

Immutable. This exact content is served forever at /api/v1/blob/08e0ffc6d2bac032.

---
name: monorepo-metro
description: Reference for running React Native and Expo apps inside monorepos. Use when a workspace app fails with Invalid hook call or duplicate react-native, when Metro cannot resolve workspace packages, when EAS builds fail only in the monorepo, or when deciding hoisting and lockfile strategy.
user-invocable: false
---

# React Native in a monorepo

## First, delete the folklore

Modern expo/metro-config configures Metro for monorepos automatically. If the repo carries a hand-written `metro.config.js` full of `watchFolders`, `nodeModulesPaths`, `extraNodeModules` or `disableHierarchicalLookup` copied from an old blog post, that config is now the most likely cause of the problem, not the fix. Strip it down to the default Expo config, run `npx expo start --clear` once, and re-test before debugging anything else.

The problems that remain after that are real, and they are all below.

## Singletons, the actual monorepo disease

react, react-native and the native-module packages must resolve to exactly one copy for the app. Two copies produce the classic signatures:

- "Invalid hook call" with a perfectly valid component
- "Tried to register two views with the same name"
- A native module that works in one workspace app and crashes in another

Diagnose with the package manager, not by staring at code: `npm ls react-native` (or `yarn why react-native`, `pnpm why react-native`) from the repo root. More than one resolved version, or the same version in two physical locations, confirms it.

Fix at the root: pin the singletons with the root manifest's override mechanism (`overrides` for npm, `resolutions` for yarn, pnpm's overrides). Then reinstall from the root and re-run the check. If the lockfile seems to hold onto stale resolutions after the override, a full reinstall of node_modules is legitimate here; this is the deliberate-upgrade case, not build-error cache thrashing.

## Hoisting strategies decide your failure mode

- npm and yarn hoist: a package you forgot to declare still resolves because a sibling declared it. Everything works locally and then fails on EAS or on a colleague's machine with a different install order. The bug is the undeclared dependency, not the machine.
- pnpm and bun isolate: undeclared dependencies fail immediately and loudly. More friction on day one, dramatically fewer ghosts later.

Whichever the repo uses, the rule is the same: every workspace package declares what it imports. Hoisting merely changes when you find out.

## Native code and config plugins in shared packages

JS-only shared packages are free. The moment a shared package carries native code or a config plugin, remember that natives are built per app: the app's own package.json must carry the dependency (so autolinking and prebuild see it), not just the shared package's. Symptoms of getting this wrong: the module exists in JS, and the native side throws "module not found" only in release or only in one app.

## EAS builds from a monorepo

- The lockfile that matters is the root one; EAS installs from the repo root and builds the app in its workspace directory.
- "Works locally, fails on EAS" in a monorepo is almost always hoisting (an undeclared dependency that local hoisting hid) or a file referenced outside the workspace that is not committed.
- Keep one SDK/react-native version pair across apps in the workspace if at all possible. Two apps on two RN versions in one workspace is a supported-in-theory, painful-in-practice setup; if a version split is unavoidable, treat it as temporary and plan the convergence.

## When Metro acts haunted

Symptoms like "module disappeared after I moved a file" or imports resolving to stale copies: one `npx expo start --clear` after dependency-graph surgery is legitimate. Running it before every start is a ritual that hides a real problem; if cold cache is the only thing that makes the app build, the resolution problem above is still unsolved.