React Native, Re.Pack and Module Federations

This guide will explain how to use Re.Pack to build a React Native app that uses Module Federation to share code between apps.

Prerequisites
  • We expect you to have finished our Get Started guide.
  • A registered account on Zephyr Cloud.
  • npm >=10
  • node >=22
  • ruby >= 3.3.2

Several key points that were handled by Re.Pack to make this integration attainable:

  1. CLI:
    1. Traditionally when users run react-native build ios it will fire metro bundler to build the application
    2. With Re.Pack it will move the build to Rspack
  2. Module Federation support
    1. Re.Pack nicely handle the cache invalidation and Federation runtime for cross-platform build
  3. Utilities
    1. A couple handy rule sets crafted by Re.Pack
    2. The rule sets are not baked-in/compulsory, user can plug and play with them within rspack config

Get Started

In this section, we will guide you through the process of starting a new React Native application powered by Module Federation. The app will include a simple host application and a remote application.

We will use the repack-init cli provided by Callstack:

To start with, create a new directory and run the following command:

mkdir ZephyrRepackPlayground 
cd ZephyrRepackPlayground 
git init
pnpm init

If this is a new repo, remember to set a remote origin for your git repository and create your first commit before running a build.

And then run the repack init command twice:

npx @callstack/repack-init@rc

For the first app we will call it HostApp

┌  RE.PACK INIT
◇  How would you like to name the app?
│  HostApp
◇  Which bundler would you like to use?
│  Rspack

When you run the command for the second time, we will call it MiniApp.

┌  RE.PACK INIT
◇  How would you like to name the app?
│  MiniApp
◇  Which bundler would you like to use?
│  Rspack

Now if you enter both application's directory and run below commands:

cd HostApp 
npm install 
npx pod-install
npm run ios

At this stage, both of your HostApp and MiniApp's rspack.config.js files should looks like this:

Default build config
Info
rspack.config.js}
import { createRequire } from 'node:module';
import path from 'node:path';
import * as Repack from '@callstack/repack';

const dirname = Repack.getDirname(import.meta.url);
const { resolve } = createRequire(import.meta.url);

/**
 * More documentation, installation, usage, motivation and differences with Metro is available at:
 * https://github.com/callstack/repack/blob/main/README.md
 *
 * The API documentation for the functions and plugins used in this file is available at:
 * https://re-pack.dev
 */

/**
 * Webpack configuration.
 * You can also export a static object or a function returning a Promise.
 *
 * @param env Environment options passed from either Webpack CLI or React Native Community CLI
 *            when running with `react-native start/bundle`.
 */
