Combining Re.Pack with Expo Router
August 18, 2025 — 11 min read
TLDR: ceopaludetto/expo-router-repack-template
I recently started building a React Native app and came across the possibility of using a bundler that was faster and more efficient than Metro, especially for larger projects. I’m referring to Re.Pack, an alternative bundler based on Rspack that promises significant improvements to build and reload performance.
Besides being faster, Re.Pack also offers a range of interesting features, such as Module Federation, Virtual Modules, advanced Tree Shaking, and the ability to use plugins from the Rspack/Webpack ecosystem.
However, while Re.Pack is an excellent alternative to Metro, I wanted to keep the integration with Expo Router, which is one of my favorite tools for React Native development. This post provides a step-by-step guide on how to replace Metro with Re.Pack in a React Native project using Expo Router.
Before getting started, it’s important to note that while it’s possible to use Re.Pack with Expo, this setup is not officially supported. You may encounter issues or limitations along the way. Additionally, tools like Expo Go, Expo CLI, and EAS won’t work with Re.Pack, so you’ll need to use a native build to test your app on an emulator/device.
Prerequisites
- Experience with React Native, Expo, and Expo Router.
- Native environment configured (Android Studio/SDKs, Xcode, and CocoaPods).
- Preferred package manager.
By the end of this guide, you’ll have a project structure similar to this:
Creating the project and installing dependencies
We’ll create a new Expo project from scratch and install the required dependencies for React Native, Expo, and Expo Router.
npm add expo
npm add -D @react-native-community/cli @types/react
npx expo install expo-router expo-linking react react-native react-native-screens react-native-safe-area-context
yarn add expo
yarn add -D @react-native-community/cli @types/react
yarn expo install expo-router expo-linking react react-native react-native-screens react-native-safe-area-context
pnpm add expo
pnpm add -D @react-native-community/cli @types/react
pnpm expo install expo-router expo-linking react react-native react-native-screens react-native-safe-area-context
bun add expo
bun add -D @react-native-community/cli @types/react
bun expo install expo-router expo-linking react react-native react-native-screens react-native-safe-area-context
Now we’ll create the app.json
file at the project root. This file is required for Continuous Native Generation (CNG), an Expo feature that lets you configure the native Android and iOS projects using a TypeScript config file.
// app.json
{
"name": "YourApplicationName",
"slug": "your-application-slug",
"scheme": "yourapplicationscheme", // Optional, but recommended for deep linking
"android": {
"package": "com.yourcompany.yourapp",
},
"ios": {
"bundleIdentifier": "com.yourcompany.yourapp",
},
"plugins": [
["expo-router", { root: "./src/screens" }] // Adjust the path as needed
]
}
When running npx expo prebuild
, Expo will generate the native Android and iOS projects based on your configuration. However, if you try to run a production build, Expo will use its own CLI and Metro as the bundler, which is not what we want. We need to remove those settings to ensure Re.Pack is used instead.
Replacing Expo CLI with the default React Native CLI in production
To do this, we need to remove Expo’s CLI_PATH
and BUNDLE_COMMAND
settings, which define the path to Expo’s CLI and the bundle command used by Metro. These settings are defined in the Android and iOS build scripts and must be removed to make Re.Pack work properly.
On Android, this is in ./android/app/build.gradle
, and on iOS, in Xcode’s custom build scripts.
While you can do this manually, every time you run npx expo prebuild
, those settings will be overwritten. Therefore, we’ll automate this using a configuration plugin. I recommend moving this plugin to a separate package, but for simplicity, we’ll keep it in the same project.
// plugins/expo-repack-plugin.js
const { withAppBuildGradle, withXcodeProject } = require("expo/config-plugins");
/**
* Configuration plugin to remove some expo defaults to ensure that re-pack works correctly.
*
* @param current Current expo configuration
* @returns Modified expo configuration
*/
module.exports = (current) => {
let res = current;
// iOS
// Replace $CLI_PATH and $BUNDLE_COMMAND in the Xcode project (this will ensure that the correct CLI is used in production builds)
res = withXcodeProject(res, (configuration) => {
const xcodeProject = configuration.modResults;
const bundleReactNativeCodeAndImagesBuildPhase = xcodeProject.buildPhaseObject(
"PBXShellScriptBuildPhase",
"Bundle React Native code and images",
);
if (!bundleReactNativeCodeAndImagesBuildPhase)
return configuration;
const script = JSON.parse(bundleReactNativeCodeAndImagesBuildPhase.shellScript);
const patched = script
.replace(/if \[\[ -z "\$CLI_PATH" \]\]; then[\s\S]*?fi\n?/g, `export CLI_PATH="$("$NODE_BINARY" --print "require('path').dirname(require.resolve('@react-native-community/cli/package.json')) + '/build/bin.js'")"`)
.replace(/if \[\[ -z "\$BUNDLE_COMMAND" \]\]; then[\s\S]*?fi\n?/g, "");
bundleReactNativeCodeAndImagesBuildPhase.shellScript = JSON.stringify(patched);
return configuration;
});
// Android
// Replace cliFile and bundleCommand in the app/build.gradle file (this will ensure that the correct CLI is used in production builds)
res = withAppBuildGradle(res, (configuration) => {
const buildGradle = configuration.modResults.contents;
const patched = buildGradle.replace(/cliFile.*/, "").replace(/bundleCommand.*/, "bundleCommand = \"bundle\"");
configuration.modResults.contents = patched;
return configuration;
});
return res;
};
Now we need to add this plugin to our app.json
:
// app.json
{
"name": "YourApplicationName",
"slug": "your-application-slug",
"scheme": "yourapplicationscheme",
"android": {
"package": "com.yourcompany.yourapp",
},
"ios": {
"bundleIdentifier": "com.yourcompany.yourapp",
},
"plugins": [
["expo-router", { root: "./src/screens" }] // Adjust the path as needed
"./plugins/expo-repack-plugin" // Add the Re.Pack plugin
]
}
After re-running npx expo prebuild
, Expo will generate the native Android and iOS projects without the CLI settings, allowing Re.Pack to be used correctly for production builds.
Adding Re.Pack
To add Re.Pack to our project, we need to install the required dependencies and create the Rspack configuration. First, install Re.Pack and Rspack:
npm add -D @callstack/repack @rspack/core
npm add @swc/helpers
yarn add -D @callstack/repack @rspack/core
yarn add @swc/helpers
pnpm add -D @callstack/repack @rspack/core
pnpm add @swc/helpers
bun add -D @callstack/repack @rspack/core
bun add @swc/helpers
In development, Expo maps Metro’s entry point to a virtual module /.expo/.virtual-metro-entry
. When the app runs, instead of the native layer fetching the bundle from http://localhost:8081/index.bundle
, it requests http://localhost:8081/.expo/.virtual-metro-entry.bundle
. Re.Pack doesn’t expose this virtual module by default, so we’ll use the devServer
proxy in Rspack to redirect these requests.
Additionally, Expo Router uses some environment variables to work properly:
EXPO_BASE_URL
: The base URL of the application. (usually empty)EXPO_OS
: The platform that the application is running on. (you can also use @callstack/repack-plugin-expo-modules which does this automatically)EXPO_PROJECT_ROOT
: The absolute path to the root of the project.EXPO_ROUTER_ABS_APP_ROOT
: The absolute path to the routes directory.EXPO_ROUTER_APP_ROOT
: The relative path to the routes directory.EXPO_ROUTER_IMPORT_MODE
: The import mode of the routes. (sync or lazy)
The Rspack configuration file, rspack.config.mjs
, at the project root looks like this:
// rspack.config.mjs
import { resolve } from 'node:path';
import * as Repack from '@callstack/repack';
import { DefinePlugin } from '@rspack/core';
/**
* Rspack configuration for Re.Pack with Expo Router
*
* @param {import("@callstack/repack").EnvOptions} context
* @type {import("@rspack/core").Configuration} configuration
*/
export default ({ mode, platform, devServer }) => ({
mode,
devServer: !!devServer && ({
...devServer,
// Use the virtual metro entry point for Expo
proxy: [{
context: ["/.expo/.virtual-metro-entry"],
pathRewrite: { "^/.expo/.virtual-metro-entry": "/index" },
}],
}),
resolve: {
...Repack.getResolveOptions(),
alias: {
"~": resolve("src"),
},
},
module: {
rules: [
...Repack.getJsTransformRules(),
...Repack.getAssetTransformRules(),
],
},
plugins: [
new Repack.RepackPlugin(),
// Inline some variables to support some expo and expo-router packages
new DefinePlugin({
"process.env.EXPO_BASE_URL": JSON.stringify(""), // Define the base URL for the Expo Router
"process.env.EXPO_OS": JSON.stringify(platform),
"process.env.EXPO_PROJECT_ROOT": JSON.stringify(resolve(".")),
"process.env.EXPO_ROUTER_ABS_APP_ROOT": JSON.stringify(resolve("./src/screens")),
"process.env.EXPO_ROUTER_APP_ROOT": JSON.stringify("./src/screens"), // Relative path to the app root
"process.env.EXPO_ROUTER_IMPORT_MODE": JSON.stringify("sync"),
}),
],
});
Finally, create the react-native.config.js
file at the project root. This file is required for Re.Pack to be used as the bundler when running npx react-native start
or npx react-native bundle
:
module.exports = {
commands: require("@callstack/repack/commands/rspack"),
};
Setting up the React Native entrypoint
Update package.json
to include the application entrypoint and scripts. This value is used automatically by both Re.Pack and the React Native CLI:
// package.json
{
"main": "src/index.ts",
"scripts": {
"start": "react-native start --reset-cache",
"android": "react-native run-android --no-packager",
"ios": "react-native run-ios --no-packager",
},
// ...
}
Then create src/index.ts
, which will be the app’s entrypoint. This file is responsible for starting the React Native application:
// src/index.ts
import { AppRegistry } from "react-native";
import { function Application(): React.JSX.Element
Application } from "./application";
AppRegistry.function AppRegistry.registerComponent(appKey: string, getComponentFunc: ComponentProvider, section?: boolean): string
registerComponent("main", () => function Application(): React.JSX.Element
Application);
And also the src/application.tsx
file, which is the root component of the application. This file is responsible for rendering the app using Expo Router:
// src/application.tsx
import { function ExpoRoot({ wrapper: ParentWrapper, ...props }: ExpoRootProps): React.JSX.Element
ExpoRoot } from "expo-router";
import { const ctx: Rspack.Context
ctx } from "expo-router/_ctx";
export function function Application(): React.JSX.Element
Application() {
return <function ExpoRoot({ wrapper: ParentWrapper, ...props }: ExpoRootProps): React.JSX.Element
ExpoRoot context: RequireContext
context={const ctx: Rspack.Context
ctx as any} />;
}
But where does this ctx
come from? Expo Router uses a feature present in most modern bundlers (including Metro) called require.context
(CJS) or import.meta.context
(ESM) or, in Vite’s case, import.meta.glob
, which lets you dynamically import modules based on a pattern. The interesting part is that you can create custom contexts with your own import rules. In my case, I chose to create a context that ignores paths with @
, allowing me to place components and hooks inside the src/screens
directory without them being imported by Expo Router. If you don’t want this, you can simply use Expo’s default context located at expo-router/_ctx
.
Creating a custom Expo Router context (optional/advanced)
Creating it is straightforward. I use the environment variables defined earlier and a custom RegExp. The src/context.ts
file looks like this:
// src/context.ts
export const const context: Rspack.Context
context = var require: NodeJS.Require
require.Rspack.Require.context(request: string, includeSubdirectories?: boolean, filter?: RegExp, mode?: "sync" | "eager" | "weak" | "lazy" | "lazy-once"): Rspack.Context
context(
var process: NodeJS.Process
process.NodeJS.Process.env: NodeJS.ProcessEnv
The process.env
property returns an object containing the user environment.
See environ(7)
.
An example of this object looks like:
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other Worker
threads.
In other words, the following example would not work:
node -e 'process.env.foo = "bar"' && echo $foo
While the following will:
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
Assigning a property on process.env
will implicitly convert the value
to a string. This behavior is deprecated. Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
Use delete
to delete a property from process.env
.
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
On Windows operating systems, environment variables are case-insensitive.
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
Unless explicitly specified when creating a Worker
instance,
each Worker
thread has its own copy of process.env
, based on its
parent thread's process.env
, or whatever was specified as the env
option
to the Worker
constructor. Changes to process.env
will not be visible
across Worker
threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of process.env
on a Worker
instance operates in a case-sensitive manner
unlike the main thread.
env.string | undefined
EXPO_ROUTER_APP_ROOT as string, // Path to the routes directory
true, // Recursively search for files
/^\.\/(?!.*@)(?!.*\+(?:api|html)\.[tj]sx?$).*\.[tj]sx?$/, // Special RegExp that ignores files with @ and +api or +html
(var process: NodeJS.Process
process.NodeJS.Process.env: NodeJS.ProcessEnv
The process.env
property returns an object containing the user environment.
See environ(7)
.
An example of this object looks like:
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other Worker
threads.
In other words, the following example would not work:
node -e 'process.env.foo = "bar"' && echo $foo
While the following will:
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
Assigning a property on process.env
will implicitly convert the value
to a string. This behavior is deprecated. Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
Use delete
to delete a property from process.env
.
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
On Windows operating systems, environment variables are case-insensitive.
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
Unless explicitly specified when creating a Worker
instance,
each Worker
thread has its own copy of process.env
, based on its
parent thread's process.env
, or whatever was specified as the env
option
to the Worker
constructor. Changes to process.env
will not be visible
across Worker
threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of process.env
on a Worker
instance operates in a case-sensitive manner
unlike the main thread.
env.string | undefined
EXPO_ROUTER_IMPORT_MODE as any) ?? "sync", // Import mode: "sync" or "lazy"
);
And then import it in the src/application.tsx
file:
// src/application.tsx
import { function ExpoRoot({ wrapper: ParentWrapper, ...props }: ExpoRootProps): React.JSX.Element
ExpoRoot } from "expo-router";
import { const context: Rspack.Context
context } from "./context";
export function function Application(): React.JSX.Element
Application() {
return <function ExpoRoot({ wrapper: ParentWrapper, ...props }: ExpoRootProps): React.JSX.Element
ExpoRoot context: RequireContext
context={const context: Rspack.Context
context as any} />;
}
When inspecting the app bundle after running npx react-native bundle --platform ios --entry-file src/index.ts --dev false
, you’ll see that Re.Pack generates a context
object that contains all routes and layouts. This is done AOT (Ahead Of Time), meaning the imports are resolved at build time, improving app performance.
// src/context.ts
import function src_screens_index(): React.JSX.Element
src_screens_index from "./screens/index.tsx";
import function src_screens__layout(): React.JSX.Element
src_screens__layout from "./screens/_layout.tsx";
export const const context: {
"./index.tsx": () => () => React.JSX.Element;
"./_layout.tsx": () => () => React.JSX.Element;
}
context = {
"./index.tsx": () => function src_screens_index(): React.JSX.Element
src_screens_index,
"./_layout.tsx": () => function src_screens__layout(): React.JSX.Element
src_screens__layout,
}
All set, so we can now run Re.Pack and verify that everything works as expected.
Development and build workflow
To start Re.Pack’s development server:
npx react-native start --reset-cache
yarn react-native start --reset-cache
pnpm react-native start --reset-cache
bun react-native start --reset-cache
To generate the native Android and iOS folders and install the apps on an emulator/device:
npx expo prebuild
npx react-native run-android --no-packager
npx react-native run-ios --no-packager
yarn expo prebuild
yarn react-native run-android --no-packager
yarn react-native run-ios --no-packager
pnpm expo prebuild
pnpm react-native run-android --no-packager
pnpm react-native run-ios --no-packager
bun expo prebuild
bun react-native run-android --no-packager
bun react-native run-ios --no-packager
To generate the production bundle:
npx react-native bundle --entry-file src/index.ts --platform android --dev false # Android
npx react-native bundle --entry-file src/index.ts --platform ios --dev false # iOS
yarn react-native bundle --entry-file src/index.ts --platform android --dev false # Android
yarn react-native bundle --entry-file src/index.ts --platform ios --dev false # iOS
pnpm react-native bundle --entry-file src/index.ts --platform android --dev false # Android
pnpm react-native bundle --entry-file src/index.ts --platform ios --dev false # iOS
bun react-native bundle --entry-file src/index.ts --platform android --dev false # Android
bun react-native bundle --entry-file src/index.ts --platform ios --dev false # iOS
Bonus
With these changes, you can use mechanisms that aren’t available in Metro. Here are some tips on how to take advantage of Re.Pack.
Virtual modules
Rspack supports virtual modules, which let you create modules that don’t physically exist on the filesystem but can be imported as if they were normal modules. This is useful for creating modules that are generated dynamically or used only during development. A good example is the unplugin-icons plugin, which allows you to import SVG icons dynamically and efficiently from a wide range of icon libraries.
Here’s how I created a compiler for React Native that allows importing SVG icons dynamically and efficiently (using react-native-svg
):
// rspack.config.mjs
import Icons from "unplugin-icons/rspack";
import { camelize } from "@iconify/utils/lib/misc/strings";
export default () => ({
// ... other Rspack/Re.Pack settings
plugins: [
Icons({
compiler: {
compiler: async (svg, collection, icon) => {
const { transform } = await import("@svgr/core");
const componentName = camelize(`${collection}-${icon}`);
const res = await transform(svg, {
ref: false,
native: true,
plugins: ["@svgr/plugin-jsx"],
jsxRuntime: "automatic",
}, { componentName });
return res;
},
extension: "jsx",
},
}),
]
})
Now, after installing any icon collection, such as @iconify-json/mdi
, you can import SVG icons directly in your React Native code:
// src/screens/index.tsx
import { class View
View } from "react-native";
import const Account: React.ComponentType<React.SVGProps<SVGSVGElement>>
Account from "virtual:icons/mdi/account";
export default function function Index(): React.JSX.Element
Index() {
return (
<class View
View>
<const Account: React.ComponentType<React.SVGProps<SVGSVGElement>>
Account React.SVGAttributes<SVGSVGElement>.width?: string | number | undefined
width={24} React.SVGAttributes<SVGSVGElement>.height?: string | number | undefined
height={24} />
</class View
View>
);
}
Custom loaders
Rspack lets you use custom loaders to transform files before they’re processed by the bundler. A good example is integration with lingui, an internationalization framework for JavaScript that lets you write translations in .po
and .json
files and load them dynamically in your app.
Bundle analysis
Rspack also supports build analysis plugins, such as @rsdoctor/rspack-plugin. It’s a powerful bundle analyzer that lets you visualize module sizes, dependencies, and other details about your bundle.
Conclusion
With this setup, you have a React Native project configured to use Re.Pack as the bundler while maintaining integration with Expo Router. This configuration allows you to benefit from Re.Pack’s faster builds and advanced features, while still using Expo features such as Expo Router and Expo CNG.