This is documentation for the next SDK version. For up-to-date documentation, see the latest version (SDK 52).

metro.config.js

Edit this page

A reference of available configurations in Metro.


See more information about metro.config.js in the customizing Metro guide.

Environment variables

Expo CLI can load environment variables from .env files. Learn more about how to use environment variables in Expo CLI in the environment variables guide.

EAS CLI uses a different mechanism for environment variables, except when it invokes Expo CLI for compiling and bundling. Learn more about environment variables in EAS.

If you are migrating an older project, then you should ignore local env files by adding the following to your .gitignore:

.gitignore
# local env files
.env*.local

Disabling dotenv files

Dotenv file loading can be fully disabled in Expo CLI by enabling the EXPO_NO_DOTENV environment variable, before invoking any Expo CLI command.

Terminal
# All users can run cross-env, followed by the Expo CLI command
- npx cross-env EXPO_NO_DOTENV=1 expo start
# Alternatively, macOS and Linux users can define the environment variable, then run npx, followed by the Expo CLI command
- EXPO_NO_DOTENV=1 npx expo start

Disabling EXPO_PUBLIC_-prefixed client environment variables

Environment variables prefixed with EXPO_PUBLIC_ will be exposed to the app at build-time. For example, EXPO_PUBLIC_API_KEY will be available as process.env.EXPO_PUBLIC_API_KEY.

Client environment variable inlining can be disabled with the environment variable EXPO_NO_CLIENT_ENV_VARS=1, this must be defined before any bundling is performed.

Terminal
# All users can run cross-env, followed by the Expo CLI command
- npx cross-env EXPO_NO_CLIENT_ENV_VARS=1 expo start
# Alternatively, macOS and Linux users can define the environment variable, then run npx, followed by the Expo CLI command
- EXPO_NO_CLIENT_ENV_VARS=1 npx expo start

CSS

CSS support is under development and currently only works on web.

Expo supports CSS in your project. You can import CSS files from any component. CSS Modules are also supported.

CSS support is enabled by default. You can disable the feature by setting isCSSEnabled in the Metro config.

metro.config.js
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname, {
  // Disable CSS support.
  isCSSEnabled: false,
});

To enable CSS support, set isCSSEnabled to true in the Metro config.

metro.config.js
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname, {
  isCSSEnabled: true,
});

Global CSS

Global styles are web-only, usage will cause your application to diverge visually on native.

You can import a CSS file from any component. The CSS will be applied to the entire page.

Here, we'll define a global style for the class name .container:

styles.css
.container {
  background-color: red;
}

We can then use the class name in our component by importing the stylesheet and using .container:

App.js
import './styles.css';
import { View } from 'react-native';

export default function App() {
  return (
    <>
      {/* Use `className` to assign the style with React DOM components. */}
      <div className="container">Hello World</div>

      {/* Use `style` with the following syntax to append class names in React Native for web. */}
      <View
        style={{
          $$css: true,
          _: 'container',
        }}>
        Hello World
      </View>
    </>
  );
}

You can also import stylesheets that are vendored in libraries, just like you would any node module:

index.js
// Applies the styles app-wide.
import 'emoji-mart/css/emoji-mart.css';
  • On native, all global stylesheets are automatically ignored.
  • Hot reloading is supported for global stylesheets, simply save the file and the changes will be applied.

CSS Modules

CSS Modules for native are under development and currently only work on web.

CSS Modules are a way to scope CSS to a specific component. This is useful for avoiding naming collisions and for ensuring that styles are only applied to the intended component.

In Expo, CSS Modules are defined by creating a file with the .module.css extension. The file can be imported from any component. The exported value is an object with the class names as keys and the web-only scoped names as the values. The import unstable_styles can be used to access react-native-web-safe styles.

CSS Modules support platform extensions to allow you to define different styles for different platforms. For example, you can define a module.ios.css and module.android.css file to define styles for Android and iOS respectively. You'll need to import without the extension, for example:

App.js
// Importing `./App.module.ios.css`:
- import styles from './App.module.css';
+ import styles from './App.module';

Flipping the extension, for example, App.ios.module.css will not work and result in a universal module named App.ios.module.

You cannot pass styles to the className prop of a React Native or React Native for web component. Instead, you must use the style prop.

App.js
import styles, { unstable_styles } from './App.module.css';