export default (env) => {
  const {
    mode = 'development',
    context = dirname,
    entry = './index.js',
    platform = process.env.PLATFORM,
    minimize = mode === 'production',
    devServer = undefined,
    bundleFilename = undefined,
    sourceMapFilename = undefined,
    assetsPath = undefined,
    reactNativePath = resolve('react-native'),
  } = env;

  if (!platform) {
    throw new Error('Missing platform');
  }

  return {
    mode,
    /**
     * This should be always `false`, since the Source Map configuration is done
     * by `SourceMapDevToolPlugin`.
     */
    devtool: false,
    context,
    entry,
    resolve: {
      /**
       * `getResolveOptions` returns additional resolution configuration for React Native.
       * If it's removed, you won't be able to use `<file>.<platform>.<ext>` (eg: `file.ios.js`)
       * convention and some 3rd-party libraries that specify `react-native` field
       * in their `package.json` might not work correctly.
       */
      ...Repack.getResolveOptions(platform),

      /**
       * Uncomment this to ensure all `react-native*` imports will resolve to the same React Native
       * dependency. You might need it when using workspaces/monorepos or unconventional project
       * structure. For simple/typical project you won't need it.
       */
      // alias: {
      //   'react-native': reactNativePath,
      // },
    },
    /**
     * Configures output.
     * It's recommended to leave it as it is unless you know what you're doing.
     * By default Webpack will emit files into the directory specified under `path`. In order for the
     * React Native app use them when bundling the `.ipa`/`.apk`, they need to be copied over with
     * `Repack.OutputPlugin`, which is configured by default inside `Repack.RepackPlugin`.
     */
    output: {
      clean: true,
      hashFunction: 'xxhash64',
      path: path.join(dirname, 'build/generated', platform),
      filename: 'index.bundle',
      chunkFilename: '[name].chunk.bundle',
      publicPath: Repack.getPublicPath({ platform, devServer }),
    },
    /** Configures optimization of the built bundle. */
    optimization: {
      /** Enables minification based on values passed from React Native Community CLI or from fallback. */
      minimize,
      /** Configure minimizer to process the bundle. */
      chunkIds: 'named',
    },
    module: {
      rules: [
        Repack.REACT_NATIVE_LOADING_RULES,
        Repack.NODE_MODULES_LOADING_RULES,
        Repack.FLOW_TYPED_MODULES_LOADING_RULES,
        /** Here you can adjust loader that will process your files. */
        {
          test: /\.[jt]sx?$/,
          exclude: [/node_modules/],
          type: 'javascript/auto',
          use: {
            loader: 'builtin:swc-loader',
            /** @type {import('@rspack/core').SwcLoaderOptions} */
            options: {
              env: {
                targets: {
                  'react-native': '0.74',
                },
              },
              jsc: {
                assumptions: {
                  setPublicClassFields: true,
                  privateFieldsAsProperties: true,
                },
                externalHelpers: true,
                transform: {
                  react: {
                    runtime: 'automatic',
                    development: mode === 'development',
                    refresh: mode === 'development' && Boolean(devServer),
                  },
                },
              },
            },
          },
        },
        /**
         * This loader handles all static assets (images, video, audio and others), so that you can
         * use (reference) them inside your application.
         *
         * If you want to handle specific asset type manually, filter out the extension
         * from `ASSET_EXTENSIONS`, for example:
         * ```
         * Repack.ASSET_EXTENSIONS.filter((ext) => ext !== 'svg')
         * ```
         */
        {
          test: Repack.getAssetExtensionsRegExp(Repack.ASSET_EXTENSIONS),
          use: {
            loader: '@callstack/repack/assets-loader',
            options: {
              platform,
              devServerEnabled: Boolean(devServer),
            },
          },
        },
      ],
    },
    plugins: [
      /**
       * Configure other required and additional plugins to make the bundle
       * work in React Native and provide good development experience with
       * sensible defaults.
       *
       * `Repack.RepackPlugin` provides some degree of customization, but if you
       * need more control, you can replace `Repack.RepackPlugin` with plugins
       * from `Repack.plugins`.
       */
      new Repack.RepackPlugin({
        context,
        mode,
        platform,
        devServer,
        output: {
          bundleFilename,
          sourceMapFilename,
          assetsPath,
        },
      }),
    ],
  };
};

Modify theHostApp

Build configuration

Open the rspack.config.js file and modify the build configuration to include the following:

HostApp/rspack.config.js}
import {createRequire} from 'node:module';
import path from 'node:path';
import rspack from '@rspack/core';
import * as Repack from '@callstack/repack';
import TerserPlugin from 'terser-webpack-plugin';
import * as mobileSdk from '@zephyr-merch/mobile-sdk';
import Dotenv from 'dotenv-webpack';
import {withZephyr} from 'zephyr-repack-plugin';

const dirname = Repack.getDirname(import.meta.url);
const {resolve} = createRequire(import.meta.url);
const {getSharedDependencies} = mobileSdk;
const STANDALONE = Boolean(process.env.STANDALONE);
const USE_ZEPHYR = Boolean(process.env.ZC);
/**
 * More documentation, installation, usage, motivation and differences with Metro is available at:
 * https://github.com/callstack/repack/blob/main/README.md
 *
 * The API documentation for the functions and plugins used in this file is available at:
 * https://re-pack.dev
 */

/**
 * Webpack configuration.
 * You can also export a static object or a function returning a Promise.
 *
 * @param env Environment options passed from either Webpack CLI or React Native Community CLI
 *            when running with `react-native start/bundle`.
 */
