Running Remotes Locally Against a Deployed Host #

Running a host plus four remotes to change one component is the reason developers stop testing integration locally — this page lets a deployed host resolve a single remote from localhost, so the daily loop is one process.

The mechanism is a remote-resolution override the host honours in non-production builds, plus the CORS and public-path details that make a local remote loadable from a deployed origin. It implements the second local mode described in testing and local development workflows.

Two ways to develop against a federated application Overriding one remote's origin gives a developer real integration against everything else without running any of it locally. Full local composition Host plus four remote dev servers Five terminals, high memory use Onboarding costs a day Everyone eventually runs one remote alone One local remote, deployed host One dev server on your machine Staging supplies everything else Real routing, real shared scope Setup is a single URL
The second mode covers the overwhelming majority of daily work, which is why it is worth building first.

Prerequisites #

Step 1 — Add an override to the host’s resolver #

The override reads from the URL, so no host rebuild or redeploy is needed to use it.

// host/src/resolve-remotes.js
export function resolveRemotes(manifest) {
  // Hard gate: overrides must be impossible in a production build.
  if (import.meta.env.MODE === 'production') return manifest;

  const params = new URLSearchParams(location.search);
  const resolved = { ...manifest };

  for (const override of params.getAll('remote')) {
    const [name, ...rest] = override.split('@');
    const url = rest.join('@');
    if (!resolved[name] || !url) continue;

    // Only localhost and 127.0.0.1 — never an arbitrary origin.
    const parsed = new URL(url);
    if (!/^(localhost|127\.0\.0\.1)$/.test(parsed.hostname)) continue;

    resolved[name] = { ...resolved[name], entry: url, overridden: true };
  }
  return resolved;
}

Both guards are load-bearing. The production check stops the parameter existing at all in the build users receive; the hostname allow-list stops a staging link from loading code off an attacker’s server. Without them this feature is a remote-code-execution vector reachable from a URL, and it undoes the controls in securing federated remotes.

Step 2 — Persist the override so it survives navigation #

A query parameter disappears on the first in-app navigation. Store it once and keep applying it.

// host/src/override-store.js — session-scoped, cleared by closing the tab
const KEY = 'mf:remote-overrides';

export function captureOverrides() {
  if (import.meta.env.MODE === 'production') return {};
  const params = new URLSearchParams(location.search);
  const incoming = params.getAll('remote');

  if (params.has('remote-clear')) { sessionStorage.removeItem(KEY); return {}; }
  if (incoming.length) sessionStorage.setItem(KEY, JSON.stringify(incoming));

  return JSON.parse(sessionStorage.getItem(KEY) ?? '[]');
}

sessionStorage rather than localStorage is the right scope: an override that persists for weeks eventually confuses someone who has forgotten it exists. Provide the explicit ?remote-clear escape hatch anyway, because someone always will.

Step 3 — Serve the local remote with permissive CORS #

How the override changes remote resolution Only the overridden remote is fetched from the developer's machine; every other remote continues to resolve from the deployed environment. Browser Deployed host localhost:3001 Staging CDN 1. open with ?remote=cart@localhost 2. resolve manifest + override 3. fetch remoteEntry.js 4. fetch every other remote 5. local cart renders in staging
Because the share scope is the deployed host's, this exercises the real version negotiation rather than a local approximation.

The deployed host fetches your remote cross-origin, which requires the dev server to allow it.

// remote/webpack.dev.js
module.exports = {
  devServer: {
    port: 3001,
    headers: {
      // Development only. Never ship this on a production origin.
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, OPTIONS',
    },
    // Required for the host to reach a dev server bound to your machine.
    allowedHosts: 'all',
    hot: true,
  },
  output: {
    publicPath: 'auto',
  },
};

publicPath: 'auto' is what makes the remote’s chunks resolve against localhost:3001 rather than the deployed host’s origin. Without it the entry loads and every subsequent chunk 404s against the wrong domain — the failure described in automatic publicPath configuration for remotes.

For Vite, the equivalents are server.cors: true and server.origin set to the dev server’s own URL so asset URLs are absolute.

Step 4 — Deal with mixed content #

A deployed host on HTTPS cannot load a script from http://localhost in most browsers. Three options, in order of preference.

Serve the dev server over HTTPS with a locally-trusted certificate. mkcert makes this a two-command setup and it is the only option with no ongoing friction.

mkcert -install
mkcert localhost 127.0.0.1
// remote/webpack.dev.js
devServer: {
  server: { type: 'https', options: { key: './localhost-key.pem', cert: './localhost.pem' } },
  port: 3001,
}

Alternatively, use a tunnel that gives your dev server a public HTTPS URL — at the cost of routing your work through a third party, which may not be acceptable. Or run the host locally too, which defeats the purpose of the whole exercise.