export default function Page() {
  return (
    <>
      <Text
        style={{
          // This is how react-native-web class names are applied
          $$css: true,
          _: styles.text,
        }}>
        Hello World
      </Text>
      <Text style={unstable_styles.text}>Hello World</Text>
      {/* Web-only usage: */}
      <p className={styles.text}>Hello World</p>
    </>
  );
}
App.module.css
.text {
  color: red;
}
  • On web, all CSS values are available. CSS is not processed or auto-prefixed like it is with the React Native Web StyleSheet API. You can use postcss.config.js to autoprefix your CSS.
  • CSS Modules use lightningcss under the hood, check the issues for unsupported features.

PostCSS

Changing the Post CSS or browserslist config will require you to clear the Metro cache: npx expo start --clear | npx expo export --clear.

PostCSS can be customized by adding a postcss.config.json file to the root of your project. This file should export a function that returns a PostCSS configuration object. For example:

postcss.config.json
{
  "plugins": {
    "autoprefixer": {}
  }
}

Both postcss.config.json and postcss.config.js are supported, but postcss.config.json enables better caching.

SASS

Expo Metro has partial support for SCSS/SASS.

To setup, install the sass package in your project:

Terminal
- yarn add -D sass

Then, ensure CSS is setup in the metro.config.js file.

  • When sass is installed, then modules without extensions will be resolved in the following order: scss, sass, css.
  • Only use the intended syntax with sass files.
  • Importing other files from inside a scss/sass file is not currently supported.

Tailwind

Standard Tailwind CSS supports only web platform. For universal support, use a library such as NativeWind, which allows creating styled React Native components with Tailwind CSS.
Tailwind CSS

Learn how to configure and use Tailwind CSS in your Expo project.

Extending the Babel transformer

Expo's Metro config uses a custom transformer.babelTransformerPath value to ensure expo-babel-preset is always used and web/Node.js environments are supported.

If you want to extend the Babel transformer, import the upstream transformer from @expo/metro-config/babel-transformer instead of metro-react-native-babel-transformer. For example:

metro.transformer.js
const upstreamTransformer = require('@expo/metro-config/babel-transformer');

module.exports.transform = async ({ src, filename, options }) => {
  // Do something custom for SVG files...
  if (filename.endsWith('.svg')) {
    src = '...';
  }
  // Pass the source through the upstream Expo transformer.
  return upstreamTransformer.transform({ src, filename, options });
};

Custom resolving

Expo CLI extends the default Metro resolver to add features like Web, Server, and tsconfig aliases support. You can similarly customize the default resolution behavior of Metro by chaining the config.resolver.resolveRequest function.

metro.config.js
const { getDefaultConfig } = require('expo/metro-config');

/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);

config.resolver.resolveRequest = (context, moduleName, platform) => {
  if (moduleName.startsWith('my-custom-resolver:')) {
    // Logic to resolve the module name to a file path...
    // NOTE: Throw an error if there is no resolution.
    return {
      filePath: 'path/to/file',
      type: 'sourceFile',
    };
  }

  // Ensure you call the default resolver.
  return context.resolveRequest(context, moduleName, platform);
};

module.exports = config;

Unlike traditional bundlers, Metro shared the same resolver function across all platforms. As a result, you can mutate the resolution settings dynamically on each request with the context object.

Mocking modules

If you want a module to be empty for a given platform, you can return a type: 'empty' object from the resolver. The following example will cause lodash to be empty on web:

metro.config.js
const { getDefaultConfig } = require('expo/metro-config');

/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);

config.resolver.resolveRequest = (context, moduleName, platform) => {
  if (platform === 'web' && moduleName === 'lodash') {
    return {
      type: 'empty',
    };
  }

  // Ensure you call the default resolver.
  return context.resolveRequest(context, moduleName, platform);
};

module.exports = config;

This technique is equivalent to using empty externals in Webpack or Vite, but with the added benefit of being able to target specific platforms.

Virtual modules

Metro doesn't support virtual modules at the moment. One technique you can use to obtain similar behavior is to create a module in the node_modules/.cache/... directory and redirect the resolution to that file.

The following example will create a module at node_modules/.cache/virtual/virtual-module.js and redirect the resolution of virtual:my-module to that file:

metro.config.js
const path = require('path');
const fs = require('fs');

const { getDefaultConfig } = require('expo/metro-config');

/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);

const virtualPath = path.resolve(__dirname, 'node_modules/.cache/virtual/virtual-module.js');

// Create the virtual module in a generated directory...
fs.mkdirSync(path.dirname(virtualPath), { recursive: true });
fs.writeFileSync(virtualPath, 'export default "Hello World";');

config.resolver.resolveRequest = (context, moduleName, platform) => {
  if (moduleName === 'virtual:my-module') {
    return {
      filePath: virtualPath,
      type: 'sourceFile',
    };
  }

  // Ensure you call the default resolver.
  return context.resolveRequest(context, moduleName, platform);
};

module.exports = config;

This can be used to emulate externals with custom imports. For example, if you want to redirect require('expo') to something custom like SystemJS.require('expo'), you can create a virtual module that exports SystemJS.require('expo') and redirect the resolution of expo to that file.

Custom transforming

Transformations are heavily cached in Metro. If you update something, use the --clear flag to see updates. For example, npx expo start --clear.

Metro doesn't have a very expressive plugin system for transforming files, instead opt to use the babel.config.js and caller object to customize the transformation.

babel.config.js
module.exports = function (api) {
  // Get the platform that Expo CLI is transforming for.
  const platform = api.caller(caller => (caller ? caller.platform : 'ios'));

  // Detect if the bundling operation is for Hermes engine or not, e.g. `'hermes'` | `undefined`.
  const engine = api.caller(caller => (caller ? caller.engine : null));

  // Is bundling for a server environment, e.g. API Routes.
  const isServer = api.caller(caller => (caller ? caller.isServer : false));

  // Is bundling for development or production.
  const isDev = api.caller(caller =>
    caller
      ? caller.isDev
      : process.env.BABEL_ENV === 'development' || process.env.NODE_ENV === 'development'
  );

  // Ensure the config is not cached otherwise the platform will not be updated.
  api.cache(false);
  // You can alternatively provide a more robust CONFIG cache invalidation:
  // api.cache.invalidate(() => platform);

  return {
    presets: ['babel-preset-expo'],
    plugins: [
      // Add a plugin based on the platform...
      platform === 'web' && 'my-plugin',

      // Ensure you filter out falsy values.
    ].filter(Boolean),
  };
};

If the caller doesn't have engine, platform, bundler, and so on, then ensure you are using @expo/metro-config/babel-transformer for the transformer. If you're using a custom transformer then it may need to extend the Expo transformer.

Always try to implement custom logic in the resolver if possible, caching is much simpler and easier to reason about. For example, if you need to remap an import, it's simpler and faster to resolve to a static file with the resolver than to parse all possible import methods and remap them with the transformer.

Always use babel-preset-expo as the default Babel preset, this ensures the transformation is always compatible with Expo runtimes. babel-preset-expo uses all of the caller inputs internally to optimize for a given platform, engine, and environment.

Node.js built-ins

When bundling for a server environment, Expo's Metro config automatically supports externalizing Node.js built-in modules (fs, path, node:crypto, and more) based on the current Node.js version. If the CLI is bundling for a browser environment, then built-ins will first check if the module is installed locally, then fallback on an empty shim. For example, if you install path for use in the browser, this can be used, otherwise, the module will automatically be skipped.

Environment settings

Expo's Metro config injects build settings that can be used in the client bundle via environment variables. All variables will be inlined and cannot be used dynamically. For example, process.env["EXPO_BASE_URL"] won't work.

  • process.env.EXPO_BASE_URL exposes the base URL defined in experiments.baseUrl. This is used in Expo Router to respect the production base URL for deployment.

These environment variables will not be defined in test environments!

Bundle splitting

This feature is web-only in SDK 50.

In SDK 50, Expo CLI automatically splits web bundles into multiple chunks based on async imports in production. This feature requires @expo/metro-runtime to be installed and imported somewhere in the entry bundle (available by default in Expo Router).

Shared dependencies of async bundles are merged into a single chunk to reduce the number of requests. For example, if you have two async bundles that import lodash, then the library is merged into a single initial chunk.

As of SDK 50, the chunk splitting heuristic cannot be customized. For example:

math.js
index.js
math.js
export function add(a, b) {
  return a + b;
}
index.js
import '@expo/metro-runtime';

