ceo.

Combining Re.Pack with Expo Router

August 18, 2025 — 11 min read

Yellow train arriving at a metro station

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

By the end of this guide, you’ll have a project structure similar to this:

Folder icon plugins
.js file icon expo-repack-plugin.js # expo configuration plugin for re-pack
Folder icon src
Folder icon screens
.tsx file icon _layout.tsx # expo router layout
.tsx file icon index.tsx # expo router / page
.tsx file icon application.tsx # expo router application entry point
.ts file icon context.ts # expo router context
.ts file icon index.ts # react native entry point
.json file icon app.json # expo CNG configuration file
.json file icon package.json
.js file icon react-native.config.js # react native CLI configuration file
.mjs file icon rspack.config.mjs # rspack configuration file

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

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

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:

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.ElementApplication } from "./application";

AppRegistry.function AppRegistry.registerComponent(appKey: string, getComponentFunc: ComponentProvider, section?: boolean): stringregisterComponent("main", () => function Application(): React.JSX.ElementApplication);

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
@hidden
ExpoRoot
} from "expo-router";
import { const ctx: Rspack.Contextctx } from "expo-router/_ctx"; export function function Application(): React.JSX.ElementApplication() { return <function ExpoRoot({ wrapper: ParentWrapper, ...props }: ExpoRootProps): React.JSX.Element
@hidden
ExpoRoot
context: RequireContextcontext={const ctx: Rspack.Contextctx 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.Contextcontext = var require: NodeJS.Require
@sincev0.1.13
require
.Rspack.Require.context(request: string, includeSubdirectories?: boolean, filter?: RegExp, mode?: "sync" | "eager" | "weak" | "lazy" | "lazy-once"): Rspack.Contextcontext(
var process: NodeJS.Processprocess.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"' &#x26;&#x26; 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.

@sincev0.1.27
env
.string | undefinedEXPO_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.Processprocess.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"' &#x26;&#x26; 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.

@sincev0.1.27
env
.string | undefinedEXPO_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
@hidden
ExpoRoot
} from "expo-router";
import { const context: Rspack.Contextcontext } from "./context"; export function function Application(): React.JSX.ElementApplication() { return <function ExpoRoot({ wrapper: ParentWrapper, ...props }: ExpoRootProps): React.JSX.Element
@hidden
ExpoRoot
context: RequireContextcontext={const context: Rspack.Contextcontext 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.Elementsrc_screens_index from "./screens/index.tsx";
import function src_screens__layout(): React.JSX.Elementsrc_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.Elementsrc_screens_index, "./_layout.tsx": () => function src_screens__layout(): React.JSX.Elementsrc_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

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

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

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 ViewView } from "react-native";
import const Account: React.ComponentType<React.SVGProps<SVGSVGElement>>Account from "virtual:icons/mdi/account";

export default function function Index(): React.JSX.ElementIndex() {
	return (
		<class ViewView>
			<const Account: React.ComponentType<React.SVGProps<SVGSVGElement>>Account React.SVGAttributes<SVGSVGElement>.width?: string | number | undefinedwidth={24} React.SVGAttributes<SVGSVGElement>.height?: string | number | undefinedheight={24} />
		</class ViewView>
	);
}

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.

Dependencies

@callstack/repack@^5.1.3 @rspack/core@^1.4.11 expo@^53.0.20 expo-router@^5.1.4