ceo.

How do I combine @callstack/repack with expo-router

March 11, 2025

Recently I migrated a React Native application to @callstack/repack. I wanted to take advantage of the performance and unique features that Rspack had to offer, such as support for virtual modules and greater control over the bundle. However, I didn’t want to abandon the benefits that expo brings to a React Native application, in my case the CNG and expo-router.

So I wrote this article to show how I integrated @callstack/repack with expo and expo-router, ensuring that both development and production workflows work as expected.

Fixing the development workflow

The first thing I did was to stop using @expo/cli. Since it runs Metro at undesired moments (e.g. before native build in release mode), I needed to ensure that I would only use the React Native CLI. Therefore, I changed the scripts in package.json to the following:

"scripts": {
	"start": "react-native start --reset-cache",
	"android": "react-native run-android --no-packager",
	"ios": "react-native run-ios --no-packager --simulator \"iPhone 16 Pro\"",
	"bundle:ios": "react-native bundle --platform ios --entry-file src/index.ts --dev false",
	"bundle:android": "react-native bundle --platform android --entry-file src/index.ts --dev false"
}

When we execute the expo prebuild command, expo replaces the android and iOS scripts with expo run (android|ios), assuming this is the initial setup for CNG. To keep the correct command, add a flag, in this case, I added --no-packager.

In development, expo also replaces the JS Bundle URL, changing it from index to .expo/.virtual-metro-entry. This breaks development since repack only exposes the bundle via http://localhost:8081/index (#906). To fix this, I’ve created an expo configuration plugin that changes the bundle URL back to index:

import type { type ConfigPlugin<Props = void> = (config: ExpoConfig, props: Props) => ExpoConfigConfigPlugin } from "expo/config-plugins";

import { const withAppDelegate: ConfigPlugin<Mod<AppDelegateProjectFile>>

Provides the AppDelegate file for modification.

@paramconfig@paramaction
withAppDelegate
, const withMainApplication: ConfigPlugin<Mod<ApplicationProjectFile>>

Provides the project MainApplication for modification.

@paramconfig@paramaction
withMainApplication
} from "expo/config-plugins";
const const plugin: ConfigPluginplugin: type ConfigPlugin<Props = void> = (config: ExpoConfig, props: Props) => ExpoConfigConfigPlugin = (expo: ExpoConfigexpo) => { let let res: ExpoConfigres = expo: ExpoConfigexpo; // iOS let res: ExpoConfigres = function withAppDelegate(config: ExpoConfig, props: Mod<AppDelegateProjectFile>): ExpoConfig

Provides the AppDelegate file for modification.

@paramconfig@paramaction
withAppDelegate
(let res: ExpoConfigres, async (configuration: ExportedConfigWithProps<AppDelegateProjectFile>configuration) => {
const const appDelegate: stringappDelegate = configuration: ExportedConfigWithProps<AppDelegateProjectFile>configuration.ExportedConfigWithProps<AppDelegateProjectFile>.modResults: AppDelegateProjectFile

The Object representation of a complex file type.

modResults
.ProjectFile<AppleLanguage>.contents: stringcontents;
configuration: ExportedConfigWithProps<AppDelegateProjectFile>configuration.ExportedConfigWithProps<AppDelegateProjectFile>.modResults: AppDelegateProjectFile

The Object representation of a complex file type.

modResults
.ProjectFile<AppleLanguage>.contents: stringcontents = const appDelegate: stringappDelegate.String.replace(searchValue: string | RegExp, replaceValue: string): string (+3 overloads)

Replaces text in a string, using a regular expression or search string.

@paramsearchValue A string or regular expression to search for.@paramreplaceValue A string containing the text to replace. When the {@linkcode searchValue} is a RegExp, all matches are replaced if the g flag is set (or only those matches at the beginning, if the y flag is also present). Otherwise, only the first match of {@linkcode searchValue} is replaced.
replace
(".expo/.virtual-metro-entry", "index");
return configuration: ExportedConfigWithProps<AppDelegateProjectFile>configuration; }); // Android let res: ExpoConfigres = function withMainApplication(config: ExpoConfig, props: Mod<ApplicationProjectFile>): ExpoConfig

Provides the project MainApplication for modification.

@paramconfig@paramaction
withMainApplication
(let res: ExpoConfigres, async (configuration: ExportedConfigWithProps<ApplicationProjectFile>configuration) => {
const const mainApplication: stringmainApplication = configuration: ExportedConfigWithProps<ApplicationProjectFile>configuration.ExportedConfigWithProps<ApplicationProjectFile>.modResults: ApplicationProjectFile

The Object representation of a complex file type.

modResults
.ProjectFile<"java" | "kt">.contents: stringcontents;
configuration: ExportedConfigWithProps<ApplicationProjectFile>configuration.ExportedConfigWithProps<ApplicationProjectFile>.modResults: ApplicationProjectFile

The Object representation of a complex file type.

modResults
.ProjectFile<"java" | "kt">.contents: stringcontents = const mainApplication: stringmainApplication.String.replace(searchValue: string | RegExp, replaceValue: string): string (+3 overloads)

Replaces text in a string, using a regular expression or search string.

@paramsearchValue A string or regular expression to search for.@paramreplaceValue A string containing the text to replace. When the {@linkcode searchValue} is a RegExp, all matches are replaced if the g flag is set (or only those matches at the beginning, if the y flag is also present). Otherwise, only the first match of {@linkcode searchValue} is replaced.
replace
(".expo/.virtual-metro-entry", "index");
return configuration: ExportedConfigWithProps<ApplicationProjectFile>configuration; }); return let res: ExpoConfigres; }; export default const plugin: ConfigPluginplugin;

