Automatic publicPath Configuration for Remotes #

When a host on https://shell.example.com loads a remote published on https://checkout.example.com, the remote’s lazy chunks start 404ing against the host’s origin — because output.publicPath was baked in at the remote’s build time and never accounts for where the host actually runs.

This guide shows why that happens and how to make a remote resolve its own asset base at runtime, so its chunks always load from the origin that served its remoteEntry.js.

Runtime publicPath routes chunks to the remote's own origin The host on origin A loads remoteEntry.js from origin B; a static publicPath wrongly fetches chunks from A, while a runtime-derived publicPath correctly fetches them from B. Origin A — Host shell.example.com Origin B — Remote checkout.example.com remoteEntry.js src_Page_chunk.js 1. load remoteEntry static publicPath → 404 on A 2. runtime publicPath → chunk from B
A static publicPath sends chunk requests to the host's origin (404); a runtime-derived publicPath routes them back to the remote's own origin.

Prerequisites #

Why a static publicPath breaks #

output.publicPath is the URL prefix webpack prepends to every chunk it requests at runtime. When you hardcode it:

// webpack.config.js (remote) — the trap
module.exports = {
  output: {
    publicPath: '/', // resolves against whatever origin runs the code
  },
};

That / is relative to the document, not to the bundle. When the remote’s code executes inside a host page on https://shell.example.com, '/' resolves to https://shell.example.com/. The remote then asks the host’s origin for src_Page_js.chunk.js, which only exists on https://checkout.example.com. Result: a 404 and a failed dynamic import.

Hardcoding the absolute remote URL (publicPath: 'https://checkout.example.com/') “works” but couples the build to one environment — it breaks across staging, preview, and per-PR deploys, and forces a rebuild for every move.

Step 1 — Switch to 'auto' publicPath #

webpack 5 can compute the public path at runtime from the URL of its own bootstrap script. Set it to the string 'auto':

// webpack.config.js (remote)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  output: {
    publicPath: 'auto',     // derive base from the script that loaded the bundle
    uniqueName: 'checkout', // stable, unique per remote — avoids runtime collisions
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'checkout',
      filename: 'remoteEntry.js',
      exposes: { './CheckoutPage': './src/CheckoutPage.tsx' },
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};

With 'auto', webpack injects a runtime helper that reads document.currentScript.src while the entry bundle is being parsed, strips the filename, and uses the resulting directory as the public path. Because remoteEntry.js is served from origin B, the derived base is origin B — and every subsequent chunk request goes to the right place.

Set uniqueName to a stable, unique value per remote. It namespaces the global runtime, which prevents two remotes from clobbering each other’s chunk-loading state.

Step 2 — Know when 'auto' isn’t enough #

'auto' relies on document.currentScript, which is only populated while a classic <script> is executing synchronously. It returns null in several real situations:

When 'auto' resolves to the wrong base (you’ll see chunks fetched from the host), take control explicitly with the magic __webpack_public_path__ variable.

Step 3 — Set __webpack_public_path__ at runtime #

webpack exposes a free variable, __webpack_public_path__, that overrides output.publicPath if assigned before the first dynamic chunk loads. Put the assignment in a tiny module and import it first in your entry.

// src/set-public-path.ts
// Must run before any dynamic import() in this remote.
declare let __webpack_public_path__: string;

function deriveBase(): string {
  // 1. Classic script (works with `output.publicPath: 'auto'` cases too)
  const current = document.currentScript as HTMLScriptElement | null;
  if (current?.src) {
    return new URL('.', current.src).href; // strip filename, keep trailing slash
  }

  // 2. ES module context — import.meta.url points at this module's URL
  if (typeof import.meta !== 'undefined' && import.meta.url) {
    return new URL('.', import.meta.url).href;
  }

  // 3. Last resort: a build-time fallback for SSR / tests
  return '/';
}

__webpack_public_path__ = deriveBase();

Wire it as the very first import of the remote’s entry so the assignment runs before webpack loads any chunk:

