skip to content

In Metro, what do watchFolders, nodeModulesPaths and extraNodeModules each do, and in what order does resolution consult them?

level: middleimportance: should knowfreq 32%

answer

  1. visibility versus lookup
  2. watchFolders: files outside projectRoot
  3. node_modules walk up first
  4. then nodeModulesPaths in order
  5. extraNodeModules is the last resort

basics

~20 s

watchFolders makes files outside projectRoot visible to Metro at all. For a package import, Metro first walks node_modules up from the importing file, then tries each nodeModulesPaths entry, and only then maps the name through extraNodeModules.

solid answer

~40 s

They answer two different questions. `watchFolders` answers "which files can Metro see?": Metro only resolves files under `projectRoot` or a listed watch folder, even in a CI build, and symlink targets must be inside them too. `nodeModulesPaths` and `extraNodeModules` answer "where do I look for a package name?". For an import like `import x from 'lodash'`, Metro (after any custom `resolveRequest`) walks `node_modules` directories up from the importing file, unless `disableHierarchicalLookup` is `true`; then it tries each directory in `nodeModulesPaths`, in order, as if it were another `node_modules`; and only if both fail does it look the package name up in `extraNodeModules`. So `extraNodeModules` is a fallback, not an override. In Expo SDK 52 and later, `expo/metro-config` fills in `watchFolders` and `nodeModulesPaths` for workspaces automatically.

go deeper

for a junior

Know that Metro can only bundle files it can see, and that watchFolders is how folders outside the app become visible.

for a middle

Recite the lookup order: walk up node_modules, then nodeModulesPaths, then extraNodeModules, and what disableHierarchicalLookup changes.

for a senior

Choose the right knob for a workspace problem and explain why extraNodeModules cannot fix a package that is found earlier.

for a principal

Decide whether a monorepo relies on framework defaults or a strict, explicit resolver setup, and who owns that config.

## Two separate concerns Metro's resolver needs two things to turn `import x from 'some-package'` into a file: - **Visibility**: the file must be in Metro's file map. Only files under `projectRoot` and the directories in `watchFolders` are there. - **A search path**: Metro must know which directories to search for a package name. Mixing these up is the source of most monorepo resolution failures. A path can be perfectly correct and still fail because the folder is not visible. ## watchFolders: what Metro can see `watchFolders` is a list of directories **outside** `projectRoot` that contain source files. Despite the name it is not only about watching for changes: the Metro docs stress that even an offline build in CI needs every file visible through `projectRoot` plus `watchFolders`. Two consequences: 1. In a workspace, the app package usually needs the **repository root** (or the shared package folders) in `watchFolders`. 2. When a dependency is a **symlink** into another folder, the symlink's target must also be inside `watchFolders`. ## The lookup order for a package name For a bare specifier (not `./` or `../`), Metro's default resolver tries these in order and stops at the first hit: 1. A custom `resolver.resolveRequest`, if configured, which replaces everything below unless it chains to the default. 2. Browser-field redirection and Haste, where enabled. 3. The package's own name through its `exports` (self-reference). 4. **Hierarchical lookup**: `node_modules` in the importing file's folder, then its parent, and so on up to the root. `resolver.disableHierarchicalLookup: true` switches this step off. 5. Each entry of **`resolver.nodeModulesPaths`**, in order, treated as another `node_modules` folder. 6. **`resolver.extraNodeModules`**: a map from package name to directory, consulted only after everything above failed. | Option | Type | Consulted | |---|---|---| | `watchFolders` | directories | Always: decides visibility, not order | | `disableHierarchicalLookup` | boolean, default `false` | Turns step 4 on or off | | `nodeModulesPaths` | ordered directories | Step 5, after the walk up | | `extraNodeModules` | name to directory map | Step 6, last | ## What each is good for - **`nodeModulesPaths`**: a dependency tree installed somewhere not on the walk-up path, for example the repository root's `node_modules` when the app lives in `apps/mobile`. Pairing it with `disableHierarchicalLookup: true` makes resolution depend only on the listed folders, a strict setup some monorepos used. - **`extraNodeModules`**: a last-resort alias, for example mapping a Node core module name to a React Native shim. It can never override a package that the earlier steps find. - **`watchFolders`**: any source outside the app folder, including workspace packages and linked libraries. ## A worked example Take a workspace with `apps/mobile` (the app, `projectRoot`), `packages/ui` (shared components) and dependencies hoisted to the root `node_modules`. The app imports `@acme/ui`, and `packages/ui/src/Card.tsx` imports `date-fns`. 1. **Visibility**: without `watchFolders` containing the root (or `packages/ui`), Metro cannot see `packages/ui` at all, and the import fails even though the workspace symlink exists. 2. **`@acme/ui` from the app**: the walk up from `apps/mobile` finds the workspace link in the root `node_modules`; its target, `packages/ui`, must be visible for the file to load. 3. **`date-fns` from `Card.tsx`**: the walk up starts in `packages/ui/src`, passes `packages/ui/node_modules`, and reaches the root `node_modules`. 4. **With `disableHierarchicalLookup: true`**, step 3 would skip the walk and use only `nodeModulesPaths`, which is why that flag always comes with an explicit list. 5. **A name no folder contains** reaches `extraNodeModules` last, which is where a shim for a Node core module would be mapped. ## Expo does this for you in workspaces Since SDK 52, `getDefaultConfig` from `expo/metro-config` detects a workspace and sets `watchFolders` to the workspace packages and `nodeModulesPaths` to the app's and the root's `node_modules`. The Expo monorepo guide tells teams that configured this by hand to delete their custom `watchFolders`, `nodeModulesPaths`, `extraNodeModules` and `disableHierarchicalLookup` settings and restart once with a cleared cache. A bare React Native project using `@react-native/metro-config` gets `watchFolders: []` and still configures these itself.

  • Why must a custom resolver.resolveRequest usually call context.resolveRequest?
    A configured `resolveRequest` fully replaces Metro's default algorithm for every import, and inside it `context.resolveRequest` is the default resolver. Handling only the special cases and delegating the rest keeps platform extensions, `node_modules` lookup and package exports working; returning nothing useful for other imports breaks the whole bundle.
  • What breaks if you set disableHierarchicalLookup to true without nodeModulesPaths?
    Metro stops walking `node_modules` folders, so bare package imports have nowhere to be found except `extraNodeModules`. Almost every third-party import then fails to resolve. The flag only makes sense together with a `nodeModulesPaths` list naming the folders that should be searched.

A librarian who first checks the shelves on your floor and each floor below, then a list of annex rooms in order, and only then a card that says where a lost title might be; none of it helps if the building you are searching is not in the catalogue at all.

saying these in an interview costs you the question

  • Thinks extraNodeModules overrides a package found in node_modules
  • Believes watchFolders only matters for file watching in development
  • Says nodeModulesPaths is searched before the node_modules walk up
  • Keeps hand-written monorepo resolver settings on Expo SDK 52 and later
  • Forgets that symlink targets must be inside watchFolders