The most surprising thing about npm build scripts is how little they have to do with npm itself.

Let’s see Webpack in action. Imagine a simple React app.

src/index.js:

import React from 'react';
import ReactDOM from 'react-dom';

function App() {
  return <h1>Hello, Webpack!</h1>;
}

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

public/index.html:

<!DOCTYPE html>
<html>
<head>
    <title>My App</title>
</head>
<body>
    <div id="root"></div>
    <script src="bundle.js"></script>
</body>
</html>

Now, package.json:

{
  "name": "my-webpack-app",
  "version": "1.0.0",
  "scripts": {
    "build": "webpack"
  },
  "devDependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "webpack": "^5.65.0",
    "webpack-cli": "^4.9.1"
  }
}

When you run npm run build, npm simply executes the command webpack. This command, as defined in webpack.config.js (which we’ll get to), tells Webpack to bundle your JavaScript modules. By default, Webpack looks for src/index.js as your entry point and outputs a single file, typically dist/main.js. It then processes all the import statements, resolves dependencies, and creates a final, optimized bundle. The public/index.html file then includes this bundle.js (or main.js depending on configuration) to run your application.

The core problem Webpack solves is managing dependencies and transforming modern JavaScript (and other assets like CSS, images) into a format that browsers can understand and execute efficiently. Before module bundlers, managing dozens or hundreds of JavaScript files and their interdependencies was a nightmare of <script> tags and global scope pollution. Webpack, at its heart, is a module bundler. It takes your code, understands how different files depend on each other, and stitches them together into one or more optimized output files.

Here’s a more detailed webpack.config.js:

const path = require('path');

module.exports = {
  mode: 'development', // 'production' for optimization
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react']
          }
        }
      }
    ]
  },
  resolve: {
    extensions: ['.js', '.jsx'],
  },
};

This config tells Webpack:

  • mode: Start in development mode. This means less optimization but faster build times and better debugging information. Switch to 'production' for minification, tree-shaking, and other optimizations.
  • entry: Your application’s starting point is ./src/index.js. Webpack will trace all import statements from here.
  • output: The bundled output should be named bundle.js and placed in a directory named dist at the root of your project.
  • module.rules: This is where the magic of loaders happens.
    • For any file ending in .js or .jsx (and not in node_modules), use babel-loader.
    • babel-loader uses @babel/preset-env to transpile modern JavaScript to a version compatible with more browsers, and @babel/preset-react to handle JSX syntax.
  • resolve.extensions: When you import 'some-module' without an extension, Webpack will try to find some-module.js and some-module.jsx.

The npm run build command triggers this configuration. Webpack reads webpack.config.js, finds the entry point, processes it through the specified loaders (like Babel for JSX and modern JS), resolves any imported modules, and finally outputs the bundle.js to the dist folder. The public/index.html should then point to dist/bundle.js.

When you’re running Webpack, you’re not just running a command; you’re orchestrating a pipeline of transformations. Each loader in your webpack.config.js is a step in that pipeline. For instance, if you wanted to process CSS, you’d add a rule for .css files, typically using style-loader and css-loader. If you were using TypeScript, you’d add ts-loader or @babel/preset-typescript. The real power lies in chaining these loaders and configuring them precisely for your project’s needs.

The npm script build is just a convenient alias. You could also run npx webpack --config webpack.config.js directly. The npm script becomes the single source of truth for how to build your project, abstracting away the underlying tool.

Most people understand that Webpack bundles code. What’s less obvious is how Webpack handles code splitting and lazy loading, which is managed through dynamic import() syntax. When Webpack encounters import('./module.js'), it doesn’t just include that module in the initial bundle. Instead, it creates a separate chunk for ./module.js and its dependencies. This chunk is then fetched by the browser on demand, only when that import() statement is executed. This dramatically improves initial load times by only shipping the code that’s immediately needed.

The next step is often integrating a development server with hot module replacement for a seamless development experience.

Want structured learning?

Take the full Npm course →