Skip to main content

Bundling

When you're done here, you'll be able to bundle and ship your apps with Metro, the React Native bundler. The tools you'll use in this guide enhance Metro, making it easier to use, type-safe, and fast.

This guide is meant to be used with one React Native app at a time, though you can repeat it for all of your apps.

Setting up the Tools

Install the tools you'll need for bundling.

yarn add @rnx-kit/cli @rnx-kit/babel-preset-metro-react-native @rnx-kit/metro-config @rnx-kit/metro-resolver-symlinks --dev

Add @rnx-kit/babel-preset-metro-react-native as a preset in your Babel configuration:

babel.config.js
module.exports = {
presets: ["@rnx-kit/babel-preset-metro-react-native"],
};

Configure Metro using @rnx-kit/metro-config. Add support for symlinks, too, since all major package managers make use of them.

metro.config.js
const { makeMetroConfig } = require("@rnx-kit/metro-config");
const MetroSymlinksResolver = require("@rnx-kit/metro-resolver-symlinks");

module.exports = makeMetroConfig({
resolver: {
resolveRequest: MetroSymlinksResolver(),
},
// Add your existing Metro configuration here! It will be merged in.
});

If your app was already using Metro, it may have mappings in its config which compensate for Metro's lack of symlink support. They probably look something like this:

const fooLocal = path.resolve(__dirname, "node_modules", "foo");
const barHoisted = path.resolve(__dirname, "..", "..", "node_modules", "foo");
{
resolver: {
extraNodeModules: {
"foo": fooLocal,
"bar": barHoisted
},
},
watchFolders: [fooLocal, barHoisted],
}

Good news! You can get rid of all these mappings now! 🎉

Creating Bundler Commands

The bundler is integrated with the React Native CLI:

react-native rnx-bundle ...
react-native rnx-start ...

It's time to put together an rnx-bundle command for your app. Start with react-native rnx-bundle, and add the parameters listed in this table:

ParameterExplanation
--entry-fileThe source file at the root of your app. It usually has a call to AppRegistry.registerComponent.
--platformPick your target platform: ios, android, macos, or windows.
--bundle-outputChoose a file name and path for the bundle file.
--sourcemap-outputChoose a file name and path for the source map. Usually named <bundle-file>.map.
--assets-destChoose a directory for storing assets like strings, images, audio, and video.

Add --dev false if you want to make an optimized, production bundle.

Put it all together into a single command. Here's an example which creates an iOS developer bundle:

react-native rnx-bundle \
--entry-file index.js \
--platform ios \
--bundle-output ios/main.jsbundle \
--sourcemap-output ios/main.jsbundle.map \
--assets-dest ios

If you're curious about the rest of the command-line parameters, you can find the complete list in the CLI bundle documentation.

Now is a good time to try out your rnx-bundle command.

Are you seeing TypeScript warnings or errors, or messages about duplicate or cyclic dependencies? That's ok! The tools in this guide enhance Metro to add type safety and protection from dependency problems. They're helping you root out potential bugs in your app. Ignore them for now. A bit later, you'll learn what they mean, why they're important, and how you can fix them.

The next step is putting together an rnx-start command for your app. Parameters aren't typically needed for this command. You should take a look at the CLI server documentation to see the full list of command-line parameters, and decide if you need any of them.

Here's an example command which starts a Metro bundle server:

react-native rnx-start

Try out your rnx-start server command!

You might see warnings or errors, as you did when you ran the rnx-bundle command. Ignore them for now. You'll work through them a bit later.

Assign both rnx-bundle and rnx-start commands to scripts in your app. A good practice is to have one bundle script per platform, an "umbrella" bundle script which runs them all, and one server command.

package.json
{
"scripts": {
"bundle": "yarn bundle:ios && yarn bundle:android",
"bundle:ios": "react-native rnx-bundle --platform ios ...",
"bundle:android": "react-native rnx-bundle --platform android ...",
"start": "react-native rnx-start"
}
}

Using App Configuration

Now you're getting to the good stuff! 🍰

Your app knows how to bundle itself. It knows which platforms it supports. It knows what to name the bundle file, and where to store assets, so that the native host app can find them at runtime. And it knows how these things vary across each platform.

