Webpack 5 Config for MFE

Webpack 5 Configuration for Micro Frontends — Complete Guide

Every Webpack 5 micro frontend configuration starts the same way — entry, output, loaders, plugins, dev server. But once you add Module Federation, HTTPS certificates, CORS headers, shared packages, and the split between local development and production builds, the config file becomes the most critical piece of your entire MFE architecture.

Already set up your monorepo? This article continues from Shared Packages in MFE Monorepo. If you're starting fresh, begin with React MFE Monorepo Setup.

This guide walks through every section of the Webpack 5 config — with real code from a 12-MFE production monorepo — showing exactly what changes between local development and production builds.

Webpack 5 micro frontend configuration diagram showing entry output loaders plugins and dev server sections

Why Webpack 5 Config Is Different for Micro Frontends

A standard single-page app has one webpack.config.js. A micro frontend monorepo has one config per MFE — a host config and one remote config for every micro frontend. Each config must handle:

  • Entry/output — host uses / publicPath, remotes use unique paths
  • Dev server — HTTPS certificates for secure cross-origin script loading
  • CORS headers — host must fetch remoteEntry.js from remote dev servers
  • Module Federation — remotes, exposes, and shared dependencies
  • Code splitting — disabled locally for speed, enabled in production for caching
  • Resolve aliases — mapping shared package imports to the packages/ directory

The biggest gotcha: local development and production configs are fundamentally different. Remotes change from https://localhost:PORT/remoteEntry.js to /mfe-name/remoteEntry.js, HTTPS certs disappear, and optimization goes from zero to full vendor splitting.

Entry and Output — Host vs Remote

The entry and output sections seem simple, but the publicPath difference between host and remote MFEs is what makes Module Federation work.

Host Application

The host serves at the root path (/) in both environments:

apps/host/webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: path.resolve(__dirname, "src", "index.js"),
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].bundle.js",
    publicPath: "/",
    chunkFilename: "[name].bundle.js",
  },
};

Remote MFE

Remote MFEs need a full URL locally so the host can fetch their remoteEntry.js over the network. In production, they use a relative path behind a reverse proxy:

apps/onboarding/webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: path.resolve(__dirname, "src", "index.js"),
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].bundle.js",
    // Full URL required — host must fetch remoteEntry.js over the network
    publicPath: "https://localhost:4001/",
    clean: true,
  },
};

Why publicPath matters for Module Federation. When the host loads a remote's remoteEntry.js, all subsequent chunk requests from that remote use its publicPath as the base URL. If the remote's publicPath is wrong, every lazy-loaded chunk will 404.

Babel Loader — Transpiling JSX and Modern JavaScript

Every MFE needs Babel to transform JSX and modern JavaScript into browser-compatible code. The standard setup uses two presets:

apps/host/webpack.config.js
module: {
  rules: [
    {
      test: /\.(jsx|js)$/,
      include: path.resolve(__dirname, "src"),
      exclude: path.resolve(__dirname, "node_modules"),
      use: [
        {
          loader: "babel-loader",
          options: {
            presets: [
              [
                "@babel/preset-env",
                {
                  targets: "defaults",
                },
              ],
              "@babel/preset-react",
            ],
          },
        },
      ],
    },
  ],
}

Including Shared Packages Directory

When your MFE imports shared packages from the monorepo packages/ directory, those packages contain JSX that also needs transpilation. Standard Babel config only processes src/ — you must explicitly include the packages directory:

apps/auth/webpack.config.js
module: {
  rules: [
    {
      test: /\.(jsx|js)$/,
      // Include BOTH src AND shared packages directory
      include: [
        path.resolve(__dirname, "src"),
        path.resolve(__dirname, "../../packages"),
      ],
      exclude: path.resolve(__dirname, "node_modules"),
      use: [
        {
          loader: "babel-loader",
          options: {
            presets: [
              ["@babel/preset-env", { targets: "defaults" }],
              "@babel/preset-react",
            ],
          },
        },
      ],
    },
  ],
}
⚠️

Common mistake: forgetting to include ../../packages in the Babel include path. The MFE builds fine because Webpack resolves the import via the alias, but the untranspiled JSX in the shared package causes a runtime SyntaxError: Unexpected token < in the browser.

CSS and Asset Handling

CSS with PostCSS (Tailwind CSS support)

The CSS pipeline extracts styles into separate files using MiniCssExtractPlugin and runs PostCSS for Tailwind CSS processing:

webpack.config.js — CSS rule
{
  test: /\.css$/i,
  use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"],
},

