Webpack, when configured for a monorepo, doesn’t just build individual packages; it orchestrates a shared build process that understands inter-package dependencies.

Let’s see it in action. Imagine a monorepo with two packages: shared-ui and app.

shared-ui has a simple component:

// packages/shared-ui/src/Button.js
import React from 'react';

export const Button = ({ children }) => {
  return <button>{children}</button>;
};

And a webpack.config.js that exports a library:

// packages/shared-ui/webpack.config.js
const path = require('path');

module.exports = {
  entry: './src/Button.js',
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'dist'),
    library: 'sharedUi',
    libraryTarget: 'umd',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-react'],
          },
        },
      },
    ],
  },
  externals: {
    react: 'react',
    'react-dom': 'react-dom',
  },
};

app depends on shared-ui:

// packages/app/src/App.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Button } from 'shared-ui'; // <-- This is the magic

function App() {
  return (
    <div>
      <h1>My App</h1>
      <Button>Click Me</Button>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));

And its webpack.config.js looks like this:

// packages/app/webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/App.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-react', '@babel/preset-env'],
          },
        },
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
  resolve: {
    // This is crucial for monorepos
    modules: [path.resolve(__dirname, 'node_modules'), 'node_modules'],
    alias: {
      // This maps the import 'shared-ui' to the actual package
      'shared-ui': path.resolve(__dirname, '../shared-ui/dist/index.js'),
    },
  },
  devServer: {
    static: {
      directory: path.join(__dirname, 'dist'),
    },
    compress: true,
    port: 3000,
  },
};

The core problem this solves is managing dependencies and build artifacts across multiple, independently developable packages within a single repository. Without a monorepo setup, you’d typically publish shared-ui to a registry and then install it as a regular npm dependency in app. This is slow for development, error-prone, and makes cross-package refactoring a nightmare.

Internally, Webpack’s resolve.alias is the key. It tells Webpack: "When you see an import for shared-ui, don’t look in node_modules; instead, go directly to this specific file: ../shared-ui/dist/index.js." This allows app to directly reference the built output of shared-ui without needing it to be published or symlinked in node_modules in the traditional sense. The resolve.modules configuration ensures that Webpack can still find other dependencies (like react or react-dom) that might be hoisted to the root node_modules directory in a typical monorepo setup (e.g., using Yarn Workspaces or Lerna).

The build process for shared-ui needs to run before app’s build if app depends on it. This is often managed by a tool like Lerna or Nx, which can determine build order based on package.json dependencies. A common command would be lerna run build --scope shared-ui && lerna run build --scope app.

The most surprising thing is how resolve.alias directly points to the output of another package. It’s not about linking source code; it’s about linking the compiled artifacts. This means that when you’re developing app and change shared-ui, you typically need to rebuild shared-ui first, then rebuild app for the changes to reflect. Hot Module Replacement (HMR) can sometimes bridge this gap if configured carefully, but the fundamental dependency is on the built output.

The next hurdle is managing shared configurations and tooling across packages, such as Babel presets or ESLint rules, without excessive duplication.

Want structured learning?

Take the full Webpack course →