skip to content

Import Resolution Rules

How Metro turns an import string into a file: platform extensions, sourceExts, package exports and custom resolveRequest hooks. Interviewers probe it through monorepos and duplicate copies of React.

on this pageshow

explore

questions

5

In a React Native app, how does Metro decide whether an imported file is source code or an asset, and what does require('./logo.png') return?

level: juniorimportance: must knowfreq 50%

answer

  1. two extension lists in resolver
  2. sourceExts get transformed into JavaScript
  3. assetExts get copied, not parsed
  4. @2x and @3x density variants
  5. require of an image returns a number

basics

~20 s

Metro classifies a file by its extension: resolver.sourceExts (js, jsx, json, ts, tsx by default) are transformed and bundled as code, resolver.assetExts (png, jpg, ttf, mp4 and more) are copied as assets. require('./logo.png') returns a numeric asset registry ID that Image resolves.

solid answer

~40 s

Metro's resolver keeps two lists. Files whose extension is in `resolver.sourceExts`, by default `js`, `jsx`, `json`, `ts` and `tsx`, are source: Metro transforms them with Babel and puts them in the JavaScript bundle. Files whose extension is in `resolver.assetExts`, a long default list of images, video, audio, fonts and a few documents, are assets: Metro does not parse them, it registers them and copies the file into the app or serves it from the dev server. So `require('./logo.png')` returns a **number**, an ID in React Native's asset registry, which `<Image source={...}>` turns into the right file, including `[email protected]` or `[email protected]` for the screen density. An extension in neither list fails to resolve, and one moved from assets to sources needs a transformer that can turn it into JavaScript.

code

javascript · 9 lines
javascript
// metro.config.js (Expo project)
const { getDefaultConfig } = require('expo/metro-config');

const config = getDefaultConfig(__dirname);

// Treat 3D models as assets so require('./model.glb') resolves.
config.resolver.assetExts.push('glb');

module.exports = config;

go deeper

for a junior

Recall the two lists, what each default holds, and that an image require returns a number that Image understands.

for a middle

Explain density suffixes, why a runtime-built require path fails, and how Expo's defaults differ from plain Metro's.

for a senior

Diagnose a new file type that will not resolve, and know when moving an extension to sourceExts also needs a transformer.

for a principal

Set conventions for assets in a large app: where they live, which formats are allowed, and how their size is budgeted.

