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) => ExpoConfig
ConfigPlugin } from "expo/config-plugins";
import { const withAppDelegate: ConfigPlugin<Mod<AppDelegateProjectFile>>
Provides the AppDelegate file for modification.
withAppDelegate, const withMainApplication: ConfigPlugin<Mod<ApplicationProjectFile>>
Provides the project MainApplication for modification.
withMainApplication } from "expo/config-plugins";
const const plugin: ConfigPlugin
plugin: type ConfigPlugin<Props = void> = (config: ExpoConfig, props: Props) => ExpoConfig
ConfigPlugin = (expo: ExpoConfig
expo) => {
let let res: ExpoConfig
res = expo: ExpoConfig
expo;
// iOS
let res: ExpoConfig
res = function withAppDelegate(config: ExpoConfig, props: Mod<AppDelegateProjectFile>): ExpoConfig
Provides the AppDelegate file for modification.
withAppDelegate(let res: ExpoConfig
res, async (configuration: ExportedConfigWithProps<AppDelegateProjectFile>
configuration) => {
const const appDelegate: string
appDelegate = configuration: ExportedConfigWithProps<AppDelegateProjectFile>
configuration.ExportedConfigWithProps<AppDelegateProjectFile>.modResults: AppDelegateProjectFile
The Object representation of a complex file type.
modResults.ProjectFile<AppleLanguage>.contents: string
contents;
configuration: ExportedConfigWithProps<AppDelegateProjectFile>
configuration.ExportedConfigWithProps<AppDelegateProjectFile>.modResults: AppDelegateProjectFile
The Object representation of a complex file type.
modResults.ProjectFile<AppleLanguage>.contents: string
contents = const appDelegate: string
appDelegate.String.replace(searchValue: string | RegExp, replaceValue: string): string (+3 overloads)
Replaces text in a string, using a regular expression or search string.
replace(".expo/.virtual-metro-entry", "index");
return configuration: ExportedConfigWithProps<AppDelegateProjectFile>
configuration;
});
// Android
let res: ExpoConfig
res = function withMainApplication(config: ExpoConfig, props: Mod<ApplicationProjectFile>): ExpoConfig
Provides the project MainApplication for modification.
withMainApplication(let res: ExpoConfig
res, async (configuration: ExportedConfigWithProps<ApplicationProjectFile>
configuration) => {
const const mainApplication: string
mainApplication = configuration: ExportedConfigWithProps<ApplicationProjectFile>
configuration.ExportedConfigWithProps<ApplicationProjectFile>.modResults: ApplicationProjectFile
The Object representation of a complex file type.
modResults.ProjectFile<"java" | "kt">.contents: string
contents;
configuration: ExportedConfigWithProps<ApplicationProjectFile>
configuration.ExportedConfigWithProps<ApplicationProjectFile>.modResults: ApplicationProjectFile
The Object representation of a complex file type.
modResults.ProjectFile<"java" | "kt">.contents: string
contents = const mainApplication: string
mainApplication.String.replace(searchValue: string | RegExp, replaceValue: string): string (+3 overloads)
Replaces text in a string, using a regular expression or search string.
replace(".expo/.virtual-metro-entry", "index");
return configuration: ExportedConfigWithProps<ApplicationProjectFile>
configuration;
});
return let res: ExpoConfig
res;
};
export default const plugin: ConfigPlugin
plugin;
Now, when we execute the development workflow:
bun expo prebuild
bun run ios
bun run start
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: ExpoConfig
res = function withXcodeProject(config: ExpoConfig, props: Mod<XcodeProject>): ExpoConfig
Provides the main .xcodeproj for modification.
withXcodeProject(let res: ExpoConfig
res, async (configuration: ExportedConfigWithProps<XcodeProject>
configuration) => {
const const xcodeProject: XcodeProject
xcodeProject = configuration: ExportedConfigWithProps<XcodeProject>
configuration.ExportedConfigWithProps<XcodeProject>.modResults: XcodeProject
The Object representation of a complex file type.
modResults;
const const bundleReactNativeCodeAndImagesBuildPhase: any
bundleReactNativeCodeAndImagesBuildPhase = const xcodeProject: XcodeProject
xcodeProject.buildPhaseObject(
"PBXShellScriptBuildPhase",
"Bundle React Native code and images",
);
if (!const bundleReactNativeCodeAndImagesBuildPhase: any
bundleReactNativeCodeAndImagesBuildPhase)
return configuration: ExportedConfigWithProps<XcodeProject>
configuration;
const const script: any
script = 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.
parse(const bundleReactNativeCodeAndImagesBuildPhase: any
bundleReactNativeCodeAndImagesBuildPhase.shellScript);
const const patched: any
patched = const script: any
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, "");
const bundleReactNativeCodeAndImagesBuildPhase: any
bundleReactNativeCodeAndImagesBuildPhase.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.
stringify(const patched: any
patched);
return configuration: ExportedConfigWithProps<XcodeProject>
configuration;
});
// Android
let res: ExpoConfig
res = function withAppBuildGradle(config: ExpoConfig, props: Mod<GradleProjectFile>): ExpoConfig
Provides the app/build.gradle for modification.
withAppBuildGradle(let res: ExpoConfig
res, async (configuration: ExportedConfigWithProps<GradleProjectFile>
configuration) => {
const const buildGradle: string
buildGradle = configuration: ExportedConfigWithProps<GradleProjectFile>
configuration.ExportedConfigWithProps<GradleProjectFile>.modResults: GradleProjectFile
The Object representation of a complex file type.
modResults.ProjectFile<"groovy" | "kt">.contents: string
contents;
const const patched: string
patched = const buildGradle: string
buildGradle.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.
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.
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: string
contents = const patched: string
patched;
return configuration: ExportedConfigWithProps<GradleProjectFile>
configuration;
});
Now, when we execute the production workflow:
bun expo prebuild
bun run ios --mode Release
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"),
})
EXPO_BASE_URL
: The base URL of the application. (usually empty)EXPO_OS
: The platform that the application is running on. (you can 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)
After that, we need to manually create the require.context
function that expo-router
uses to load the routes.
export const const context: any
context = var require: NodeJS.Require
require.NodeJS.Require.context(request: string, includeSubdirectories?: boolean, filter?: RegExp, mode?: "sync" | "eager" | "weak" | "lazy" | "lazy-once"): any
context(
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: string
EXPO_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" | undefined
EXPO_ROUTER_IMPORT_MODE ?? "sync",
);
And now we create our root component:
import { function ExpoRoot({ wrapper: ParentWrapper, ...props }: ExpoRootProps): React.JSX.Element
ExpoRoot } from "expo-router";
import { const context: any
context } from "./context";
export function function Application(): JSX.Element
Application() {
return <function ExpoRoot({ wrapper: ParentWrapper, ...props }: ExpoRootProps): React.JSX.Element
ExpoRoot context: RequireContext
context={const context: any
context 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.