React Native + Next.js Monorepo

Preamble

If you need an introduction to Yarn Workspaces: Yarn Blog

If you prefer looking at the finished repository: GitHub

Initial Setup

Our goal for this blog post is to have a basic monorepo setup that contains one bare React Native app and one Next.js project. This will result in a file structure like this:

monorepo-tutorial
├── package.json
└── packages
    ├── app
    └── web

For starters we create our root directory and initialize a fresh project with git repository.

mkdir monorepo-tutorial && cd monorepo-tutorial && yarn init -y && echo node_modules > .gitignore && git init

Since both of our packages will depend on react we will lift up the dependency to the root level of our monorepo. Note that we also add react-dom in case we want to create more web packages later.

yarn add -W react react-dom

In our package.json we define a workspace structure. The below glob defined in workspaces tells Yarn where our monorepo packages are located.

{
+ "private": true,
+ "name": "root",
  "version": "1.0.0",
  "main": "index.js",
  "author": "ecklf",
  "license": "MIT",
+ "workspaces": [
+   "packages/*"
+ ]
}

We can now proceed with creating our packages folder.

mkdir packages && cd packages

React Native

Let's start by initializing a fresh React Native project from the template:

npx react-native init app --template react-native-template-typescript

You should now encouter this error:

Failed to install CocoaPods dependencies for iOS project.

This is perfectly fine since the template's CocoaPods configuration has the wrong path to react-native.

Continue by removing the react dependency from the template since we will resolve it from the root level.

cd app
yarn remove react

From my experience Metro plays the nicest in monorepos when launched separately with yarn start, so we disable the packaging when running ios / android scripts. While we are at it we can also update the name in our package.json.

{
+ "private": true,
+ "name": "@monorepo/app",
  "version": "1.0.0",
  "main": "index.js",
  "author": "ecklf",
  "license": "MIT",
  "scripts": {
-   "android": "react-native run-android",
+   "android": "react-native run-android --no-packager",
-   "ios": "react-native run-ios",
+   "ios": "react-native run-ios --no-packager",
  },
}

React Native Configuration

Create the file react-native.config.js with the following content:

+ module.exports = {
+   reactNativePath: '../../node_modules/react-native',
+ };

Metro Configuration

Update metro.config.js to have an additional watch folder at root level.

+ const path = require('path');

module.exports = {
+ watchFolders: [path.resolve(__dirname, '../../')],
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
};

Babel Configuration

We need to add aliases to explicitly define where our root-level packages are located in babel.config.js.

yarn add -D @babel/runtime babel-plugin-module-resolver
const path = require("path");

module.exports = {
  presets: ["module:metro-react-native-babel-preset"],
  plugins: [
    [
      "module-resolver",
      {
        root: ["./src"],
        alias: {
          react: require.resolve("react", {
            paths: [path.join(__dirname, "./")],
          }),
          "^react-native$": require.resolve("react-native", {
            paths: [path.join(__dirname, "./")],
          }),
          "^react-native/(.+)": ([, name]) =>
            require.resolve(`react-native/${name}`, {
              paths: [path.join(__dirname, "./")],
            }),
        },
        extensions: [
          ".ios.js",
          ".ios.ts",
          ".ios.tsx",
          ".android.js",
          ".android.ts",
          ".android.tsx",
          ".native.js",
          ".native.ts",
          ".native.tsx",
          ".js",
          ".ts",
          ".tsx",
        ],
      },
    ],
  ],
};

iOS / iPadOS

Podfile

First, we fix our previous install error by now pointing to our root's node_modules folder.

- require_relative '../node_modules/react-native/scripts/react_native_pods'
+ require_relative '../../../node_modules/react-native/scripts/react_native_pods'
- require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
+ require_relative '../../../node_modules/@react-native-community/cli-platform-ios/native_modules'

We can confirm if this worked by installing our pods:

npx pod install

Xcode (workspace) - Signing & Capabilities

Add your development team to build the project.

Xcode (workspace) - Build Phases

Nothing special here. We just adjust the paths like in CocoaPods.

Start Packager
- echo "export RCT_METRO_PORT=${RCT_METRO_PORT}" > "${SRCROOT}/../node_modules/react-native/scripts/.packager.env"
+ echo "export RCT_METRO_PORT=${RCT_METRO_PORT}" > "${SRCROOT}/../../../node_modules/react-native/scripts/.packager.env"

- open "$SRCROOT/../node_modules/react-native/scripts/launchPackager.command" || echo "Can't start packager automatically"
+ open "$SRCROOT/../../../node_modules/react-native/scripts/launchPackager.command" || echo "Can't start packager automatically"

Xcode (workspace) - Bundle React Native code and images

- ../node_modules/react-native/scripts/react-native-xcode.sh
+ ../../../node_modules/react-native/scripts/react-native-xcode.sh

Build Settings

User-Defined

Add a user-defined setting (+ sign at the top menu bar) RCT_NO_LAUNCH_PACKAGER with the value 1.

Android

Getting things to work on Android is just a matter of adding paths for hermes + react-native cli and updating the existing ones.

android/build.gradle

maven {
    // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
-   url("$rootDir/../node_modules/react-native/android")
+   url("$rootDir/../../../node_modules/react-native/android")
}
maven {
    // Android JSC is installed from npm
-   url("$rootDir/../node_modules/jsc-android/dist")
+   url("$rootDir/../../../node_modules/jsc-android/dist")
}

android/settings.gradle

- apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
+ apply from: file("../../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)

app/build.gradle

project.ext.react = [
-  enableHermes: false,  // clean and rebuild if changing
+  enableHermes: true,  // clean and rebuild if changing
+  hermesCommand: "../../../../node_modules/hermes-engine/%OS-BIN%/hermesc",
+  composeSourceMapsPath: "../../node_modules/react-native/scripts/compose-source-maps.js",
+  cliPath: "../../node_modules/react-native/cli.js"
]

- apply from: "../../node_modules/react-native/react.gradle"
+ apply from: "../../node_modules/react-native/react.gradle"

- def hermesPath = "../../node_modules/hermes-engine/android/";
+ def hermesPath = "../../../../node_modules/hermes-engine/android/";

- apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
+ apply from: file("../../../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)

Testing the Configuration

yarn start
yarn ios
yarn android

Next.js

Fortunately adding a Next.js project is more straightforward. All we need to do is delete package-lock.json (we use yarn not npm) and remove our root dependencies from the template.

npx create-next-app@latest --ts web
rm package-lock.json && yarn remove react react-dom
{
+ "private": true,
+ "name": "@monorepo/web",
+ "version": "1.0.0",
  "main": "index.js",
  "author": "ecklf",
  "license": "MIT",
}

24