Now, when we execute the development workflow:

Everything works as expected.

Fixing the production workflow

In the production workflow we want to ensure that repack is used in release builds, for this we need to include two more modifications in the expo configuration plugin in order to replace @expo/cli:

// iOS
let res: ExpoConfigres = function withXcodeProject(config: ExpoConfig, props: Mod<XcodeProject>): ExpoConfig

Provides the main .xcodeproj for modification.

@paramconfig@paramaction
withXcodeProject
(let res: ExpoConfigres, async (configuration: ExportedConfigWithProps<XcodeProject>configuration) => {
const const xcodeProject: XcodeProjectxcodeProject = configuration: ExportedConfigWithProps<XcodeProject>configuration.ExportedConfigWithProps<XcodeProject>.modResults: XcodeProject

The Object representation of a complex file type.

modResults
;
const const bundleReactNativeCodeAndImagesBuildPhase: anybundleReactNativeCodeAndImagesBuildPhase = const xcodeProject: XcodeProjectxcodeProject.buildPhaseObject( "PBXShellScriptBuildPhase", "Bundle React Native code and images", ); if (!const bundleReactNativeCodeAndImagesBuildPhase: anybundleReactNativeCodeAndImagesBuildPhase) return configuration: ExportedConfigWithProps<XcodeProject>configuration; const const script: anyscript = var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.JSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): any

Converts a JavaScript Object Notation (JSON) string into an object.

@paramtext A valid JSON string.@paramreviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.
parse
(const bundleReactNativeCodeAndImagesBuildPhase: anybundleReactNativeCodeAndImagesBuildPhase.shellScript);
const const patched: anypatched = const script: anyscript .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, ""); const bundleReactNativeCodeAndImagesBuildPhase: anybundleReactNativeCodeAndImagesBuildPhase.shellScript = var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)

Converts a JavaScript value to a JavaScript Object Notation (JSON) string.

@paramvalue A JavaScript value, usually an object or array, to be converted.@paramreplacer A function that transforms the results.@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
stringify
(const patched: anypatched);
return configuration: ExportedConfigWithProps<XcodeProject>configuration; }); // Android let res: ExpoConfigres = function withAppBuildGradle(config: ExpoConfig, props: Mod<GradleProjectFile>): ExpoConfig

Provides the app/build.gradle for modification.