All of this knowledge is encoded in the command-lines you've written for your app.

App configuration is a declarative, structured way to express these decisions as defaults for how your app is bundled. All the knowledge is captured in one easy-to-read format, without duplication.

Take a look at this example iOS bundling command:

react-native rnx-bundle \
--entry-file index.js \
--platform ios \
--bundle-output ios/main.jsbundle \
--sourcemap-output ios/main.jsbundle.map \
--assets-dest ios

Here's what it looks like as app defaults in configuration:

package.json
{
"rnx-kit": {
"bundle": {
"entryFile": "index.js",
"targets": ["ios"],
"platforms": {
"ios": {
"bundleOutput": "ios/main.jsbundle",
"sourcemapOutput": "ios/main.jsbundle.map",
"assetsDest": "ios"
}
}
}
}
}

And here's what the command reduces to, once the defaults are in place:

react-native rnx-bundle

You can override default using command-line parameters, either in your terminal or in package scripts:

react-native rnx-bundle --entry-file test.js

Now it's time to convert your rnx-bundle commands into app defaults. Use the example above as a guide. Refer to the BundleConfig schema for a complete list of configuration properties.

Do the same thing for your rnx-start command, but only if you added command-line parameters. Use the ServerConfig schema as a reference. If you don't needing any ServerConfig, the server will use bundler defaults so you end up serving the same way you bundle.

Beyond the Command-Line

Remember that bit at the start of this guide about enhancing Metro? You've arrived. 🚗

This section of the guide will introduce you to a number of features and explain why they are important. Most of them are on by default. You can explicitly control each one through app configuration.

Type Safety with TypeScript

TypeScript validation with Metro. 😎

When you bundle or serve your app, TypeScript will validate your code and tell you about any type safety issues. You'll see the familiar, colorful TS messages you're used to, right in the Metro output:

Image showing TypeScript errors. Foo is declared but never used. Parameter x implicitly has an 'any' type.

TypeScript validation is on by default. You can control it with the typescriptValidation configuration property.

package.json
{
"rnx-kit": {
"bundle": {
"typescriptValidation": true
}
}
}

Tree Shaking

Tree shaking makes your app bundle smaller by eliminating unused code. Sometimes, a LOT smaller. 📉

Tree shaking is off by default. You can control it with the treeShake configuration property.

package.json
{
"rnx-kit": {
"bundle": {
"treeShake": true
}
}
}

Duplicate Dependencies

Did you know that your app bundle can have multiple copies of a single package in it? Yes, this is a thing that happens. Sometimes, the duplicates are even the same version of the package! 📦 📦 📦

Clearly, this is bad because the bundle is larger than it needs to be. More importantly, though, duplicates can lead to runtime problems, especially when the duplicated package has global state.

Image showing a duplicate dependency error. The package named react-is has been included in the bundle twice. Once as version 16.13.1, and once as version 17.0.2.

Duplicate detection is on by default. You can control it with the detectDuplicateDependencies configuration property.

package.json
{
"rnx-kit": {
"bundle": {
"detectDuplicateDependencies": true
}
}
}

Cyclic Dependencies

If your dependency graph has a cycle (loop), then you are at risk for app hangs. 🔄

A cycle in your dependency graph will look somthing like this: logger requires file-appender which requires fs-utils which then requires logger.

Image showing a cyclic dependency error. Test-app requires the logger package, which requires file-appender, which requires fs-utils, which requires logger again.

Cycles can be bad news. Detecting them is on by default. You can control it with the detectCyclicDependencies configuration property.

package.json
{
"rnx-kit": {
"bundle": {
"detectCyclicDependencies": true
}
}
}

Running the Bundler

It's now time to get bundling and serving working for your app. This part of the guide will help you work through issues related to type safety, duplicate dependencies, and cyclic dependencies.

Run the bundler:

yarn bundle

Start the bundle server, and request a bundle from it using your native host app:

yarn start

Did both commands succeed? If so, great! Skip to the next section.

Are you seeing TypeScript errors?

Type safety matters. It lets you reason about your app without running it to find flaws, see patterns, and do automated transformations.

