Using webpack + `npm link` for react or vue libraries

How to make webpack work with npm link and shared dependencies.

webpack + npm

Intro

The benefits of splitting re-usable or library code off into its own NPM package are pretty obvious. What complicates matters is how you expect to continue developing or maintaining such a package. Here are the most usable options:

  • (Terrible) Republish every change - You could bump your version number and republish every change to your NPM registry, then pull that change into a "real" project to test those changes. This wastes a lot of your time and creates tons of wasted version numbers.
  • (Bad) Manually pack your library locally - You could run npm pack after ever change to create the archive of your project, then install that archive directly into your "real" project to test your changes. This is tedious and requires you to remember to re-install your package from the real registry when you're done testing.
  • (Best) npm link the local package code - npm link lets you symlink the library package into the "real" project so you can test your changes without packing or publishing. This won't pollute CI build processes and even works with HMR.

npm link is a really awesome feature that lets you temporarily replace a dependency in your project with a symlink to another codebase sitting on your machine. Once linked, you can change code in your library project and see it directly in your down-stream project without packing or publishing. If set up correctly, it even works with Hot Module Reloading, as if the library code existed right inside your main project.


The setup

The process for npm link is as follows:

  1. Open a terminal in your library project. We'll call it "@my-org/library". Be sure your terminal is sitting in the directory where your "package.json" is.
  2. Type npm link inside that terminal. This will create a symlink from your current directory to the global NPM directory on your machine.
  3. Open a new terminal or navigate the original one to your main project. We'll call it "@my-org/project". Be sure your terminal is sitting in the directory where your "package.json" is.
  4. Type npm link @my-org/library. This will create a symlink from the global NPM directory on your machine to the "node_modules" directory inside your main project.
  5. build, test, or watch your main project as normal.
  6. When you're done testing, you can run npm unlink @my-org/library --no-save && npm install to restore the dependency to the one from the real registry. Even if you skip or forget this step, any CI pipelines or team members won't have an issue. Your "package.json" file still references the real NPM registry, so they'll pull the published version of your package.

This also assumes you've set up your library project correctly. Namely, that:

  • Dependencies that your library requires, but not for its own build process, are moved to the peerDependencies section of your library's "package.json". So things like webpack would still be in devDependencies but something like react or vue would be in peerDependencies.
  • Similar to above, items in your library's peerDependencies should be declared as "external" in your webpack config:
module.exports = {
  //...
  externals: {
    vue: 'vue',
  },
};

The problem

In a perfect world, this would be seamless with the library package you downloaded from the real NPM registry. In reality, our projects and build processes can be very complicated.

If you're using a frontend library like react or vue, you may find that your devtools are complaining about multiple instances of react/vue running or code that depends on a global instance of your frontend library may act like it isn't working with the rest of your project's code.

This is because that's exactly what's going on. When your main project has to resolve the "react" or "vue" module, it looks in its own "node_modules" folder to find it. This works fine when you install your library package from the real registry, since that library is forced to use the main project's "node_modules" as well.

However, when you npm link the library, you're linking to the raw source code for the package. It has its own "node_modules" folder sitting there and the webpack inside your main project happily lets your library resolve modules inside the library's "node_modules" first.

In the end, you have two instances of your react or vue module running in your main project.


The solution

Webpack has a very useful alias feature. You may already be using it for your frontend library (eg. forcing webpack to resolve "vue" to a specific distribution, per the docs). However, we need to be very explicit in telling our main project's webpack how to resolve these dependencies:

// webpack.config.js

const path = require('path');

module.exports = {
  //...
  resolve: {
    alias: {
      vue: path.resolve(__dirname, 'node_modules', 'vue', 'dist', 'vue.js')
    }
  }
};

Here, we tell webpack the exact, literal path to the file we want it to supply whenever any of our code requests the "vue" module (import Vue from "vue"). This will specifically be the instance of the file inside the main project's "node_modules" folder, ignoring any other instance in the symlinked library.

The incorrect way to do this is to let webpack resolve the module's literal path for us:

// webpack.config.js

const path = require('path');

module.exports = {
  //...
  resolve: {
    alias: {
      vue: "vue/dist/vue.js"
    }
  }
};

This is the kind of recursive module resolution that lets our selfish library code resolve its own module instance, rather than using the one already present in the main project.


Other notes

This module resolution step was the only major headache I had with getting npm link to work with my projects. But there are a few other pitfalls.

  • If HMR isn't working, be sure that your webpack isn't excluding files from "node_modules" entirely. Lots of example configs online will add exclude: /node_modules/ to config blocks for JS transpilers. Either remove those exclude lines or add your library as an explicit include: include: path.resolve(__dirname, "node_modules", "@my-org", "library", "src").
  • Older versions of NPM would install peerDependencies seen in your main project's dependencies. Newer versions of NPM (6-8) do not. You will need to list shared dependencies in both "package.json" files. There are some open github issues related to people wanting this behavior reverted and it seems like the NPM maintainers are looking into it.