Edit this page
A reference of available configurations in Metro.
See more information about metro.config.js in the customizing Metro guide.
In SDK 49 and higher, the 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 to SDK 49 or above, then you should ignore local env files by adding the following to your .gitignore:
# local env files
.env*.local
Dotenv file loading can be fully disabled in Expo CLI by enabling the EXPO_NO_DOTENV
environment variable, before invoking any Expo CLI command.
# 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
EXPO_PUBLIC_
-prefixed client environment variablesEnvironment 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.
# 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 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.
/** @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.
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname, {
isCSSEnabled: true,
});
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
:
.container {
background-color: red;
}
We can then use the class name in our component by importing the stylesheet and using .container
:
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:
// Applies the styles app-wide.
import 'emoji-mart/css/emoji-mart.css';
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:
// 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 thestyle
prop.
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>
</>
);
}
.text {
color: red;
}
StyleSheet
API. You can use postcss.config.js
to autoprefix your CSS.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:
{
"plugins": {
"autoprefixer": {}
}
}
Both postcss.config.json
and postcss.config.js
are supported, but postcss.config.json
enables better caching.
Expo Metro has partial support for SCSS/SASS.
To setup, install the sass
package in your project:
-Â
yarn add -D sass
Then, ensure CSS is setup in the metro.config.js file.
sass
is installed, then modules without extensions will be resolved in the following order: scss
, sass
, css
.sass
files.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.
Learn how to configure and use Tailwind CSS in your Expo project.
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:
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 });
};
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.
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.
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:
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.
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:
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.
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.
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.
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.
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!
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
export function add(a, b) {
return a + b;
}
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.
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.
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.
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;
- };
+ 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
, andENTRY_FILE
environment variables to overwrite these defaults.
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 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";
}
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())
}