The loader chain runs right to left: postcss-loader processes Tailwind directives and autoprefixer, css-loader resolves @import and url(), then MiniCssExtractPlugin.loader extracts the CSS into a separate .css file instead of injecting it into the DOM via JavaScript.

Image and Asset Handling

Webpack 5 has built-in asset modules (opens in a new tab) — no need for file-loader or url-loader:

webpack.config.js — asset rule
{
  test: /\.(png|jpeg|gif|jpg)$/i,
  type: "asset/resource",
},

Using type: "asset/resource" emits the file as-is and provides a URL. Webpack 5 replaces the older loader-based approach with native asset module support.

Dev Server — HTTPS, CORS, and historyApiFallback

The dev server config is where local and production diverge the most. Local development needs HTTPS certificates and CORS headers. Production doesn't use devServer at all — Nginx or a reverse proxy serves the static files.

apps/host/webpack.config.js
const fs = require("fs");
const certPath = path.join(__dirname, "../../localhost.pem");
const keyPath = path.join(__dirname, "../../localhost-key.pem");

// Load local HTTPS certificates (generated with mkcert)
const httpsConfig =
  fs.existsSync(certPath) && fs.existsSync(keyPath)
    ? {
        type: "https",
        options: {
          cert: fs.readFileSync(certPath),
          key: fs.readFileSync(keyPath),
        },
      }
    : {
        type: "https",
        options: {
          requestCert: false,
          rejectUnauthorized: false,
        },
      }; // Fallback to self-signed

module.exports = {
  devServer: {
    server: httpsConfig,
    static: {
      directory: path.resolve(__dirname, "dist"),
    },
    open: true,
    port: 4000,
    historyApiFallback: true,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
      "Access-Control-Allow-Headers":
        "X-Requested-With, content-type, Authorization",
    },
  },
};

Why HTTPS Is Required Locally

Module Federation loads remote code by injecting <script> tags at runtime. If your host runs on HTTPS (which most modern dev setups do), the browser blocks any HTTP script requests as mixed content. Every remote dev server must also run HTTPS.

Use mkcert (opens in a new tab) to generate trusted local certificates:

# Install mkcert and generate certificates
mkcert -install
mkcert localhost
# Creates localhost.pem and localhost-key.pem

Place the certificate files at the monorepo root. Each MFE's Webpack config reads them using path.join(__dirname, "../../localhost.pem").

Why CORS Headers Are Required

The host running on https://localhost:4000 fetches remoteEntry.js from https://localhost:4001, 4002, etc. Without CORS headers, the browser blocks these cross-origin requests. Setting Access-Control-Allow-Origin: * in development allows all origins. In production, you'd whitelist specific domains instead.

historyApiFallback

Each MFE uses client-side routing (React Router). Without historyApiFallback: true, refreshing on a deep route like /products/123 returns a 404 because the dev server looks for a literal /products/123 file. This setting redirects all requests to index.html, letting React Router handle the routing.

Resolve — Extensions and Aliases

The resolve section maps shared package import paths to their actual directory locations:

apps/host/webpack.config.js
resolve: {
  extensions: [".js", ".jsx", ".json"],
  alias: {
    "@myapp/api": path.resolve(__dirname, "../../packages/core/api"),
    "@myapp/store": path.resolve(__dirname, "../../packages/core/store"),
  },
},

extensions lets you import files without specifying the extension: import App from './App' resolves to App.js, App.jsx, or App.json in order.

alias maps the @myapp/store and @myapp/api import paths to the actual package directories in the monorepo. Without aliases, Webpack wouldn't know where @myapp/store lives on disk.

Important: Aliases handle build-time resolution. Module Federation's shared config handles runtime deduplication. You need both — covered in detail in Shared Packages in MFE Monorepo.

Plugins — MiniCssExtract and HtmlWebpack

Beyond Module Federation (which is covered in-depth in the next article), every MFE config needs two essential plugins:

apps/host/webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");

plugins: [
  // Module Federation plugin goes here (covered in next article)
  new MiniCssExtractPlugin(),
  new HtmlWebpackPlugin({
    template: "./src/index.html",
    filename: "index.html",
  }),
],
  • MiniCssExtractPlugin (opens in a new tab) — extracts CSS into separate files instead of inlining them in JavaScript bundles. Critical for production caching — CSS changes don't invalidate JS bundles and vice versa.
  • HtmlWebpackPlugin — generates the index.html with the correct script and link tags injected automatically. Each MFE has its own src/index.html template.

Optimization — Local vs Production