// This will be split into a separate chunk.
import('./math').then(math => {
  console.log(math.add(1, 2));
});

When you run npx expo export -p web, the bundles are split into multiple files, and the entry bundle is added to the main HTML file. @expo/metro-runtime adds the runtime code that loads and evaluates the async bundles.

Source map debug ID

Available from SDK 50 on all platforms.

If a bundle is exported with an external source map, a Debug ID annotation will be added to the end of the file, along with a matching debugId in the source map for corresponding the files together. If no source maps are exported, or inline source maps are used then this annotation will not be added.

// <all source code>

//# debugId=<deterministic chunk hash>

The associated *.js.map or *.hbc.map source map will be a JSON file containing an equivalent debugId property. The debugId will be injected before hermes bytecode generation to ensure matching in all cases.

The debugId is a deterministic hash of the bundle's contents without the external bundle splitting references. This is the same value used to create a chunks filename but formatted as a UUID. For example, 431b98e2-c997-4975-a3d9-2987710abd44.

@expo/metro-config injects debugId during npx expo export and npx expo export:embed. Any additional optimization steps in npx expo export:embed like Hermes bytecode generation will need to have the debugId injected manually.

Metro require runtime

You can optionally enable a custom Metro require implementation with the environment variable EXPO_USE_METRO_REQUIRE=1. This runtime has the following features:

  • String module IDs that are human-readable and make missing module errors easier to follow.
  • Deterministic IDs that are the same between runs and across modules (required for React Server Components in development).
  • Removed support for legacy RAM bundles.

Magic import comments

Available from SDK 52 on all platforms.

Server environments such as Workers, and Node.js support import arbitrary files at runtime, so you may want to keep import syntax in-tact instead of using Metro's require system. You can opt-out dynamic imports with the /* @metro-ignore */ comment in import() statements.

// Manually ensure `./my-module.js` is included in the correct spot relative to the module.
const myModule = await import(/* @metro-ignore */ './my-module.js');

Expo CLI will skip the ./my-module.js dependency and assume that the developer has manually added it to the output bundle. Internally, this is used for exporting custom server code that dynamically switches between files based on the request. Avoid using this syntax for native bundles since import() is generally not available in React Native with Hermes enabled.

Many React libraries shipped the Webpack /* webpackIgnore: true */ comment to achieve similar behavior. To bridge the gap, we've also added support for Webpack's comment but recommend using the Metro equivalent in your app.

Asset imports

When assets are imported, a virtual module is created to represent the data required for importing the asset.

On native platforms, an asset will be a numeric ID: 1, 2, 3, and so on, which can be looked up using require("@react-native/assets-registry/registry").getAssetByID(<NUMBER>). On web and server platforms, the asset will change depending on the file type. If the file is an image, then the asset will be { uri: string, width?: number, height?: number }, otherwise the asset will be a string representing the remote URL for the asset.

The assets can be used as follows:

import { Image } from 'react-native';

import asset from './img.png';

function Demo() {
  return <Image source={asset} />;
}

In API routes, you can always assume the type of the asset will not be a number:

import asset from './img.png';

export async function GET(req: Request) {
  const ImageData = await fetch(
    new URL(
      // Access the asset URI.
      asset.uri,
      // Append to the current request URL origin.
      req.url
    )
  ).then(res => res.arrayBuffer());

  return new Response(ImageData, {
    headers: {
      'Content-Type': 'image/png',
    },
  });
}

Bare workflow setup

This guide is versioned and will need to be revisited when upgrading/downgrading Expo. Alternatively, use Expo Prebuild for fully automated setup.

Projects that don't use Expo Prebuild must configure native files to ensure the Expo Metro config is always used to bundle the project.

These modifications are meant to replace npx react-native bundle and npx react-native start with npx expo export:embed and npx expo start respectively.

metro.config.js

Ensure the metro.config.js extends expo/metro-config:

const { getDefaultConfig } = require('expo/metro-config');

const config = getDefaultConfig(__dirname);

module.exports = config;

android/app/build.gradle

The Android app/build.gradle must be configured to use Expo CLI for production bundling. Modify the react config object:

react {
  ...
+     // Use Expo CLI to bundle the app, this ensures the Metro config
+     // works correctly with Expo projects.
+     cliFile = new File(["node", "--print", "require.resolve('@expo/cli')"].execute(null, rootDir).text.trim())
+     bundleCommand = "export:embed"
}

ios/<Project>.xcodeproj/project.pbxproj

In your ios/<Project>.xcodeproj/project.pbxproj file, replace the following scripts:

"Start Packager"

Remove the "Start Packager" script in SDK 50 and higher. The dev server must be started with npx expo before/after running the app.

-    FD10A7F022414F080027D42C /* Start Packager */ = {
-			isa = PBXShellScriptBuildPhase;
-			alwaysOutOfDate = 1;
-			buildActionMask = 2147483647;
-			files = (
-			);
-			inputFileListPaths = (
-			);
-			inputPaths = (
-			);
-			name = "Start Packager";
-			outputFileListPaths = (
-			);
-			outputPaths = (
-			);
-			runOnlyForDeploymentPostprocessing = 0;
-			shellPath = /bin/sh;
-			shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n  source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n  source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n  source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\nexport RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > `$NODE_BINARY --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/.packager.env'\"`\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n  if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n    if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n      echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n      exit 2\n    fi\n  else\n    open `$NODE_BINARY --print \"require('path').dirname(require.resolve('expo/package.json')) + '/scripts/launchPackager.command'\"` || echo \"Can't start packager automatically\"\n  fi\nfi\n";
-			showEnvVarsInLog = 0;
-		};

"Bundle React Native code and images"

+			shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n  source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n  source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n  export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n  # Set the entry JS file using the bundler's entry resolution.\n  export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n  # Use Expo CLI\n  export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli')\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n  # Default Expo CLI command for bundling\n  export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";

Alternatively, in the Xcode project, select the "Bundle React Native code and images" build phase and add the following modifications:

if [[ -f "$PODS_ROOT/../.xcode.env" ]]; then
  source "$PODS_ROOT/../.xcode.env"
fi
if [[ -f "$PODS_ROOT/../.xcode.env.local" ]]; then
  source "$PODS_ROOT/../.xcode.env.local"
fi

# The project root by default is one level up from the ios directory
export PROJECT_ROOT="$PROJECT_DIR"/..

if [[ "$CONFIGURATION" = *Debug* ]]; then
  export SKIP_BUNDLING=1
fi
+ if [[ -z "$ENTRY_FILE" ]]; then
+   # Set the entry JS file using the bundler's entry resolution.
+   export ENTRY_FILE="$("$NODE_BINARY" -e "require('expo/scripts/resolveAppEntry')" "$PROJECT_ROOT" ios absolute | tail -n 1)"
+ fi

+ if [[ -z "$CLI_PATH" ]]; then
+   # Use Expo CLI
+   export CLI_PATH="$("$NODE_BINARY" --print "require.resolve('@expo/cli')")"
+ fi
+ if [[ -z "$BUNDLE_COMMAND" ]]; then
+   # Default Expo CLI command for bundling
+   export BUNDLE_COMMAND="export:embed"
+ fi

`"$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'"`

You can set CLI_PATH, BUNDLE_COMMAND, and ENTRY_FILE environment variables to overwrite these defaults.

Custom entry file

By default, React Native only supports using a root index.js file as the entry file (or platform-specific variation like index.ios.js). Expo projects allow using any entry file, but this requires addition bare setup.

Development

Development mode entry files can be enabled by using the expo-dev-client package. Alternatively you can add the following configuration:

In the ios/[project]/AppDelegate.mm file:

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
#if DEBUG
-  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
+  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"];
#else
  return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}

In the android/app/src/main/java/**/MainApplication.java:

@Override
protected String getJSMainModuleName() {
-  return "index";
+  return ".expo/.virtual-metro-entry";
}

Production

In your ios/<Project>.xcodeproj/project.pbxproj file, replace the "Bundle React Native code and images" script to set $ENTRY_FILE according using Metro:

+			shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n  source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n  source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n  export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n  # Set the entry JS file using the bundler's entry resolution.\n  export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n  # Use Expo CLI\n  export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli')\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n  # Default Expo CLI command for bundling\n  export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";

The Android app/build.gradle must be configured to use Metro module resolution to find the root entry file. Modify the react config object:

+ def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()

react {
+    entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
}