export default env => {
  const {
    mode = 'development',
    context = dirname,
    entry = './index.js',
    platform = process.env.PLATFORM,
    minimize = mode === 'production',
    devServer = undefined,
    bundleFilename = undefined,
    sourceMapFilename = undefined,
    assetsPath = undefined,
    reactNativePath = resolve('react-native'),
  } = env;

  if (!platform) {
    throw new Error('Missing platform');
  }

  /**
   * Using Module Federation might require disabling hmr.
   * Uncomment below to set `devServer.hmr` to `false`.
   *
   * Keep in mind that `devServer` object is not available
   * when running `webpack-bundle` command. Be sure
   * to check its value to avoid accessing undefined value,
   * otherwise an error might occur.
   */
  // if (devServer) {
  //   devServer.hmr = false;
  // }

  /**
   * Depending on your Babel configuration you might want to keep it.
   * If you don't use `env` in your Babel config, you can remove it.
   *
   * Keep in mind that if you remove it you should set `BABEL_ENV` or `NODE_ENV`
   * to `development` or `production`. Otherwise your production code might be compiled with
   * in development mode by Babel.
   */
  process.env.BABEL_ENV = mode;

  const config = {
    mode,
    /**
     * This should be always `false`, since the Source Map configuration is done
     * by `SourceMapDevToolPlugin`.
     */
    devtool: false,
    context,
    // If this is a miniapp, Repack runtime only compile the MF part
    // If this is the host only pass in the entry point
    entry: entry,
    resolve: {
      /**
       * `getResolveOptions` returns additional resolution configuration for React Native.
       * If it's removed, you won't be able to use `<file>.<platform>.<ext>` (eg: `file.ios.js`)
       * convention and some 3rd-party libraries that specify `react-native` field
       * in their `package.json` might not work correctly.
       */
      ...Repack.getResolveOptions(platform),

      /**
       * Uncomment this to ensure all `react-native*` imports will resolve to the same React Native
       * dependency. You might need it when using workspaces/monorepos or unconventional project
       * structure. For simple/typical project you won't need it.
       */
      // alias: {
      //   'react-native': reactNativePath,
      // },
    },
    /**
     * Configures output.
     * It's recommended to leave it as it is unless you know what you're doing.
     * By default Webpack will emit files into the directory specified under `path`. In order for the
     * React Native app use them when bundling the `.ipa`/`.apk`, they need to be copied over with
     * `Repack.OutputPlugin`, which is configured by default inside `Repack.RepackPlugin`.
     */
    output: {
      clean: true,
      hashFunction: 'xxhash64',
      path: path.join(dirname, 'build/generated', platform),
      filename: 'index.bundle',
      chunkFilename: '[name].chunk.bundle',
      publicPath: Repack.getPublicPath({platform, devServer}),
      uniqueName: 'zephyr-merch-host',
    },
    /**
     * Configures optimization of the built bundle.
     */
    optimization: {
      /** Enables minification based on values passed from React Native Community CLI or from fallback. */
      minimize,
      // /** Configure minimizer to process the bundle. */
      // minimizer: [
      //   new TerserPlugin({
      //     test: /\.(js)?bundle(\?.*)?$/i,
      //     /**
      //      * Prevents emitting text file with comments, licenses etc.
      //      * If you want to gather in-file licenses, feel free to remove this line or configure it
      //      * differently.
      //      */
      //     extractComments: false,
      //     terserOptions: {
      //       format: {
      //         comments: false,
      //       },
      //     },
      //   }),
      // ],
      chunkIds: 'named',
    },
    module: {
      /**
       * This rule will process all React Native related dependencies with Babel.
       * If you have a 3rd-party dependency that you need to transpile, you can add it to the
       * `include` list.
       *
       * You can also enable persistent caching with `cacheDirectory` - please refer to:
       * https://github.com/babel/babel-loader#options
       */
      rules: [
        Repack.REACT_NATIVE_LOADING_RULES,
        Repack.NODE_MODULES_LOADING_RULES,
        Repack.FLOW_TYPED_MODULES_LOADING_RULES,
        // {
        //   test: /\.[cm]?[jt]sx?$/,
        //   include: [
        //     /node_modules(.*[/\\])+react-native/,
        //     /node_modules(.*[/\\])+@react-native/,
        //     /node_modules(.*[/\\])+@react-navigation/,
        //     /node_modules(.*[/\\])+@react-native-community/,
        //     /node_modules(.*[/\\])+expo/,
        //     /node_modules(.*[/\\])+pretty-format/,
        //     /node_modules(.*[/\\])+metro/,
        //     /node_modules(.*[/\\])+abort-controller/,
        //     /node_modules(.*[/\\])+@callstack[/\\]repack/,
        //   ],
        //   use: 'babel-loader',
        // },

        /** Here you can adjust loader that will process your files. */
        {
          test: /\.[jt]sx?$/,
          exclude: [/node_modules/],
          type: 'javascript/auto',
          use: {
            loader: 'builtin:swc-loader',
            /** @type {import('@rspack/core').SwcLoaderOptions} */
            options: {
              env: {
                targets: {
                  'react-native': '0.74',
                },
              },
              jsc: {
                externalHelpers: true,
                transform: {
                  react: {
                    runtime: 'automatic',
                    development: mode === 'development',
                    refresh: mode === 'development' && Boolean(devServer),
                  },
                },
              },
            },
          },
        },
        /** Run React Native codegen, required for utilizing new architecture */
        Repack.REACT_NATIVE_CODEGEN_RULES,
        /**
         * Here you can adjust loader that will process your files.
         *
         * You can also enable persistent caching with `cacheDirectory` - please refer to:
         * https://github.com/babel/babel-loader#options
         */
        {
          test: /\.[jt]sx?$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
            options: {
              /** Add React Refresh transform only when HMR is enabled. */
              plugins:
                devServer && devServer.hmr
                  ? ['module:react-refresh/babel']
                  : undefined,
            },
          },
        },
        /** Additional rule to enable HMR for local workspace packages */
        {
          test: /\.[jt]sx?$/,
          include: [/zephyr-merch/],
          use: 'builtin:react-refresh-loader',
        },
        /**
         * This loader handles all static assets (images, video, audio and others), so that you can
         * use (reference) them inside your application.
         *
         * If you want to handle specific asset type manually, filter out the extension
         * from `ASSET_EXTENSIONS`, for example:
         * ```
         * Repack.ASSET_EXTENSIONS.filter((ext) => ext !== 'svg')
         * ```
         */
        {
          test: Repack.getAssetExtensionsRegExp(Repack.ASSET_EXTENSIONS),
          use: {
            loader: '@callstack/repack/assets-loader',
            options: {
              platform,
              devServerEnabled: Boolean(devServer),
              /**
               * Defines which assets are scalable - which assets can have
               * scale suffixes: `@1x`, `@2x` and so on.
               * By default all images are scalable.
               */
              scalableAssetExtensions: Repack.SCALABLE_ASSETS,
            },
          },
        },
      ],
    },
    plugins: [
      new Dotenv(),
      /**
       * Configure other required and additional plugins to make the bundle
       * work in React Native and provide good development experience with
       * sensible defaults.
       *
       * `Repack.RepackPlugin` provides some degree of customization, but if you
       * need more control, you can replace `Repack.RepackPlugin` with plugins
       * from `Repack.plugins`.
       */
      new Repack.RepackPlugin({
        context,
        mode,
        platform,
        devServer,
        output: {
          bundleFilename,
          sourceMapFilename,
          assetsPath,
        },
      }),
      // silence missing @react-native-masked-view optionally required by @react-navigation/elements
      new rspack.IgnorePlugin({
        resourceRegExp: /^@react-native-masked-view/,
      }),
      new Repack.plugins.ModuleFederationPluginV2({
        name: 'HostApp',
        filename: 'HostApp.container.js.bundle',
        remotes: {
          MiniApp: `MiniApp@http://localhost:9000/${platform}/MiniApp.container.js.bundle`,
        },

        shared: getSharedDependencies({eager: true}),
      }),
    ],
  };

  if (USE_ZEPHYR) {
    return withZephyr()(config);
  }

  return config;
};