You should fix these now, or plan on fixing them soon. To defer until later, configure the bundler so it reports the errors without failing. Set throwOnError to false in the typescriptValidation configuration object. It's also a good idea to open a new issue for tracking the type errors, and to remind you to re-enable throwOnError. In the meantime, keeping the errors visible in the Metro output helps everyone be aware of danger-areas in the app.

Are you running into duplicate package errors?

You may be wondering how this even happens. Metro sees each copy of a duplicate package as unique because it has a unique filesystem path. How does that happen? pnpm isolation does this by design when versions differ or peerDependencies vary in parent packages. yarn hoisting also does this by design, allowing for a hoisted version and different local versions.

So what can you do? The goal is to "collapse" the duplicate packages down to a single version. That means getting yarn/pnpm to look at all the places a duplicate package is named, and decide that one single version can be used to satisfy all constraints. That will lead to only one copy showing up on the filesystem.

Below are some helpful tricks you can try:

  • (Easy) Make sure your packages all depend on the same version of the duplicate package.
  • (Easy) If you are using yarn, run the yarn-deduplicate package to collapse away duplicate entries in your yarn.lock file. These aren't cleaned up automatically as yarn runs.
  • (Hard) In your lock file, find the set of external packages that are pulling in the duplicate package. See if you can upgrade or downgrade them so they all bring in the same version of the duplicate package. This may not be possible.
  • (Hard) If you are using pnpm, trace the duplicates through the maze of parent dependencies. If any of them have common peerDependencies, like react or react-native, make sure the semver constraints match. "^17.0.0" and "^17.0.2" will cause pnpm to fork the dependency tree to isolate (encapsulate) this difference.
  • (Easy but Dangerous). Force yarn/pnpm to use a specific version of the duplicate package whenever they see it, regardless of what is in package.json. yarn has a resolutions property which does this. pnpm has a readPackage hook which you can use to do this. Both are dangerous because you can break the packages that depend on the package version you are changing.

If you can't get rid of the duplicate package, and your app seems to be working, you can choose to live with it. Add the duplicate package name to the ignoredModules list in the detectDuplicateDependencies configuration object.

Are you seeing cyclic dependency errors?

A cycle in your dependency graph will look somthing like this: logger requires file-appender which requires fs-utils which then requires logger. The logger package depends on itself.

Cycles are bad because they can lead to an infinite loop at runtime.

Here are some approaches you can use to fix a cycle:

  • If any of the packages live in your repo, refactor them to break the cycle.
  • If they're all external packages, you can submit a PR, though it may take time to be accepted and published. If you're impatient, you can use patch-package while you wait for the PR to land.
  • Try looking for a different package, or group of packages, that does roughly what you want.

If none of this is appealing, you can take a closer look at the code involved to understand if and how an infinite loop might occur. If you can avoid triggering it, then document that in your code, and live with the cycle. Set throwOnError to false in the detectCyclicDependencies configuration object.

Integrating with PRs and Builds

You've done the hard part, and you're close to the finish line! The last step is to add automation which guards your app against bundling errors in future PRs. 🛡️

If you use Lerna, you can run one command from the root of your repo to bundle all packages:

yarn lerna run bundle

If you're using a task runner like Nx, Lage or Gulp, you can go for a more sophisticated integration. The advantage being that bundle runs as its own task with isolated logging and error handling. And, you can run it in parallel with other tasks.

You should now have a command you can run from the root of the repo which bundles every app package. Add it to the root package.json file:

package.json
{
"scripts": {
"bundle": "lerna run bundle"
}
}

And then call that top-level command from your PR and Build CI loop definitions:

GitHub Actions Example
jobs:
build:
steps:
- name: Bundle all packages
run: yarn bundle

Easy, right? Now your PRs and Builds will protect the source tree from code that breaks bundling!

Wrapping Up

You've done it! You are bundling and serving your React Native apps with Metro, using enhancements to make the process fast and safe! And your customers will thank you when there are fewer errors (type safety) and smaller bundles (tree shaking)!

Was this helpful? Spread the word on Twitter: @ReactNativeMSFT #rnxkit

See room for improvement? Please make your voice heard! 💬 Contribute your ideas in a new issue, a discussion topic, or through a pull request.