// src/bootstrap.ts
import './set-public-path'; // side-effecting; MUST be first
import('./CheckoutPage');   // now chunks resolve against the derived base
// webpack.config.js (remote) — entry indirection
module.exports = {
  entry: './src/index.ts', // index.ts does: import('./bootstrap')
  output: { publicPath: 'auto', uniqueName: 'checkout' },
};

The import('./bootstrap') indirection (an async boundary in index.ts) is the standard federation pattern: it guarantees the shared-scope and public-path setup completes before any federated module evaluates.

Step 4 — Derive the base from the remoteEntry URL #

When the host controls where the remote lives — for example it reads the remoteEntry.js URL from a manifest — pass that URL down so the remote doesn’t have to guess. The host already knows origin B; hand it to the remote via a global the remote reads on boot.

// host: record where each remote was loaded from
window.__MFE_REMOTE_BASES__ = {
  checkout: 'https://checkout.example.com/',
};
// src/set-public-path.ts (remote) — prefer host-provided base
declare let __webpack_public_path__: string;

const fromHost = (window as any).__MFE_REMOTE_BASES__?.checkout;
__webpack_public_path__ =
  fromHost ?? new URL('.', (document.currentScript as HTMLScriptElement)?.src ?? location.href).href;

This is the most robust option for CDN deployments where the remote’s URL carries a content hash that changes on each release — see CDN cache invalidation for federated remotes for how those hashed URLs are minted and busted.

Step 5 — Enable infrastructure logging to verify #

Turn on webpack’s runtime logging so you can confirm which base it actually resolved:

// webpack.config.js (remote)
module.exports = {
  infrastructureLogging: { level: 'verbose' },
  output: { publicPath: 'auto', uniqueName: 'checkout' },
};

You can also print the resolved value at runtime in the browser console:

// anywhere after set-public-path has run
console.log('[checkout] publicPath =', __webpack_public_path__);

Verification #

Open the host page in the browser and confirm the remote’s chunks load from the remote’s origin, not the host’s:

  1. Open DevTools → Network, filter to JS, and trigger the remote (navigate to its route).
  2. Each lazy chunk request (e.g. src_CheckoutPage_tsx.chunk.js) must show Domain = checkout.example.com, not shell.example.com.
  3. There should be no 404s for *.chunk.js.

A lightweight assertion you can run in an integration test:

// e2e/publicPath.spec.ts (Playwright)
import { test, expect } from '@playwright/test';

test('remote chunks load from the remote origin', async ({ page }) => {
  const chunkHosts = new Set<string>();
  page.on('response', (res) => {
    if (/\.chunk\.js$/.test(res.url())) chunkHosts.add(new URL(res.url()).host);
  });

  await page.goto('https://shell.example.com/checkout');
  await page.getByTestId('checkout-loaded').waitFor();

  expect([...chunkHosts]).toContain('checkout.example.com');
  expect([...chunkHosts]).not.toContain('shell.example.com');
});

Troubleshooting #

Symptom: chunks 404 against the host’s origin even with publicPath: 'auto'.

Diagnosis: document.currentScript was null when the runtime ran — usually because the remote loads as an ES module or via a deferred/eval loader. Fix: add the explicit set-public-path.ts from Step 3 and import it first; don’t rely on 'auto' alone in module/loader contexts.

Symptom: chunks load from checkout.example.comsrc_... (missing slash).

Diagnosis: you derived the base by string-slicing the script URL and dropped the trailing slash. Fix: always use new URL('.', scriptSrc).href, which returns the directory with a trailing slash. Never concatenate raw strings.

Symptom: requests are blocked with a CSP error, not a 404.

Diagnosis: the host’s Content-Security-Policy doesn’t allow origin B as a script/connect source. publicPath is correct — the browser is refusing the request. Fix: add origin B to the host’s script-src and connect-src directives.

Symptom: the wrong base is used when the same remote is loaded twice (e.g. nested host).

Diagnosis: two bundles shared one runtime because uniqueName collided. Fix: give every remote a distinct output.uniqueName; this also keeps __webpack_require__.p from being overwritten by the later-loading bundle. Confirm by logging __webpack_public_path__ from each remote.