Modify consuming app

Within the HostApp folder, create a screens folder in src and create a HomeScreen.tsx file.

src/screens/HomeScreen.tsx}
import {useNavigation} from '@react-navigation/native';
import React from 'react';
import {Button, StyleSheet, Text, View} from 'react-native';
import {MainStackNavigationProp} from '../navigation/MainNavigator';

const HomeScreen = () => {
  const navigation = useNavigation<MainStackNavigationProp>();

  return (
    <View style={styles.container}>
      <Text style={styles.label}>HomeScreen</Text>

      <Button
        color="rgba(127, 103, 190, 1)"
        title="Navigate to Cart"
        onPress={() => {
          navigation.navigate('Cart');
        }}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  label: {
    fontSize: 20,
    fontWeight: '700',
    marginBottom: 40,
  },
});

export default HomeScreen;

Create a CartScreen.tsx file in the screens folder.

src/screens/CartScreen.tsx}
import {useNavigation} from '@react-navigation/native';
import React from 'react';
import {Button, StyleSheet, Text, View} from 'react-native';
import {MainStackNavigationProp} from '../navigation/MainNavigator';

const HomeScreen = () => {
  const navigation = useNavigation<MainStackNavigationProp>();

  return (
    <View style={styles.container}>
      <Text style={styles.label}>HomeScreen</Text>

      <Button
        color="rgba(127, 103, 190, 1)"
        title="Navigate to Cart"
        onPress={() => {
          navigation.navigate('Cart');
        }}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  label: {
    fontSize: 20,
    fontWeight: '700',
    marginBottom: 40,
  },
});

