Articles · Technical GuideUpdated October 2026

Metro turns your whole project into one JavaScript file, and it does that job twice.

Metro is the bundler a React Native project builds with. It starts at one entry file, follows every import to build a graph of your modules, converts each one into something the phone can run, and combines the result into a single JavaScript file. While you are working it does that continuously, as a server your app fetches from over the network. When you ship, it does it once, and the file goes inside the app binary. Those two outputs are not the same file and are not built with the same options, which is the single most useful thing to know about it. Everything below comes from React Native, Expo and Metro documentation read on 17 October 2026.

You probably did not choose Metro and you will never configure it from scratch. What follows is what it is doing, the four or five times you do have to touch it, and what it costs you to keep treating the dev server and the shipped app as the same thing.

Start here

The short version

A server while you work, a single file when you ship.

Metro has one job and two modes. The job is to take your entry file, find every file it depends on, transform each of them, and serialize the lot into a bundle. The first mode is a dev server on your machine that rebuilds as you type and pushes the change into the running app. The second is a one-shot export during the build that writes a bundle the native project embeds.

Nothing about this is specific to generated code. It is how every React Native app is built, including every app a human typed by hand, which is also why it is worth half an hour of your time: the knowledge does not expire when you move off a tool. If you want the layer underneath this first, start with what React Native is. Flutter projects have no Metro at all, because they ship no JavaScript, which is one of the real differences behind Flutter against React Native.

What is Metro and what is it actually doing?

React Native's own documentation puts it in one line: React Native uses Metro to build your JavaScript code and assets. Metro describes itself as a JavaScript bundler, one that takes an entry file and options and returns a single JavaScript file containing the code and its dependencies. Both sentences are narrower than people assume. Metro does not compile the iOS or Android app. It does not sign anything, talk to a store, or know what a provisioning profile is. It handles JavaScript and assets. Xcode and Gradle handle the rest.

Inside, Metro's documentation names three stages, and every error message you will ever see from it belongs to one of them. Resolution builds a graph of the modules required from the entry point, using a resolver to find each required file on disk. Transformation passes each module through a transformer that converts it into a format the target platform understands, and it does this in parallel, scaled to the number of CPU cores on your machine. Serialization then combines those transformed modules into one or more bundles, where a bundle is defined as a set of modules combined into a single JavaScript file. Resolution and transformation run alongside each other rather than in sequence. Each of the three is a swappable component with its own responsibility.

One more piece worth knowing, because it explains a whole category of wasted afternoons: Metro keeps a local cache of transformed modules and reuses them unless the source code or the current configuration has changed since the last time they were transformed. Most of the time that is a gift. Occasionally it decides nothing changed when something did, and you spend an hour debugging code that is not the code running.

The configuration file is called metro.config.js and it sits at the root of the project. Two rules from React Native's documentation are worth reading before you touch it. Your config should extend @react-native/metro-config or @expo/metro-config, because those packages contain the essential defaults necessary to build and run React Native apps. And an exported object is merged on top of Metro's internal defaults, while exporting a function instead is an opt-in to managing the final config yourself, after which Metro applies no internal defaults at all. That second form is how people quietly break a working project.

React Native, Metro, version 0.87, last updated 12 August 2026, read 17 October 2026

What does Metro do in development, and what does it do when you ship?

In development, Metro is a web server. Expo's CLI reference says that npx expo start starts a server on http://localhost:8081 that a client can use to interact with the bundler, and that the port defaults to 8081, with a port of zero picking the first available one. The app on your simulator or phone is a client of that server. It asks for a bundle over HTTP and gets one back. That is why React Native's device guide tells you to run adb reverse tcp:8081 tcp:8081 over USB, or to type your machine's address and port into Debug server host and port for device. Without a route to your laptop, a debug build has no JavaScript to run.

When you ship, Metro runs once and then has nothing more to do. Expo's reference states it plainly in the description of the port option on npx expo run:ios and npx expo run:android: the option is only relevant for development builds, because production builds export the project and embed the files in the native binary before installing them on a device. It also says the export happens automatically when compiling the native runtime. The bundle is a file in the app by then. Nothing in a published app fetches JavaScript from you.

The options differ too, and this is where the two artefacts stop being comparable. Expo documents npx expo export with a dev flag that bundles for development without minifying or stripping __DEV__, which tells you that a normal export does both. It documents a no-minify flag for skipping minification of JavaScript and CSS assets, a no-bytecode flag for skipping Hermes bytecode generation for native platforms, and a clear flag for clearing the bundler cache before exporting. A max-workers flag caps how many tasks the bundler may spawn. Read the defaults backwards from those flags: the shipped bundle is minified, has debug code removed, and is turned into Hermes bytecode.

One more asymmetry, because it catches people building for both web and mobile. Expo CLI splits bundles on asynchronous imports, and Expo says that is web only. With Expo Router on web, the split follows the route files in the app directory. On iOS and Android you get one bundle, which means everything reachable from the entry file is in the app whether a given screen is ever opened or not.

Expo, Expo CLI reference, read 17 October 2026

Do you have to care if the app already runs?

Mostly no. A working project arrives with a metro.config.js that already extends the right defaults, and for weeks at a time the correct amount of attention to pay Metro is none. It becomes your problem on a short and predictable list of occasions, and each one has a recognisable error.

