Share JavaScript code between frontend apps in a monorepo through a local NPM package

If you have multiple frontend apps in a monorepo and want to share code between them to avoid duplication, you are probably better off doing it locally rather than going the whole "setup a private registry, publish a package on it and install it everywhere" route. Fortunately, NPM makes it pretty simple to use a filesystem-based package:

{
// ...
"dependencies": {
// ...
"@neemzy/common": "file:../common",
// ...
},
// ...
}

This simple setup is enough... if the module(s) you share do not import anything, which will not get you very far. Let's see how to handle dependencies within the common package!

Your first instinct might be to just install these dependencies through the package's own package.json file, but this might not actually be your best option:

  • unless you are very thorough with version constraints, you will probably end up with different versions of these dependencies between common and the host app, which might cause trouble down the road
  • this requires more unnecessary maintenance regarding common itself
  • if you use React and there is code in common which imports react, your bundle will end up including two copies of it, which will not work at runtime (even if both are the exact same version)

It is far more convenient to just let the host app supply the dependencies, which actually makes sense if we consider modules in common have a fair chance to have originated in one of your apps, and were moved to common once they were needed in another. Since we will not be specifying common's dependencies directly, we need to tell our build tool to explicitly look for dependencies in the app's node_modules directory upon bundling our code. Here is how to do it with Webpack:

const path = require("path");

module.exports = {
// ...
resolve: {
modules: [
// ...
"node_modules", // generic "resolve in relative node_modules directory"
path.resolve(__dirname, "node_modules"), // explicitly check the app's node_modules as a backup
],
// ...
},
// ...
};

And with Browserify (for e.g. Gulp):

const path = require("path");

// ...

const b = browserify({
// ...
paths: [
path.resolve(__dirname, "node_modules"),
],
// ...
});

// ...

Note that if you used create-react-app to bootstrap your app, this step is not necessary: the default Webpack configuration seems to have this taken care of.

This should cover it for runtime! Now, if the host app has unit tests for code which imports from common, the same logic applies: we have to let our test runner (this example uses Jest) know that it needs to look for dependencies in the local node_modules:

const path = require("path");

module.exports = {
//...
moduleDirectories: [
"node_modules",
path.join(__dirname, "node_modules"),
],

// You might need this in order to transpile the package's code with Babel before Jest interprets it:
transformIgnorePatterns: ["node_modules/(?!@neemzy/common)"],

// ...
};

Finally, we also need to handle this the other way around: if common has unit tests for modules which import stuff from third-party dependencies, these will also be needed in order for the tests to run; we will therefore tell Jest to fetch dependencies from neighbour apps:

const path = require("path");

module.exports = {
// ...
moduleDirectories: [
path.join(__dirname, "../my-app/node_modules"),
// add more of your apps here if needed
],
// ...
};

With all of this, everything should be working as intended, and copied and pasted files now belong in the past!

Update: there is actually one more caveat the above solution does not deal with, which is that when building the app locally in development, devDependencies from the local package might still take precedence over the apps' own dependencies if the former are installed (given npm install actually uses npm link under the hood for local packages), which in turn might cause the same two-copies-of-React-at-runtime issue I described above if those devDependencies happen to bring along React, like @testing-library/react. Since we set up common's unit tests to borrow dependencies from neighbour apps, one ugly but effective solution is to explicitly remove react and react-dom in a postinstall script in common's package.json:

{
// ...
"scripts": {
// ...
"postinstall": "rm -rf node_modules/react node_modules/react-dom",
// ...
},
// ...
}