export default HomeScreen;

Create a navigation folder in src and create a MainNavigator.tsx file.

src/navigation/MainNavigator.tsx}
import React from 'react';
import {ActivityIndicator, StyleSheet, View} from 'react-native';
import ErrorBoundary from '../components/ErrorBoundary';
// @ts-expect-error DTS disabled
const CartNavigator = React.lazy(() => import('MobileCart/CartNavigator'));

const FallbackComponent = () => (
  <View style={styles.container}>
    <ActivityIndicator color="rgba(56, 30, 114, 1)" size="large" />
  </View>
);

const CartScreen = () => {
  return (
    <React.Suspense fallback={<FallbackComponent />}>
      <CartNavigator />
    </React.Suspense>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
});

export default CartScreen;

Modify theMiniApp

Build configuration

Open the rspack.config.js in MiniApp and modify it as below:

MiniApp/rspack.config.js}
import { createRequire } from 'node:module';
import path from 'node:path';
import rspack from '@rspack/core';
import * as Repack from '@callstack/repack';
import TerserPlugin from 'terser-webpack-plugin';
import { getSharedDependencies } from '@zephyr-merch/mobile-sdk';
import Dotenv from "dotenv-webpack"
import { withZephyr } from "zephyr-repack-plugin"

const dirname = Repack.getDirname(import.meta.url);
const { resolve } = createRequire(import.meta.url);
const STANDALONE = Boolean(process.env.STANDALONE);
const USE_ZEPHYR = Boolean(process.env.ZC);
/**
 * More documentation, installation, usage, motivation and differences with Metro is available at:
 * https://github.com/callstack/repack/blob/main/README.md
 *
 * The API documentation for the functions and plugins used in this file is available at:
 * https://re-pack.dev
 */

/**
 * Webpack configuration.
 * You can also export a static object or a function returning a Promise.
 *
 * @param env Environment options passed from either Webpack CLI or React Native Community CLI
 *            when running with `react-native start/bundle`.
 */