## Metro sorts files by extension **Metro** is the bundler behind React Native and Expo. When code says `import Button from './Button'` or `require('./logo.png')`, Metro's **resolver** turns that string into a file on disk, and the file's extension decides what happens next. Two resolver options hold the rules: | Option | Default | What Metro does with a match | |---|---|---| | `resolver.sourceExts` | `['js', 'jsx', 'json', 'ts', 'tsx']` | Transforms it with Babel into JavaScript and includes it in the bundle | | `resolver.assetExts` | Images (`png`, `jpg`, `gif`, `webp`, `svg`, ...), video (`mp4`, `mov`, ...), audio (`mp3`, `wav`, ...), fonts (`ttf`, `otf`), `pdf`, `html`, `zip` and more | Registers it as an asset and copies the file; never parses it | Expo's `getDefaultConfig` from `expo/metro-config` starts from these and extends them: it adds `cjs` (and `css`, `scss`, `sass` when CSS support is on) to `sourceExts`, adds `heic`, `avif` and `db` to `assetExts`, and removes from `assetExts` anything that is also a source extension. ## What an asset import returns For an asset, Metro generates a tiny module that registers the file's metadata (name, type, dimensions, available scales) with React Native's **asset registry**. The import evaluates to the registry's return value, a **number**: ```tsx const logo = require('./assets/logo.png'); // typeof logo === 'number' <Image source={logo} /> ``` `Image` looks that ID up and loads the right file. In development it comes from the Metro server; in a release build the file is copied into the app package. ## Density variants come for free For images, Metro also looks for **scale suffixes** listed in `resolver.assetResolutions` (default `['1', '1.5', '2', '3', '4']`). Place `logo.png`, `[email protected]` and `[email protected]` side by side, import `./logo.png`, and each device gets the variant closest to its pixel density. Code never names the `@2x` file. ## When an extension is in the wrong list 1. **In neither list**: the import fails to resolve. Adding a 3D model format such as `glb` means pushing it onto `assetExts`. 2. **Moved from assets to sources**: Metro will try to parse the file as JavaScript. The classic case is SVG as a React component, which needs a custom transformer as well as moving `svg` from `assetExts` to `sourceExts`. 3. **In both lists**: when an import names the file with its extension, Metro checks `assetExts` first, so the file behaves as an asset; Expo's default removes this overlap for you. ## Changing the lists safely Both options are plain arrays on `config.resolver`, and assigning a new array **replaces** the defaults rather than merging with them. Three habits avoid surprises: - **Append, do not assign**: `config.resolver.assetExts.push('glb')` keeps every default image and font type; `config.resolver.assetExts = ['glb']` silently breaks every PNG in the app. - **Move, do not duplicate**: when an extension changes lists, filter it out of one array and push it onto the other in the same config. - **Restart Metro** after changing either list, because the config file is read when the dev server starts. In a bare React Native project these edits go into the object passed to `mergeConfig` alongside `getDefaultConfig` from `@react-native/metro-config`; in an Expo project they go onto the object returned by `getDefaultConfig` from `expo/metro-config`. ## Only what you import is shipped Metro does not copy every file with an asset extension. An asset reaches the bundle only if some module requires it, which is why `require` with a string built at runtime, such as `require('./flags/' + code + '.png')`, fails with an "Invalid call" error: Metro resolves import strings at build time and needs a literal. ## Interview checkpoints - `json` is a **source** extension, so `require('./data.json')` returns the parsed object, not an asset ID. - `svg` is an **asset** by default: without extra configuration it is an image file reference, not a component. - Fonts (`ttf`, `otf`) are assets too; loading them into the text system is a separate runtime step.

  • Why does require('./flags/' + code + '.png') fail in React Native?
    Metro resolves every `require` and `import` string at build time to decide what goes into the bundle. A string assembled at runtime cannot be resolved ahead of time, so Metro rejects it. The usual fix is an explicit map of literal `require` calls, one per flag, and a lookup by code.
  • What do you change to import an .svg file as a component instead of an image?
    Two things: move `svg` out of `resolver.assetExts` and into `resolver.sourceExts`, so the resolver treats it as code, and configure a transformer that converts SVG markup into a JavaScript component, since Babel alone cannot parse it. Changing only the resolver lists makes Metro fail to parse the file.

saying these in an interview costs you the question

  • Thinks require of a PNG returns a file path or URI string
  • Believes Metro bundles every image in the project folder
  • Expects to reference [email protected] explicitly for Retina screens
  • Replaces assetExts with a one-item list and loses the defaults
  • Assumes adding svg to sourceExts alone makes SVGs importable components
open as a page

In a monorepo, a React Native app importing a shared UI package crashes with an invalid hook call; how does Metro resolution cause it, and how do you fix it?

level: seniorimportance: must knowfreq 44%

basics

~20 s

Metro resolves react from each importing file's own folder upwards, so a copy nested in packages/ui/node_modules is bundled beside the app's copy. Make react a peer dependency and dedupe it, or pin react and react-native in resolveRequest or blockList the nested copy.

open as a page

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

level: middleimportance: should knowfreq 32%

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.

open as a page

Since Metro resolves package.json exports by default, why might a React Native library's platform-specific file stop loading, and how do condition names help?

level: seniorimportance: should knowfreq 28%

basics

~20 s

With unstable_enablePackageExports on by default since Metro 0.82 (React Native 0.79), a subpath matched in exports resolves to its exact target, without .ios/.android or extension expansion. Libraries must route platforms through conditions: React Native asserts react-native, and Metro always asserts default plus import or require.

open as a page

In Metro, why can Button.js be loaded on iOS even though Button.ios.tsx exists next to it?

level: middleimportance: nice to knowfreq 16%

basics

~10 s

Metro's resolver loops over sourceExts in order and, for each extension, tries .ios, then .native, then the plain name. With the default order js before tsx, Button.js matches before Button.ios.tsx is ever tried.

open as a page