This is the biggest difference between local and production configs. Local development disables code splitting entirely for faster rebuilds. Production enables full vendor splitting with deterministic module IDs for long-term browser caching.

apps/host/webpack.config.js
// Local Development — disable code splitting for faster rebuilds
optimization: {
  splitChunks: false,
},

What Each Production Setting Does

SettingPurpose
moduleIds: "deterministic"Generates stable numeric IDs for modules. Without this, adding a single file can change every chunk's hash, busting all browser caches
runtimeChunk: "single"Extracts the Webpack runtime into its own chunk. When a module changes, only its chunk and the runtime chunk update — not every chunk
splitChunks.chunks: "all"Splits both sync and async chunks, extracting shared vendor code
cacheGroups.vendorCreates a separate chunk for every node_modules package (e.g., vendor.react.js, vendor.axios.js). Only the changed package's chunk gets a new hash on update
performance.hints: falseSuppresses Webpack warnings about bundle size. The 512KB limits are set as guardrails for monitoring, not blocking

Why splitChunks: false locally? Code splitting creates multiple small files, computes content hashes, and manages chunk loading graphs — all of which slow down rebuilds and HMR. In development, a single large bundle that rebuilds in 200ms is better than 50 optimized chunks that take 2 seconds.

Remotes With Multiple Exposes

Some MFEs expose a single component, while others expose many. Here's a real example of a Product Management remote that exposes 9 components:

apps/products/webpack.config.js
// ProductManagement remote — exposes 9 components
new ModuleFederationPlugin({
  name: "Products",
  filename: "remoteEntry.js",
  exposes: {
    "./BulkUploadPage": "./src/pages/BulkUploadPage.jsx",
    "./DownloadTemplatePage": "./src/pages/DownloadTemplatePage.jsx",
    "./ProductsMFE": "./src/ProductsMFE.jsx",
    "./MyProductCatalogPage": "./src/pages/MyProductCatalogPage.jsx",
    "./MyProductImagesPage": "./src/pages/MyProductImagesPage.jsx",
    "./StyleManagementPage": "./src/pages/StyleManagementPage.jsx",
    "./SingleUploadPage": "./src/pages/SingleUploadPage.jsx",
    "./ProductCatalogManagement": "./src/pages/ProductCatalogManagement.jsx",
    "./ModelApprovalPage": "./src/pages/ModelApprovalPage.jsx",
  },
  shared: { /* ... */ },
})

Each exposed module becomes separately loadable — the host can lazy-load ./BulkUploadPage without loading ./StyleManagementPage. This is why Module Federation is powerful for large MFEs with many pages.

Full Config Comparison — Local vs Production

Here are the complete, working Webpack configs for both the host and a remote MFE, showing every difference between local and production:

Host Application — Full Config

apps/host/webpack.config.js
// webpack.config.js — Host Application (Local Development)
const path = require("path");
const fs = require("fs");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
const dependencies = require("./package.json").dependencies;

// ========== HTTPS Configuration ==========
const certPath = path.join(__dirname, "../../localhost.pem");
const keyPath = path.join(__dirname, "../../localhost-key.pem");

const httpsConfig =
  fs.existsSync(certPath) && fs.existsSync(keyPath)
    ? {
        type: "https",
        options: {
          cert: fs.readFileSync(certPath),
          key: fs.readFileSync(keyPath),
        },
      }
    : {
        type: "https",
        options: { requestCert: false, rejectUnauthorized: false },
      };