export default (env) => {
  const {
    mode = 'development',
    context = dirname,
    entry = './index.js',
    platform = process.env.PLATFORM,
    minimize = mode === 'production',
    devServer = undefined,
    bundleFilename = undefined,
    sourceMapFilename = undefined,
    assetsPath = undefined,
    reactNativePath = resolve('react-native'),
  } = env;

  if (!platform) {
    throw new Error('Missing platform');
  }

  /**
   * Using Module Federation might require disabling hmr.
   * Uncomment below to set `devServer.hmr` to `false`.
   *
   * Keep in mind that `devServer` object is not available
   * when running `webpack-bundle` command. Be sure
   * to check its value to avoid accessing undefined value,
   * otherwise an error might occur.
   */
  // if (devServer) {
  //   devServer.hmr = false;
  // }

  /**
   * Depending on your Babel configuration you might want to keep it.
   * If you don't use `env` in your Babel config, you can remove it.
   *
   * Keep in mind that if you remove it you should set `BABEL_ENV` or `NODE_ENV`
   * to `development` or `production`. Otherwise your production code might be compiled with
   * in development mode by Babel.
   */
  process.env.BABEL_ENV = mode;

  const config = {
    mode,
    /**
     * This should be always `false`, since the Source Map configuration is done
     * by `SourceMapDevToolPlugin`.
     */
    devtool: false,
    context,
     // empty entry when building as federated miniapp
    entry: {},
    resolve: {
      /**
       * `getResolveOptions` returns additional resolution configuration for React Native.
       * If it's removed, you won't be able to use `<file>.<platform>.<ext>` (eg: `file.ios.js`)
       * convention and some 3rd-party libraries that specify `react-native` field
       * in their `package.json` might not work correctly.
       */
      ...Repack.getResolveOptions(platform),

      /**
       * Uncomment this to ensure all `react-native*` imports will resolve to the same React Native
       * dependency. You might need it when using workspaces/monorepos or unconventional project
       * structure. For simple/typical project you won't need it.
       */
      // alias: {
      //   'react-native': reactNativePath,
      // },
    },
    /**
     * Configures output.
     * It's recommended to leave it as it is unless you know what you're doing.
     * By default Webpack will emit files into the directory specified under `path`. In order for the
     * React Native app use them when bundling the `.ipa`/`.apk`, they need to be copied over with
     * `Repack.OutputPlugin`, which is configured by default inside `Repack.RepackPlugin`.
     */
    output: {
      clean: true,
      hashFunction: 'xxhash64',
      path: path.join(dirname, 'build/generated', platform),
      filename: 'index.bundle',
      chunkFilename: '[name].chunk.bundle',
      publicPath: Repack.getPublicPath({ platform, devServer }),
      uniqueName: 'mini-app',
    },
    /**
     * Configures optimization of the built bundle.
     */
    optimization: {
      /** Enables minification based on values passed from React Native Community CLI or from fallback. */
      minimize,
      // /** Configure minimizer to process the bundle. */
      // minimizer: [
      //   new TerserPlugin({
      //     test: /\.(js)?bundle(\?.*)?$/i,
      //     /**
      //      * Prevents emitting text file with comments, licenses etc.
      //      * If you want to gather in-file licenses, feel free to remove this line or configure it
      //      * differently.
      //      */
      //     extractComments: false,
      //     terserOptions: {
      //       format: {
      //         comments: false,
      //       },
      //     },
      //   }),
      // ],
      chunkIds: 'named',
    },
    module: {
      /**
       * This rule will process all React Native related dependencies with Babel.
       * If you have a 3rd-party dependency that you need to transpile, you can add it to the
       * `include` list.
       *
       * You can also enable persistent caching with `cacheDirectory` - please refer to:
       * https://github.com/babel/babel-loader#options
       */
      rules: [
        Repack.REACT_NATIVE_LOADING_RULES,
        Repack.NODE_MODULES_LOADING_RULES,
        Repack.FLOW_TYPED_MODULES_LOADING_RULES,
        // {
        //   test: /\.[cm]?[jt]sx?$/,
        //   include: [
        //     /node_modules(.*[/\\])+react-native/,
        //     /node_modules(.*[/\\])+@react-native/,
        //     /node_modules(.*[/\\])+@react-navigation/,
        //     /node_modules(.*[/\\])+@react-native-community/,
        //     /node_modules(.*[/\\])+expo/,
        //     /node_modules(.*[/\\])+pretty-format/,
        //     /node_modules(.*[/\\])+metro/,
        //     /node_modules(.*[/\\])+abort-controller/,
        //     /node_modules(.*[/\\])+@callstack[/\\]repack/,
        //   ],
        //   use: 'babel-loader',
        // },

         /** Here you can adjust loader that will process your files. */
         {
          test: /\.[jt]sx?$/,
          exclude: [/node_modules/],
          type: 'javascript/auto',
          use: {
            loader: 'builtin:swc-loader',
            /** @type {import('@rspack/core').SwcLoaderOptions} */
            options: {
              env: {
                targets: {
                  'react-native': '0.74',
                },
              },
              jsc: {
                externalHelpers: true,
                transform: {
                  react: {
                    runtime: 'automatic',
                    development: mode === 'development',
                    refresh: mode === 'development' && Boolean(devServer),
                  },
                },
              },
            },
          },
        },
          /** Run React Native codegen, required for utilizing new architecture */
          Repack.REACT_NATIVE_CODEGEN_RULES,
        /**
         * Here you can adjust loader that will process your files.
         *
         * You can also enable persistent caching with `cacheDirectory` - please refer to:
         * https://github.com/babel/babel-loader#options
         */
        {
          test: /\.[jt]sx?$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
            options: {
              /** Add React Refresh transform only when HMR is enabled. */
              plugins:
                devServer && devServer.hmr
                  ? ['module:react-refresh/babel']
                  : undefined,
            },
          },
        },
         /** Additional rule to enable HMR for local workspace packages */
         {
          test: /\.[jt]sx?$/,
          include: [/zephyr-merch/],
          use: 'builtin:react-refresh-loader',
        },
        /**
        /**
         * This loader handles all static assets (images, video, audio and others), so that you can
         * use (reference) them inside your application.
         *
         * If you want to handle specific asset type manually, filter out the extension
         * from `ASSET_EXTENSIONS`, for example:
         * ```
         * Repack.ASSET_EXTENSIONS.filter((ext) => ext !== 'svg')
         * ```
         */
        {
          test: Repack.getAssetExtensionsRegExp(Repack.ASSET_EXTENSIONS),
          use: {
            loader: '@callstack/repack/assets-loader',
            options: {
              platform,
              devServerEnabled: Boolean(devServer),
              /**
               * Defines which assets are scalable - which assets can have
               * scale suffixes: `@1x`, `@2x` and so on.
               * By default all images are scalable.
               */
              scalableAssetExtensions: Repack.SCALABLE_ASSETS,
            },
          },
        },
      ],
    },
    plugins: [
      new Dotenv(),
      /**
       * Configure other required and additional plugins to make the bundle
       * work in React Native and provide good development experience with
       * sensible defaults.
       *
       * `Repack.RepackPlugin` provides some degree of customization, but if you
       * need more control, you can replace `Repack.RepackPlugin` with plugins
       * from `Repack.plugins`.
       */
      new Repack.RepackPlugin({
        context,
        mode,
        platform,
        devServer,
        output: {
          bundleFilename,
          sourceMapFilename,
          assetsPath,
        },
      }),
       // silence missing @react-native-masked-view optionally required by @react-navigation/elements
       new rspack.IgnorePlugin({
        resourceRegExp: /^@react-native-masked-view/,
      }),

      new Repack.plugins.ModuleFederationPluginV2({
        name: 'MiniApp',
        filename: 'MiniApp.container.js.bundle',
        dts: false,
        exposes: {
          './CartNavigator': './navigation/CartNavigator',
        }, 
        shared: getSharedDependencies({eager: STANDALONE})
        
      }),
    ],
  };

  if (USE_ZEPHYR) {
    return withZephyr()(config);
  }

  return config;
};

