DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Expo in a pnpm monorepo: one flat node_modules, two peer overrides, and the Metro flag you should not set

Munchable is a pnpm workspace with two apps and five packages in it. One app is Next.js. The other is an Expo app whose web build is also our web app. Three of the five packages are imported by both of them.

Getting Metro to agree with that arrangement took three configuration decisions, and the most useful one was choosing not to set a flag that every monorepo guide tells you to set.

The layout

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
Enter fullscreen mode Exit fullscreen mode
"dependencies": {
  "@munchable/content": "workspace:*",
  "@munchable/design-tokens": "workspace:*",
  "@munchable/pricing": "workspace:*",
  "@munchable/rules-engine": "workspace:*",
  ...
}
Enter fullscreen mode Exit fullscreen mode

Those are plain TypeScript with no build step. Metro transforms their source directly, which is the main reason to have them in the same repo at all: an edit to a shared package shows up in the running app without a publish, a version bump, or a tsc watcher.

Decision one: give up on pnpm's symlinked store

# .npmrc
node-linker=hoisted
engine-strict=true
Enter fullscreen mode Exit fullscreen mode

This throws away the thing people choose pnpm for. pnpm's default layout is a content-addressed store with symlinks, which gives you strict dependency isolation: a package can only import what it declares.

React Native does not survive it. The native module resolution in the Gradle and CocoaPods autolinking paths, and parts of Metro's resolver, walk real directory trees and make assumptions about where a package's dependencies physically sit. With symlinks they resolve the same package twice through two paths, or fail to find it at all, and the error you get is three layers below anything you wrote.

node-linker=hoisted gives you a flat node_modules that looks like npm's. You keep pnpm's lockfile, its workspace protocol, and its install speed, and you give up the isolation. For an Expo app that is the recommended trade and it is not really a trade: the alternative is not working.

engine-strict=true is unrelated but belongs in the same file. The repo declares "node": ">=22.18", and without this the wrong Node version fails later, as a Metro error or a native build error, in a place that gives you no hint that the version is the problem.

Decision two: tell Metro about the root

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

const config = getDefaultConfig(projectRoot);

config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];
Enter fullscreen mode Exit fullscreen mode

Two lines doing two different jobs, and conflating them is a common way to waste an afternoon.

watchFolders is about the file watcher. By default Metro watches the project directory, which means an edit in packages/rules-engine/src produces nothing: no rebuild, no refresh, no error. You save the file, you look at the phone, and the old behaviour is still there. Adding the workspace root makes the shared packages live.

nodeModulesPaths is about resolution. With a hoisted layout most dependencies end up in the root node_modules, and Metro needs to be told that is a place where modules live. The app's own node_modules stays first in the list so a version installed for the app specifically wins.

Decision three: leave disableHierarchicalLookup alone

This is the one worth the post. The usual monorepo advice is to set it:

// Do not do this here.
config.resolver.disableHierarchicalLookup = true;
Enter fullscreen mode Exit fullscreen mode

The argument for it is reasonable. With nodeModulesPaths listing every real location, Node's walk-up-the-tree algorithm is redundant work: for a deep file, Metro tries node_modules at every ancestor directory on every resolution. Turning it off is a measurable speed-up and it makes resolution deterministic.

It also breaks our install, which is why the config has a comment on it rather than a flag:

// NOTE: hierarchical lookup is intentionally left ON. pnpm (even with
// node-linker=hoisted) keeps peer deps like expo-modules-core in the nested
// store, and Metro needs the walk-up to resolve them from inside `expo`.
Enter fullscreen mode Exit fullscreen mode

Hoisted is not the same as flat. pnpm hoists what it can, and where a package needs a specific version of a peer that disagrees with the hoisted one, it still gets a nested copy. So expo-modules-core exists at node_modules/expo/node_modules/expo-modules-core, a path that is in neither of the two nodeModulesPaths entries. A file inside expo resolves it by the walk-up, and only by the walk-up. Disable that and the bundle fails on a module that is unambiguously installed, which is a confusing enough error that you will go looking in the wrong place first.

The general lesson: that flag is safe when your layout is genuinely flat. Under pnpm you cannot assume it is, no matter which linker you picked.

Two peer dependencies pnpm resolved to the wrong major

The remaining config is two overrides, and they share a cause. A package declares a peer as '*', pnpm takes that literally and installs the newest release, and the newest release is from a line the rest of the toolchain does not use.

overrides:
  '@react-native/metro-config': 0.86.3
  '@types/react': ~19.2.18
  '@types/react-dom': ~19.2.7
Enter fullscreen mode Exit fullscreen mode

The first: react-native-worklets (pulled in by Reanimated 4) declares '@react-native/metro-config': '*'. pnpm installs the newest 0.87.x. Meanwhile @react-native/community-cli-plugin pins it to react-native's own 0.86.3. Two copies of the Metro config package, from two React Native minors, in one bundle.

The second: Radix, which arrives through expo-router, lists the React type packages as optional '*' peers. pnpm installs the newest @types/react-dom, which then demands @types/react@^19.3, which Expo SDK 57 pins below. In a hoisted node_modules there is one slot for @types/react, so the two requirements collide and tsc reports type errors inside libraries you have never opened.

Both overrides pin to the version the Expo SDK expects, with the reason in a comment above them. Six lines of YAML. The comments are the valuable part: without them, the next person to see three pinned versions in an overrides block will try removing them, and will have to rediscover the whole chain.

There is also one patchedDependencies entry, for a camera feature we needed on Android that the upstream module does not expose. That is its own story, but it is worth noting that patchedDependencies and node-linker=hoisted coexist without trouble.

Checking it worked

The honest test is not that the bundler starts. It is that one source file produces the right thing on both targets. The recipe pages on our site are rendered by Next out of the same @munchable/content package that the Expo app imports and Metro transforms, with no published artefact in between, and the condition pages read their types and their vocabulary from @munchable/rules-engine in the same way. When the resolver configuration is wrong, the web and the phone stop agreeing before anything actually crashes, which is the failure mode this whole file exists to prevent.

Top comments (0)