React Native + Next.js Monorepo

Picture of the author
ecklf
11/13/2021·5 min read
Post header image

Table of Contents

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:

tree monorepo-tutorial
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.

monorepo-tutorial/package.json
{
+ "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 package name.

packages/app/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:

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

Metro Configuration

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

packages/app/metro.config.js
+ 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.

yarn add -D @babel/runtime babel-plugin-module-resolver
packages/app/babel.config.js
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.

packages/app/ios/Podfile
- 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.

packages/app/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")
}
packages/app/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)
packages/app/android/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
packages/web/package.json
{
+ "private": true,
+ "name": "@monorepo/web",
+ "version": "1.0.0",
  "main": "index.js",
  "author": "ecklf",
  "license": "MIT",
}