Add exposed module

Within MiniApp, create a navigation folder and create the content below:

src/navigation/CartNavigator.tsx}
import React from 'react';
import {StyleSheet} from 'react-native';
import {
  createNativeStackNavigator,
  NativeStackNavigationProp,
} from '@react-navigation/native-stack';

import CartScreen from '../screens/CartScreen';

export type MainStackParamList = {
  Cart: undefined;
};

export type MainStackNavigationProp =
  NativeStackNavigationProp<MainStackParamList>;

const Main = createNativeStackNavigator<MainStackParamList>();

const MainNavigator = () => {
  return (
    <Main.Navigator
      screenOptions={{
        headerTitle: 'MiniApp',
        headerBackTitleVisible: false,
        headerStyle: styles.header,
        headerTitleStyle: styles.headerTitle,
        headerTintColor: 'rgba(255,255,255,1)',
      }}>
      <Main.Screen name="Cart" component={CartScreen} />
    </Main.Navigator>
  );
};

const styles = StyleSheet.create({
  header: {
    backgroundColor: 'rgba(79, 55, 139, 1)',
  },
  headerTitle: {
    color: 'rgba(255,255,255,1)',
  },
});

export default MainNavigator;

For MiniApp screen, create a CartScreen.tsx file.

src/screens/CartScreen.tsx}
import React from 'react';
import {
  Image,
  ImageRequireSource,
  ScrollView,
  StyleSheet,
  FlatList,
  Text,
  View,
} from 'react-native';

const pics = (i: number) => {
  return `https://picsum.photos/200/300?random=${i}`;
};

const data = Array(3)
  .fill('')
  .map((_, i) => ({id: i, title: `Picture ${i}`, source: pics(i)}));

const Row = ({title, source}: {title: string; source: string}) => (
  <View style={styles.row}>
    <View style={styles.titleContainer}>
      <Text style={styles.title}>{title}</Text>
      <Text style={styles.subtitle}>
        The quick brown fox jumps over the lazy dog
      </Text>
    </View>
    <Image source={{uri: source}} style={styles.image} />
  </View>
);

const CartScreen = () => {
  return (
    <View style={styles.container}>
      <Text>CartScreen</Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  row: {
    flex: 1,
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    height: 100,
    paddingRight: 20,
  },
  image: {
    width: 90,
    height: 90,
  },
  title: {
    fontSize: 20,
    fontWeight: '500',
  },
  subtitle: {
    fontSize: 12,
    fontWeight: '200',
  },
  titleContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'flex-start',
    paddingLeft: 20,
  },
  separator: {
    height: 1,
    backgroundColor: 'rgba(127, 103, 190, 1)',
  },
});

export default CartScreen;

That's all the modifications within application code itself!

Manage a monorepo

At the root of the monorepo, create a pnpm-workspace.yaml file.