The first is a new file type. Metro needs extensions declared up front, in resolver.assetExts for files it should copy as assets and resolver.sourceExts for files it should transform, and Expo's guide to customising Metro uses pushing db onto assetExts, so a prebuilt SQLite database can be bundled, as its worked example. The second is import paths. Expo supports compilerOptions.paths and baseUrl from your tsconfig, which covers absolute imports, and anything past that is a custom resolver.resolveRequest that maps a name and then calls the default resolver. Expo adds a detail that saves real time here: resolutions are not cached, so a resolver change applies on a dev server restart without clearing anything, whereas a Babel plugin doing the same job does need a cache clear.

The third is project layout. Pulling the app into a monorepo alongside a website used to mean teaching Metro about directories outside the app folder through watchFolders. Expo says that from SDK 56 an on-demand filesystem is enabled by default and watchFolders no longer needs to list every bundled module, and that symlinked dependencies outside the project root resolve correctly. Worth checking which SDK your project is on before you copy an older recipe off the internet.

There are also limits on what you are allowed to do, which is useful to know before you plan an afternoon around a config change. Expo states that it locks some Metro options, does not support YAML configuration files, and does not support configuration files outside the repository. The generated metro.config.js template comes from npx expo customize metro.config.js, which is the honest way to get a correct starting file rather than pasting one from a blog post written against a different SDK.

Find yours

Which part of Metro you are actually looking at

Pick what you are seeing. Almost every Metro problem is one of three stages failing, and knowing which one tells you where to go.

Nothing picked. If the app builds and your edits appear within a second, Metro is doing its job and there is nothing here you need.

What does ignoring Metro actually cost you?

The expensive mistake is not a config file you never opened. It is measuring the wrong artefact. A development bundle is not minified and keeps __DEV__ in, so it carries development-only checks and warnings that the shipped app does not. Judging startup time, scroll smoothness or memory use from a simulator attached to the dev server produces a number about a bundle no customer will ever run, in either direction: some things are slower in development and some only work there. Measure a release build. The runtime side of that story, and why the engine underneath matters as much as the bundle, is Hermes and the new architecture.

The second cost is a class of bug that only appears after you submit. Code guarded by __DEV__ is gone in the export. A dependency that worked because the dev server happened to resolve it from a sibling directory may not resolve during the build. A test fixture imported for convenience ships inside the bundle. None of these show up in the simulator, all of them show up in TestFlight or on an internal testing track, and by then the feedback loop is a build upload rather than a save.

The third is size, and it is the one with a bill attached. The serializer puts the whole graph into one file, and on iOS and Android there is no automatic split to save you. A chart library added for one screen is in the bundle for every screen. Nothing warns you, because the app still works. It just gets slower to download and slower to start, on exactly the phones least able to absorb it.

The fourth is the most human. Metro's cache means your edit sometimes does not appear, and the instinct is to doubt your code. React Native's Fast Refresh page, last updated 12 August 2026, gives the other half of the explanation. Fast Refresh is on by default, most edits should land within a second or two, a module exporting only components is updated on its own, and a module exporting other values is re-run along with everything that imports it. A file imported by modules outside the React tree forces a full reload instead, and the documented fix is to move the shared value into its own file. Class components do not keep their state through a refresh, and hooks with dependency lists always re-run during one, with their dependencies ignored for that pass. Knowing which of those you are in saves more time than any config change on this page.

The dev server and the shipped bundle, side by side

What you are asking aboutDevelopment, the Metro serverRelease, the exported bundleSame in both
Where the JavaScript comes fromFetched from the dev server, port 8081 by defaultA file embedded in the native binaryNo
How often Metro runsContinuously, for as long as you are editingOnce, during the buildNo
MinifiedNo, a development bundle is not minifiedYes, unless you pass no-minifyNo
The __DEV__ flagLeft in, so debug-only code runsStripped, unless you pass devNo
Your edits appear without rebuildingYes, that is Fast RefreshNo, you build againNo
Reads metro.config.jsYesYesYes
Needs your machine reachableYes, over USB or the same networkNo, nothing phones home for codeNo

Where this lands if a builder generated your app

Metro behaves identically whether a person typed the project or an agent did. There is a metro.config.js at the root, it extends expo/metro-config, and the three stages run the same way. The one difference worth naming is that a generated project is often the first React Native project its owner has seen, so the first Unable to resolve module message arrives with no mental model behind it. That is the whole reason this page exists.

Newly generates a real React Native and Expo project you own, with two-way GitHub sync, so the config file is in your repository and an edit you make there is an edit in the project rather than a setting in somebody else's dashboard. It uploads iOS builds to TestFlight, publishes to Google Play internal testing, and builds a standalone Android APK. Every one of those is a release path, which means every one of them carries an exported bundle rather than anything served from a dev server. Newly is $25 a month, with no free tier. If you are costing the whole exercise, the store fees and the rest are in what it costs to publish an app.

The honest limits. Newly does not submit to the App Store for you, because Apple requires the content owner to submit. And no builder, Newly included, can tell you whether the thing you measured was the dev bundle or the real one. That is on you, and the check is cheap: if your machine had to be reachable for the app to start, you were looking at Metro the server, not the file your customers will run.

Questions people ask about the Metro bundler

Metro is the JavaScript bundler that React Native projects build with. Its own documentation describes it as a JavaScript bundler that takes an entry file and options and returns a single JavaScript file containing the code and its dependencies. React Native's documentation says React Native uses Metro to build your JavaScript code and assets. It is not a compiler for the native app, and it is not Xcode or Gradle. It handles the JavaScript and the assets, and nothing else.

Open metro.config.js once, then go and measure a release build

Two things take ten minutes and pay for themselves. Read the config file so the name is not a mystery the first time an error mentions it. Then install a release build on a real phone, and trust that one for every number you report to anybody.

Start building