Monorepos with Next.js and React Native

Not long ago I needed to figure out how to share code efficiently between an existing Next.js project and a newly created React Native project. In this note I'll describe the particulars of our scenario, the options available and the solution implemented.

TLDR

We decide to initialize a new subdirectory in our repo and write a custom Metro configuration to allow our React Native project to access files outside its root directory. No special yarn/git features or extra tools to manage. Not beautiful, but very functional. Read on for more details!

Scenario

Our repo has a Next.js application that lives at the root alongside directories for infrastructure, database migrations, tests and shared code. It looks something like this:

our-lovely-repo
    └─ components
    └─ features
    └─ infra
    └─ lib
    └─ migrations
    └─ pages
    └─ styles
    └─ tests

Where do we introduce a React Native project if we want to share code with our Next.js project?

Note - We will not touch on sharing components, which comes with a whole other set of problems. We'll focus on sharing business logic written in TypeScript/JavaScript.

Options

There are two major approaches to repo organization:

To (over)simplify things a bit, the tradeoff is between structure and speed. With the classic multi repo approach, you have a rigid structure that has more steps and formalism to manage package updates, and as such is usually slower. On the other hand, a monorepo structure can enable faster updates and reduce friction with the right management -- but what is the right approach to management? There are a number of tools, each with their own sets of tradeoffs:

In our scenario we prefer speed above all else, so a monorepo is the solution we lean towards. Before exploring options to make that happen, we should first consider whether Next.js itself is amenable to the idea of living in a monorepo.

A Next.js gotcha

Sensible software engineers that we are, it is tempting to look at the repo structure above, isolate our Next.js project related directories, and initialize a new repo for our React native project:

our-lovely-repo
	└─ infra
	└─ lib
	└─ migrations
	└─ native
		├─ components
		├─ features
		├─ screens
		└─ test
			├─ integration
			└─ smoke
		└─ utils
		└─ styles
	└─ web
		├─ components
		├─ features
		├─ pages
		└─ test
			├─ integration
			└─ smoke
		└─ utils
		└─ styles
	...

Looks clean right? The thing is, Next.js really does not want to live in a subdirectory.

To borrow some links from Joseph Luck's excellent monorepo solution, Next.js is very opinionated about project structure, making monorepos containing Next.js projects very painful:

Joseph's solution is exciting in that someone finally wrote up a working solution for how to get Next.js projects working well in a monorepo structure, but has some drawbacks:

That being said, there might be a middle ground we can explore to avoid upsetting Next.js.

Solution

The solution we eventually landed on is the simplest: leave everything where it is, and create a new subdirectory to house the React Native project:

our-lovely-repo
    └─ components
    └─ features
    └─ infra
    └─ lib
    └─ migrations
    └─ native
        ├─ components
        ├─ features
        ├─ screens
        └─ test
            ├─ integration
            └─ smoke
        └─ lib
        └─ styles
    └─ pages
    └─ styles
    └─ tests

While this structure is not the most ideal since there is no clear separation between project related files, it does allow us to avoid adding any tools or processes and still achieve a monorepo structure. Git performance, although something to consider in the long run, is not an issue at this scale. There is, however, one remaining challenge with this approach: how to share code outside the root of the directory the React Native project lives in.

Metro extra node modules config

Imagine we're in a component in our React Native project and we want to use a util in the parent lib directory:

// In our-lovely-repo/native/screens/home.tsx

import React from 'react';
import { Text, View } from 'react-native';

// The bundler throws an error, can't find the util
import { capitalize } from '../../lib/utils/capitalize';

export function Home(): JSX.Element {
  return (
    <View>
      <Text>{capitalize('hello world!')}</Text>
    </View>
  );
}

The bundler does not know how to access the capitalize module in the top level lib directory. To solve this, we need to tell Metro where to find it:

// In our-lovely-repo/native/metro.config.js

const path = require('path');
const { getDefaultConfig } = require('expo/metro-config');

// Default expo config to merge
const defaultExpoConfig = getDefaultConfig(__dirname);

// Enable access to `lib` top level directorh
const customConfig = {
  resolver: {
    extraNodeModules: new Proxy(
      {
        '@olr/lib': path.resolve(__dirname, '../lib'),
      },
      {
        get: (defaultExtraModules, importedModule) => {
          // Designated parent directories
          if (defaultExtraModules[importedModule]) {
            return defaultExtraModules[importedModule];
          }

          // Parent directory file node module imports
          return path.join(process.cwd(), `../node_modules/${importedModule}`);
        },
      }
    ),
  },
  projectRoot: path.resolve(__dirname),
  watchFolders: [
    path.resolve(__dirname, '../lib'),
    path.resolve(__dirname, '../node_modules'),
  ],
};

module.exports = {
  ...defaultExpoConfig,
  ...customConfig,
};

This config uses a Proxy that does a few things:

In addition, we use an @olr alias (our-lovely-repo) to differentiate between the top level lib directory and the lib subdirectory in native.

How do we enable this alias?

TypeScript paths config

Given that we are using TypeScript in our scenario, we need to tell TS that we want to use the @olr alias and where it should look for files when we use the alias.

// In our-lovely-repo/native/tsconfig.json

{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    ...
    "paths": {
      "@olr/lib/*": ["../lib/*"],
    }
  }
}

With that done, we can now update our import to use the newly defined path alias:

// In our-lovely-repo/native/screens/home.tsx

import React from 'react';
import { Text, View } from 'react-native';

// The bundler is happy, and so are we!
import { capitalize } from '@olr/lib/utils/capitalize';

export function Home(): JSX.Element {
  return (
    <View>
      <Text>{capitalize('hello world!')}</Text>
    </View>
  );
}

Wrapping up

And there we have it! While the solution we decided on may certainly not be appropriate for other use cases, this functional but ugly approach helped us avoid writing a thesis on proper monorepo solutions (🧐) and start scaffolding our React Native project right away.

Thanks for reading, and good luck with your next monorepo! 😄


Thanks for reading! Go home for more notes.