@paramconfig@paramaction
withAppBuildGradle
(let res: ExpoConfigres, async (configuration: ExportedConfigWithProps<GradleProjectFile>configuration) => {
const const buildGradle: stringbuildGradle = configuration: ExportedConfigWithProps<GradleProjectFile>configuration.ExportedConfigWithProps<GradleProjectFile>.modResults: GradleProjectFile

The Object representation of a complex file type.

modResults
.ProjectFile<"groovy" | "kt">.contents: stringcontents;
const const patched: stringpatched = const buildGradle: stringbuildGradle.
String.replace(searchValue: {
    [Symbol.replace](string: string, replaceValue: string): string;
}, replaceValue: string): string (+3 overloads)

Passes a string and {@linkcode replaceValue } to the [Symbol.replace] method on {@linkcode searchValue } . This method is expected to implement its own replacement algorithm.

@paramsearchValue An object that supports searching for and replacing matches within a string.@paramreplaceValue The replacement text.
replace
(/cliFile.*/, "").
String.replace(searchValue: {
    [Symbol.replace](string: string, replaceValue: string): string;
}, replaceValue: string): string (+3 overloads)

Passes a string and {@linkcode replaceValue } to the [Symbol.replace] method on {@linkcode searchValue } . This method is expected to implement its own replacement algorithm.

@paramsearchValue An object that supports searching for and replacing matches within a string.@paramreplaceValue The replacement text.
replace
(/bundleCommand.*/, "bundleCommand = \"bundle\"");
configuration: ExportedConfigWithProps<GradleProjectFile>configuration.ExportedConfigWithProps<GradleProjectFile>.modResults: GradleProjectFile

The Object representation of a complex file type.

modResults
.ProjectFile<"groovy" | "kt">.contents: stringcontents = const patched: stringpatched;
return configuration: ExportedConfigWithProps<GradleProjectFile>configuration; });

Now, when we execute the production workflow:

Everything works as expected.

Integrating expo-router

To integrate expo-router, we need to ensure that the environment variables are correctly set. In the rspack.config.mjs file, we need to add a DefinePlugin that sets the environment variables:

new DefinePlugin({
	"process.env.EXPO_BASE_URL": JSON.stringify(""),
	"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("~/screens"),
	"process.env.EXPO_ROUTER_IMPORT_MODE": JSON.stringify("sync"),
})

After that, we need to manually create the require.context function that expo-router uses to load the routes.

export const const context: anycontext = var require: NodeJS.Requirerequire.NodeJS.Require.context(request: string, includeSubdirectories?: boolean, filter?: RegExp, mode?: "sync" | "eager" | "weak" | "lazy" | "lazy-once"): anycontext(
	
var process: {
    env: {
        EXPO_ROUTER_APP_ROOT: string;
        EXPO_ROUTER_IMPORT_MODE?: "sync" | "eager" | "weak" | "lazy" | "lazy-once";
    };
}
process
.
env: {
    EXPO_ROUTER_APP_ROOT: string;
    EXPO_ROUTER_IMPORT_MODE?: "sync" | "eager" | "weak" | "lazy" | "lazy-once";
}
env
.type EXPO_ROUTER_APP_ROOT: stringEXPO_ROUTER_APP_ROOT,
true, /^\.\/(?!(?:.*\+api|\+html)\.[tj]sx?$).*\.[tj]sx?$/,
var process: {
    env: {
        EXPO_ROUTER_APP_ROOT: string;
        EXPO_ROUTER_IMPORT_MODE?: "sync" | "eager" | "weak" | "lazy" | "lazy-once";
    };
}
process
.
env: {
    EXPO_ROUTER_APP_ROOT: string;
    EXPO_ROUTER_IMPORT_MODE?: "sync" | "eager" | "weak" | "lazy" | "lazy-once";
}
env
.EXPO_ROUTER_IMPORT_MODE?: "sync" | "eager" | "weak" | "lazy" | "lazy-once" | undefinedEXPO_ROUTER_IMPORT_MODE ?? "sync",
);

And now we create our root component:

import { function ExpoRoot({ wrapper: ParentWrapper, ...props }: ExpoRootProps): React.JSX.Element
@hidden
ExpoRoot
} from "expo-router";
import { const context: anycontext } from "./context"; export function function Application(): JSX.ElementApplication() { return <function ExpoRoot({ wrapper: ParentWrapper, ...props }: ExpoRootProps): React.JSX.Element
@hidden
ExpoRoot
context: RequireContextcontext={const context: anycontext as any} />
}

Now we have a fully integrated expo-router with @callstack/repack!

Conclusion

In this article, I showed how to integrate @callstack/repack with expo and expo-router. I hope this article helps you to make your React Native development more enjoyable. Keep in mind that this solution is not perfect and may have some issues, but it’s a good starting point.

Dependency map

expo@^52.0.37 expo-router@^4.0.17