pnpm-workspace.yaml}
packages:
  - '*'

You can use the helpful npm-run-all package to run multiple scripts at once.

pnpm add npm-run-all

And at the root package.json, add the following:

TIP

You can use the run-p command to run scripts in parallel.
Alternatively, you can use mprocs to run scripts in parallel (with a UI).

package.json}
"scripts": {
     "start:ios": "run-p start:host-app:ios start:mini-app:ios",
     "start:android": "run-p start:host-app:android start:mini-app:android",
     "start:host-app": "pnpm --filter HostApp start",
    "start:mini-app": "pnpm --filter MiniApp start",
    "run:host-app:ios": "pnpm --filter HostApp run ios",
    "run:host-app:android": "pnpm --filter HostApp run android",
    "start:host-app:ios": "pnpm --filter HostApp start --platform ios",
    "start:host-app:android": "pnpm --filter HostApp start --platform android",
    "start:mini-app:ios": "pnpm --filter MiniApp start --platform ios",
    "start:mini-app:android": "pnpm --filter MiniApp start --platform android",
}

Running the application

To run the application, you can use the following commands:

Firstly we need to bundle the ios application.

pnpm run:host-app:ios

Then we can run the dev server for both host and mini app.

pnpm start:host-app:ios
pnpm start:mini-app:ios

You should see the applicaiton running in iOS emulator:

Zephyr Cloud Minimal example running in iOS emulator

Manage dependencies

Sharing dependencies in a federated mobile application differs slightly from a federated web application. It requires more effort due to the native aspects of React Native dependencies.

To make module federation work correctly in React Native applications, we need to configure the shared field in the Module Federation plugin from Re.Pack. The general rule is that react and react-native dependencies must be included in the list and marked as singletons. Marking them as singletons ensures that only one instance of such modules is ever initialized, which is a strict requirement for React and React Native.

If the dependencies are specified in the host app (or a mini application running in isolation), they should also include the eager flag. The eager flag ensures that the module is initialized at the start of the app.

The same rule applies to all dependencies that include native code. For example, if any mini-app uses a library with native code (such as react-native-reanimated or react-native-mmkv), it should be added as a singleton and eager-shared dependency in the host app, and as a singleton dependency in the mini-app that uses it.

For shared JavaScript-only dependencies, it’s not necessary to mark them as shared, as Re.Pack can handle downloading them from the mini-app. However, for better network efficiency, it is recommended to include them as shared dependencies.

All this effort requires significant maintenance of the dependencies list in rspack/webpack and package.json for each application. This process can be simplified by using Microsoft’s rnx-align-deps library with a custom preset and helper functions to generate the shared dependencies list.

Handle Navigation

Handling navigation in a React Native federated application differs slightly from web apps, as it does not rely solely on a links-based routing system and the browser's history API. Instead, it incorporates native navigation concepts such as UINavigationController on iOS and Fragment on Android.

This has a few implications:

  • Every navigator needs to be wrapped in a NavigationContainer.
  • It is not possible to navigate directly from one NavigationContainer to another.

Considering best practices for mini application development, one recommended approach is to use multiple NavigationContainer instances: one for the host application and one for each mini application. This allows each mini application to maintain independent navigation states and linking setups (e.g., with unique prefixes). Navigation between mini applications would then rely solely on methods exposed by the host. This approach minimizes the coupling of mini applications by enabling communication between them exclusively through the host application.

However, sometimes we opted for a centralized solution, letting the host manage the navigation using a single NavigationContainer with the navigation structure defined within it. This approach made sense in our case, as some of our mini applications contained only a single isolated screen. It offered several advantages, including:

  • A unified source of navigation truth.
  • Simplified linking setup.
  • Compatibility with all navigation scenarios.
  • Adherence to best navigation practices, such as avoiding nested stack navigators and maintaining type safety.

On the downside, this approach increased coupling, which resulted in challenges for standalone development and required host app updates for any navigation changes within the application.

There is also a third solution: using a single NavigationContainer in the host application while exposing navigators from the mini applications. This approach reduces coupling, allowing mini applications to maintain control over their navigators. However, it can lead to undesirable navigation structures, such as deeply nested stack navigators, and a complex linking setup that requires synchronization between the host and mini applications.

Contributor

Huge thanks for Callstack for working with us to make this possible.

Jakub Romańczyk

Maciej Budziński

Kacper Wiszczuk

Boris Yankov

Maciej Łodygowski