module.exports = {
  mode: "development",
  entry: path.resolve(__dirname, "src", "index.js"),
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].bundle.js",
    publicPath: "/",
    chunkFilename: "[name].bundle.js",
  },
  devServer: {
    server: httpsConfig,
    static: { directory: path.resolve(__dirname, "dist") },
    open: true,
    port: 4000,
    historyApiFallback: true,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
      "Access-Control-Allow-Headers":
        "X-Requested-With, content-type, Authorization",
    },
  },
  resolve: {
    extensions: [".js", ".jsx", ".json"],
    alias: {
      "@myapp/api": path.resolve(__dirname, "../../packages/core/api"),
      "@myapp/store": path.resolve(__dirname, "../../packages/core/store"),
    },
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "Main",
      filename: "remoteEntry.js",
      remotes: {
        Onboarding: "Onboarding@https://localhost:4001/remoteEntry.js",
        Products: "Products@https://localhost:4002/remoteEntry.js",
        Inventory: "Inventory@https://localhost:4003/remoteEntry.js",
        Orders: "Orders@https://localhost:4004/remoteEntry.js",
        Pricing: "Pricing@https://localhost:4005/remoteEntry.js",
        Earnings: "Earnings@https://localhost:4006/remoteEntry.js",
        Analytics: "Analytics@https://localhost:4007/remoteEntry.js",
        Reports: "Reports@https://localhost:4008/remoteEntry.js",
        Settings: "Settings@https://localhost:4009/remoteEntry.js",
        Support: "Support@https://localhost:4010/remoteEntry.js",
        Reviews: "Reviews@https://localhost:4011/remoteEntry.js",
      },
      shared: {
        react: { singleton: true, requiredVersion: dependencies.react },
        "react-dom": { singleton: true, requiredVersion: dependencies["react-dom"] },
        "react-router-dom": { singleton: true, requiredVersion: dependencies["react-router-dom"] },
        "@reduxjs/toolkit": { singleton: true, requiredVersion: "^2.6.0" },
        "react-redux": { singleton: true, requiredVersion: "^9.2.0" },
        "@myapp/store": { singleton: true, requiredVersion: "0.0.1" },
      },
    }),
    new MiniCssExtractPlugin(),
    new HtmlWebpackPlugin({
      template: "./src/index.html",
      filename: "index.html",
    }),
  ],
  module: {
    rules: [
      {
        test: /\.(jsx|js)$/,
        include: path.resolve(__dirname, "src"),
        exclude: path.resolve(__dirname, "node_modules"),
        use: [{
          loader: "babel-loader",
          options: {
            presets: [
              ["@babel/preset-env", { targets: "defaults" }],
              "@babel/preset-react",
            ],
          },
        }],
      },
      {
        test: /\.css$/i,
        use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"],
      },
      {
        test: /\.(png|jpeg|gif|jpg)$/i,
        type: "asset/resource",
      },
    ],
  },
  optimization: {
    splitChunks: false,
  },
};

Remote MFE — Full Config

apps/onboarding/webpack.config.js
// webpack.config.js — Remote MFE (Local Development)
const path = require("path");
const fs = require("fs");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
const dependencies = require("./package.json").dependencies;

// ========== HTTPS Configuration ==========
const certPath = path.join(__dirname, "../../localhost.pem");
const keyPath = path.join(__dirname, "../../localhost-key.pem");

const httpsConfig =
  fs.existsSync(certPath) && fs.existsSync(keyPath)
    ? {
        type: "https",
        options: {
          cert: fs.readFileSync(certPath),
          key: fs.readFileSync(keyPath),
        },
      }
    : {
        type: "https",
        options: { requestCert: false, rejectUnauthorized: false },
      };

module.exports = {
  mode: "development",
  entry: path.resolve(__dirname, "src", "index.js"),
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].bundle.js",
    publicPath: "https://localhost:4001/",
    clean: true,
  },
  devServer: {
    server: httpsConfig,
    static: { directory: path.resolve(__dirname, "dist") },
    open: true,
    port: 4001,
    historyApiFallback: true,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
      "Access-Control-Allow-Headers":
        "X-Requested-With, content-type, Authorization",
    },
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "Onboarding",
      filename: "remoteEntry.js",
      exposes: {
        "./OnboardingApp": "./src/OnboardingApp.jsx",
      },
      shared: {
        react: { singleton: true, requiredVersion: dependencies.react },
        "react-dom": { singleton: true, requiredVersion: dependencies["react-dom"] },
        "react-router-dom": { singleton: true, requiredVersion: dependencies["react-router-dom"] },
        "@myapp/store": { singleton: true, requiredVersion: "0.0.1" },
        swiper: { singleton: true, requiredVersion: dependencies["swiper"] },
      },
    }),
    new MiniCssExtractPlugin(),
    new HtmlWebpackPlugin({
      template: "./src/index.html",
      filename: "index.html",
    }),
  ],
  module: {
    rules: [
      {
        test: /\.(jsx|js)$/,
        include: path.resolve(__dirname, "src"),
        exclude: path.resolve(__dirname, "node_modules"),
        use: [{
          loader: "babel-loader",
          options: {
            presets: [
              ["@babel/preset-env", { targets: "defaults" }],
              "@babel/preset-react",
            ],
          },
        }],
      },
      {
        test: /\.css$/i,
        use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"],
      },
      {
        test: /\.(png|jpeg|gif|jpg)$/i,
        type: "asset/resource",
      },
    ],
  },
  optimization: {
    splitChunks: false,
  },
};

Configuration Differences at a Glance

Webpack 5 micro frontend local vs production configuration comparison table showing all setting differences

Here's every setting that changes between environments:

SettingLocal DevelopmentProduction / Server
mode"development""production"
filename"[name].bundle.js""[name].bundle.js" or "[name].[contenthash].js"
publicPath (host)"/""/"
publicPath (remote)"https://localhost:PORT/""/mfe-name/"
devServerHTTPS + CORS + historyApiFallbackNot needed (Nginx serves files)
HTTPS certsmkcert local certificatesHandled by reverse proxy
CORS headersWildcard * (all origins)Whitelist specific domains
remoteshttps://localhost:PORT/remoteEntry.js/mfe-name/remoteEntry.js
splitChunksfalse (disabled for speed)Full vendor splitting enabled
moduleIdsDefault (natural)"deterministic" (stable hashes)
runtimeChunkNot set"single" (shared runtime)
performanceNot sethints: false, 512KB limits

Common Webpack 5 Mistakes in MFE Projects

1. Wrong publicPath on Remotes

If a remote's publicPath doesn't match where its files are actually served, every chunk request after remoteEntry.js will 404. The remote loads but all its lazy-loaded components fail silently.

2. Missing HTTPS Certificates

Forgetting to set up mkcert means your local dev servers run on HTTP. The host on HTTPS blocks all remote script loads as mixed content. You see ERR_BLOCKED_BY_CLIENT or net::ERR_INSECURE_RESPONSE in the console.

3. Forgetting CORS Headers on Remote Dev Servers

The host at localhost:4000 fetching from localhost:4001 is a cross-origin request. Without Access-Control-Allow-Origin: *, the browser blocks the fetch and remoteEntry.js never loads.

4. Not Including Packages in Babel Include

Shared packages in packages/ contain JSX. If Babel only processes src/, the raw JSX hits the browser and throws SyntaxError: Unexpected token <.

5. Using splitChunks in Development

Enabling vendor splitting in development makes HMR slower and debugging harder. Every code change potentially re-generates chunk hashes across multiple files.

Common Webpack 5 micro frontend mistakes checklist showing wrong publicPath HTTPS CORS babel and splitChunks issues

What's Next?

This article covered every section of the Webpack 5 configuration for Micro Frontends — entry/output, loaders, dev server, resolve, plugins, and optimization — with real code showing exactly what changes between local development and production. In the next article, we'll set up Tailwind CSS in a Micro Frontend monorepo — covering PostCSS config, shared theme setup, and how to prevent style conflicts between MFEs.

← Back to Shared Packages in MFE Monorepo

Continue to Tailwind CSS in Micro Frontend Monorepo →


Frequently Asked Questions

Why does publicPath differ between local and production in a micro frontend Webpack config?

In local development, remote MFEs use a full URL like https://localhost:4001/ so the host can fetch remoteEntry.js over the network from a different dev server. In production, remotes use a relative path like /onboarding/ because all MFEs are served behind a single reverse proxy or CDN, so the browser resolves paths relative to the same domain.

Why is splitChunks disabled in local development for micro frontends?

Disabling splitChunks locally speeds up builds and hot module replacement. Code splitting adds overhead — creating multiple chunk files, computing content hashes, and managing chunk loading. In development you optimize for rebuild speed, not bundle size. Production enables full vendor splitting with deterministic moduleIds for long-term caching.

Why do micro frontends need HTTPS in local development?

Module Federation loads remote MFE code via script tags at runtime. Browsers block mixed content — if your host runs on HTTPS, all remote script URLs must also be HTTPS. Using mkcert (opens in a new tab) to generate local certificates ensures all dev servers communicate securely without browser warnings or blocked requests.

What is the difference between resolve.alias and Module Federation shared in Webpack?

resolve.alias is a build-time mechanism that tells Webpack where to find a package on disk — it maps import paths like @myapp/store to the actual directory. Module Federation shared is a runtime mechanism that ensures only one instance of a dependency loads across all MFEs. You need both: aliases for import resolution, shared for runtime deduplication.

How does vendor splitting work in a production micro frontend Webpack config?

Production configs use splitChunks with a cacheGroups vendor rule that extracts every node_modules package into its own chunk named vendor.package-name. Combined with deterministic moduleIds, this creates stable filenames that only change when the package version changes, enabling long-term browser caching across deployments.

Why does the Babel loader include the packages directory in some MFE Webpack configs?

When MFEs consume shared packages from a monorepo packages/ directory, those packages contain JSX or modern JavaScript that needs transpilation. By default, Babel only processes the src/ directory. Adding the packages/ directory to the include array ensures shared code like @myapp/store and @myapp/api is also transpiled correctly.