Note that Chrome treats http://localhost as a secure context for many purposes but still blocks it as active mixed content on an HTTPS page, so the certificate route is usually the one that works.

Step 5 — Make the override obvious in the running app #

An override that is invisible produces a memorable class of confusion: a developer investigating a bug against what they believe is staging, while one remote is actually their half-finished local branch.

// host/src/OverrideBanner.jsx
export function OverrideBanner({ resolved }) {
  const overridden = Object.entries(resolved).filter(([, s]) => s.overridden);
  if (!overridden.length) return null;

  return (
    <div role="status" className="override-banner">
      Local remotes: {overridden.map(([name]) => name).join(', ')}
      {' — '}
      <a href="?remote-clear=1">reset</a>
    </div>
  );
}

Make it visually loud. This is one of the few cases where an obtrusive banner is exactly right.

Step 6 — Align shared dependency versions #

The three reasons an override fails Nearly every failure of this workflow is one of three things, and each has a distinct console symptom that identifies it immediately. The local remote will not load into the deployed host. Why? Entry loads, chunks 404 Fix publicPath Chunks resolve to the host origin Set publicPath to auto Blocked as mixed content Serve dev over HTTPS HTTPS page, HTTP script Use a trusted local certificate Unsatisfied version warning Align shared versions Host registered first Relax strictVersion in dev only
All three are configuration rather than code, which is why they are worth documenting in the remote's README.

The deployed host has already registered its shared dependencies into the share scope by the time your remote loads. If your local remote requires a range the host’s version does not satisfy, the negotiation fails.

// remote/webpack.dev.js — accept whatever staging is running
shared: {
  react: {
    singleton: true,
    requiredVersion: deps.react,
    // Development only: a mismatch degrades to a warning instead of failing.
    strictVersion: false,
  },
},

Relaxing strictVersion in development is a deliberate, narrow exception. It lets you work against a staging host that is one minor version ahead without blocking on an upgrade, and it must not carry into the production configuration where a silent second React copy is a real bug.

If the versions differ by a major, relaxing the check will not help — you will get two copies and broken hooks. The fix there is to align the versions, which is a genuine finding rather than a local-development inconvenience.

Step 7 — Wire it into a single command #

The workflow should be one command, not a checklist.

{
  "scripts": {
    "dev:against-staging": "concurrently \"npm run dev\" \"npm run open:staging\"",
    "open:staging": "wait-on https://localhost:3001/remoteEntry.js && open \"https://staging.example.com/?remote=cart@https://localhost:3001/remoteEntry.js\""
  }
}

wait-on matters: opening the host before the dev server is ready produces a failed load and a confusing first impression. Waiting for the entry to be served makes the command reliable enough that people use it.

Document the URL shape in the remote’s README as well. Developers will want to construct it by hand for a specific route, and a memorable pattern is worth more than a script they have to read.

Verification #

Troubleshooting #

Symptom: the entry loads, the remote renders nothing, chunks 404.

Diagnosis: publicPath is not 'auto', so chunk URLs resolve against the host origin. Fix: set output.publicPath: 'auto' in the remote’s dev configuration.

Symptom: the browser blocks the request as mixed content.

Diagnosis: an HTTPS host cannot load an HTTP script. Fix: serve the dev server over HTTPS with a locally-trusted certificate, as in step 4.

Symptom: Unsatisfied version in the console and the remote falls back to its own React.

Diagnosis: the local remote requires a range the deployed host’s registered version does not satisfy. Fix: relax strictVersion in development, and if the gap is a major version, align them properly.

Symptom: the override stops working after clicking a link.

Diagnosis: the query parameter was dropped by client-side navigation. Fix: capture it into sessionStorage on first load, as in step 2.

Symptom: CORS error fetching the entry.

Diagnosis: the dev server does not send Access-Control-Allow-Origin. Fix: add the development-only headers from step 3, and confirm no proxy in front of the dev server strips them.

What this mode is good at, and what it hides #

The override gives you a genuinely real integration environment: real routing, the real share scope, real neighbouring remotes at their current versions. That is far more faithful than any local composition, because a local composition runs whatever versions happen to be checked out on your machine rather than what is actually deployed.

It also hides three things worth knowing about. Your local remote is unminified and unbundled for production, so it behaves differently under the profiler and its size tells you nothing — bundle work still needs a production build and the checks in setting per-remote JavaScript size budgets in CI. Your dev server has no CDN in front of it, so caching and cache-invalidation behaviour cannot be observed here at all. And the deployed host is running whatever landed on staging most recently, which means a colleague’s deploy can change your environment mid-session.

That last point is the one that causes confusion rather than bugs. When something inexplicable happens, check what staging is running before assuming your change caused it — the override banner tells you which remote is yours, and everything not listed there belongs to someone else’s most recent merge.