Skip to content

Microfrontends

Microfrontends (MFE) let teams ship team-owned UI into the same product surface. Each team can build and deploy its feature on its own release schedule, then the host composes those features at runtime.

Teams own their feature end-to-end and coordinate shared concerns such as versioning, styling, and communication contracts.

This solves organizational problems:

  • Ownership and deployment independence: deploy feature changes without waiting for other teams
  • Technology freedom: payments can use React while marketing uses Svelte or Vue
  • Parallel development: fewer merge conflicts across unrelated features
  • Incremental migration: move one region at a time instead of rewriting the whole app

Microfrontends describe an architecture and a delivery model. A team-owned widget or region counts as a microfrontend when the team can build, test, and deploy it without releasing the host. Teams can implement that model with a small runtime or a platform, depending on their release needs.

mountly provides that runtime composition layer with standard ES modules, custom elements, framework adapters, and a small mount / unmount contract. For teams with clear feature boundaries, a CDN and a mountly manifest may provide enough infrastructure. Teams that need coordinated rollouts, runtime version negotiation, or route-to-app traffic management will need those controls elsewhere.

For multi-team hosts, a JSON manifest lists platform dependencies and vertical URLs. Plain HTML hosts call bootstrapMountly(). Vite hosts add mountlyHostPlugin and import remotes directly:

const { Checkout } = await import("payments/Checkout");

A vertical authors its build with mountlyRemote (vite build, no shared block), and a Vite host can declare a remote by published URL, federation-style, with mountlyHostPlugin({ remotes: { checkout: "https://cdn/checkout/" } }) fetching the remote’s fragment to type the import. See Manifest & host shells for schema, validation, the mountlyRemote/remotes reference, SSR, and per-tenant registries.

Option 1: As an npm dependency (in your app)

Section titled “Option 1: As an npm dependency (in your app)”
host-app/src/main.ts
import { createOnDemandFeature } from "mountly/feature";
import { checkoutWidget } from "payments-team";
const container = document.getElementById("checkout");
const checkout = createOnDemandFeature({
moduleId: "checkout-widget",
loadModule: async () => checkoutWidget,
});
checkout.attach({
trigger: container,
mount: container,
activateOn: "viewport",
preloadOn: "viewport",
props: { plan: "pro" },
});

You install a library, nothing else. No custom elements, no special config. If your server already emits data-mountly islands they wire themselves up; if your app owns the DOM node directly, createOnDemandFeature().attach() from mountly/feature is the lower-level API.

Option 2: As custom elements (third-party hosts, CMS, etc.)

Section titled “Option 2: As custom elements (third-party hosts, CMS, etc.)”

Each team builds a widget bundle:

payments-team/src/index.ts
import { createWidget } from "mountly-react";
import { CheckoutCard } from "./CheckoutCard";
export const checkoutCard = createWidget(CheckoutCard);
export default checkoutCard;

The host registers it as a custom element:

<script type="module">
import { defineMountlyFeature } from "mountly/elements";
defineMountlyFeature("https://payments.example.com/checkout/dist/index.js");
</script>
<checkout-card trigger="viewport" props='{"plan":"pro"}'></checkout-card>

Pick Option 1 if the host is your app. Pick Option 2 if the host is unknown, a CMS, or a third party.

defineMountlyFeature() is the MFE-friendly API because it lets the host register one bundle, many bundles, prefixed tags, or explicit aliases.

interface DefineMountlyFeatureOptions {
tagName?: string;
source?: string;
moduleUrl?: string;
modules?: FeatureModuleManifest;
aliases?: boolean | Record<string, string>;
prefix?: string;
scan?: boolean;
auto?: boolean;
baseUrl?: string;
resolveModuleUrl?: (moduleId: string) => string;
}
Option Default What it controls
tagName mountly-feature Wrapper custom element name.
source None One shared ESM bundle URL. Alias tags resolve named exports from this bundle.
moduleUrl None Back-compat alias for source.
modules None Restrict/register known modules. Accepts arrays, [id, url] tuples, [id, options] tuples, or object maps.
aliases true Define alias custom elements automatically, disable them, or provide an alias map such as { 'checkout-card': 'checkout' }.
prefix None Namespace alias tags, e.g. prefix: 'payment' maps <payment-checkout-card> to module ID checkout-card.
scan true Scan the current DOM for <mountly-feature> and alias tags.
auto true Back-compat alias for scan.
baseUrl None With modules: ['checkout-card'], derive /baseUrl/checkout-card/dist/index.js.
resolveModuleUrl None Custom URL resolver for each module ID.

Common registration shapes:

defineMountlyFeature("/widgets/dist/index.js");
defineMountlyFeature({ source: "/widgets/dist/index.js", prefix: "team" });
defineMountlyFeature({ baseUrl: "/widgets", modules: ["checkout-card"] });
defineMountlyFeature({
modules: {
"checkout-card": "/widgets/checkout-card.js",
"invoice-list": {
moduleUrl: "/widgets/invoice-list.js",
moduleExport: "invoiceList",
},
},
});
defineMountlyFeature({
modules: { checkout: "/widgets/checkout.js" },
aliases: { "checkout-card": "checkout" },
});

modules values use the same options as registerFeatureModule(): moduleUrl, moduleExport, assetOptions, loadData, and getCacheKey.

These attributes are available on <mountly-feature> and generated alias tags such as <payment-checkout-card>:

Attribute Default What it controls
module-id Required on <mountly-feature> Registered module ID. Alias tags infer this from the tag name.
trigger click High-level preset: click, hover, focus, viewport, idle, media, or url-change.
preload-on None (opt-in) Prefetch trigger: hover, viewport, idle, or media. Never implied by trigger.
activate-on trigger Activation trigger when it differs from trigger.
preload-media-query None Media query for preload-on="media".
activate-media-query None Media query for activate-on="media" or trigger="media".
idle-timeout Browser/default trigger behavior Timeout used for idle preload or activation.
viewport-root-margin 0px Root margin passed to viewport triggers.
data-url None Adds dataUrl to feature context for factories that implement loadData.
data-method GET Adds dataMethod to feature context.
props {} JSON props passed to the widget. Updating this attribute calls feature.update().
mount-selector Internal mount div Child selector used as the mount target. Must match a descendant of the element.

trigger="url-change" covers popstate, hashchange, pushState and replaceState — there is no attribute to narrow that set. See the custom element reference for how each preset maps onto the core’s data-on.

Teams that share React (or any peer dependency) must agree on versions upfront. The host import map pins one version per shared dep. Verticals built with dist/peer.js externalize those deps and load whatever the host pins.

<script type="importmap">
{ "imports": { "react": "https://esm.sh/react@19.2.7" } }
</script>

Self-contained widgets that bundle their own React do not need import-map agreement. Peer builds do.

Run mountly manifest validate before deploy. It catches version skew across React import-map entries, duplicate vertical ids, and ambiguous export config.

Two React copies in the same component tree break Context, hooks, and refs. That usually means the host bundled React and a vertical shipped a self-contained dist/index.js that also bundles React. Use dist/peer.js on React hosts and let the import map provide one React.

A remote bundle creates a separate build artifact. Your release process creates deployment independence. If the host team must edit and release its repository for each bundle URL change, both teams still share a release step.

Give the widget team control of its manifest or registry entry, or provide a promotion workflow that updates the resolved URL without a host release. Keep review and rollback policy in that workflow.

When to use mountly for MFE-style composition

Section titled “When to use mountly for MFE-style composition”
  • Team-owned UI regions with separate release schedules
  • Mixed frameworks where the host should not care what built each widget
  • Independent features that can communicate through props, events, URL state, or a small shared store
  • Existing hosts such as CMS pages, server templates, Rails/Django apps, static pages, or legacy shells
  • Strangler-fig migrations where one framework replaces another region by region
  • Marketing and community surfaces where teams need to drop controlled interactive cards into otherwise static pages
  • Every region shares live app state. When your UI couples through one Router context, one Query client, or one Redux store, a single app shell fits better than coordinating independent regions.
  • Your releases require central orchestration. mountly validates manifests and loads verticals. Your platform must handle traffic routing, coordinated rollouts, and runtime version policy.

Self-contained bundles trade bytes for less version coordination:

  • Regions stay independent and you pick bundle URLs explicitly.
  • You accept upfront version agreement via the host import map.

Real-world example: Payment + Marketing teams

Section titled “Real-world example: Payment + Marketing teams”

Scenario A: Host is your own app (use as npm dependency)

Section titled “Scenario A: Host is your own app (use as npm dependency)”
host-app/src/main.ts
import { createOnDemandFeature } from "mountly/feature";
import { checkoutCard } from "@payments/widgets";
import { heroCard } from "@marketing/widgets";
const checkoutContainer = document.getElementById("checkout");
const heroContainer = document.getElementById("hero");
const checkout = createOnDemandFeature({
moduleId: "checkout-widget",
loadModule: async () => checkoutCard,
});
const hero = createOnDemandFeature({
moduleId: "hero-widget",
loadModule: async () => heroCard,
});
checkout.attach({
trigger: checkoutContainer,
mount: checkoutContainer,
activateOn: "viewport",
preloadOn: "viewport",
});
hero.attach({
trigger: heroContainer,
mount: heroContainer,
activateOn: "idle",
preloadOn: "idle",
});

Import your widget module and attach it like any other library.

Scenario B: Host is vanilla HTML, CMS, or third-party (use custom elements)

Section titled “Scenario B: Host is vanilla HTML, CMS, or third-party (use custom elements)”

Payment team (React):

payment-team/src/index.ts
import { createWidget } from "mountly-react";
import { CheckoutCard } from "./CheckoutCard";
export const checkoutCard = createWidget(CheckoutCard);

Marketing team (Svelte):

marketing-team/src/index.ts
import { createWidget } from "mountly-svelte";
import HeroSection from "./HeroSection.svelte";
export const heroCard = createWidget(HeroSection);

Host (vanilla HTML):

<script type="module">
import { defineMountlyFeature } from "mountly/elements";
defineMountlyFeature({
source: "https://payments.example.com/checkout/dist/index.js",
prefix: "payment",
});
defineMountlyFeature({
source: "https://marketing.example.com/widgets/dist/index.js",
prefix: "marketing",
});
</script>
<payment-checkout-card trigger="viewport" props='{"customerId":"cus_123"}'></payment-checkout-card>
<marketing-hero-card trigger="idle" props='{"campaign":"spring"}'></marketing-hero-card>

Why custom elements work well for unknown hosts:

  1. Each team publishes a browser-loadable ESM bundle.
  2. The host points mountly at the bundle URL.
  3. The host can namespace tags with prefix to avoid collisions.
  4. Version changes: update the source URL.
  5. No special bundler wiring on the host page.

mountly composes with other architectures:

  • Use Astro for a content site and mountly for portable widgets that must also run outside Astro.
  • Use Next.js, Remix, Rails, or Django for routing/SSR and mountly for isolated client regions.
  • Use plain HTML or CMS templates when there is no app shell at all.

mountly does not replace your framework. It gives you a portable boundary for activating components wherever the host allows JavaScript.

A mountly widget can be built as:

  • Self-contained: includes its framework runtime; easiest for unknown hosts, largest bundle
  • Peer/runtime-provided: externalizes framework runtime; smaller bundle, requires host/import map coordination
  • SSR fallback only: ships no client JS until or unless a trigger activates it

mountly adds custom element registration, trigger setup, module loading, optional data loading, and adapter mount. For most independent widgets, the framework or component bundle you load still dominates the cost.

Import-map composition loads every vertical into one JavaScript context. That is what makes it fast — no second bootstrap, one copy of React — and it is also the whole of its isolation story. A vertical that mutates window, registers a global listener or patches a prototype affects every other vertical on the page. shadow: true scopes styles, but the JS context is still shared.

For most products that is the right trade. It stops being the right trade when you cannot vouch for what a vertical does internally — a decade-old codebase, an acquired team, a third-party embed. At that point the guarantee you want is one the browser enforces, not one your conventions do.

Bootstrap cost Style isolation JS isolation
moduleUrl Shared None None
moduleUrl + shadow Shared Strong None
iframeFeature Second bootstrap Strong Strong

This is a per-vertical choice, not an architecture-wide one. The three untrusted verticals can be framed while the other twenty stay on import maps — same triggers, same adapters, same lifecycle.

Loading a module whose URL is only known at runtime is normally done with new Function("s", "return import(s)"), which keeps bundlers quiet but is eval — and a page whose CSP omits 'unsafe-eval' cannot run it. That is the default posture in regulated environments and the CSP the MCP Apps spec requires hosts to enforce.

mountly tries that path and falls back to a plain dynamic import when the CSP forbids it, so on-demand loading works either way. Nothing to configure.

The fallback is a dynamic import a bundler cannot statically resolve, so Vite may print “The above dynamic import cannot be analyzed” once when it processes mountly. That warning is the cost of the feature working under a strict CSP; it is expected and safe to ignore.

iframeFeature gives a vertical its own document, and therefore its own window, globals and styles:

import { iframeFeature } from "mountly/iframe";
const billing = iframeFeature({
moduleId: "billing",
src: "https://billing.acme.com/widget",
title: "Billing breakdown",
sandbox: "allow-scripts",
});

The framed page mounts the same widget build with mountAsFrame, so authors write one component regardless of how the host decides to isolate it.

Isolation cuts both ways: a framed vertical cannot reach the host either. Props flow host → frame automatically; anything flowing back goes over a typed, versioned channel rather than raw postMessage.

interface BillingEvents {
invoicePaid: { invoiceId: string };
currencyChanged: { code: string };
}
const billing = iframeFeature<BillingEvents>({
moduleId: "billing",
src: "https://billing.acme.com/widget",
title: "Billing breakdown",
channel: {
version: 1,
validators: {
invoicePaid: (p): p is BillingEvents["invoicePaid"] =>
typeof (p as { invoiceId?: unknown })?.invoiceId === "string",
},
connect: (channel) => {
channel.on("invoicePaid", ({ invoiceId }) => refreshLedger(invoiceId));
channel.emit("currencyChanged", { code: "GBP" });
return () => console.log("frame gone");
},
},
});

Inside the frame:

import { mountAsFrame } from "mountly/iframe/child";
mountAsFrame<BillingEvents>(widget, {
channel: {
version: 1,
connect: (channel) => {
channel.on("currencyChanged", ({ code }) => setCurrency(code));
onPaid((invoiceId) => channel.emit("invoicePaid", { invoiceId }));
},
},
});

Three properties matter here, and each exists for a reason:

  • Explicit. Only what you name crosses. A same-context bus bridged into the frame would carry everything sharing its namespace — including state you framed the vertical to keep away from it.
  • Validated on receive, not just on send. A frame you do not trust is a frame whose payloads you do not trust.
  • Versioned. An event stamped newer than the receiver understands is dropped with a warning rather than delivered. Deploy a vertical that emits a new event and the old shell keeps running.

Authenticity is handled below this layer: the transport matches event.source against the exact contentWindow, which the browser sets and a page cannot forge, and outbound messages are pinned to the frame’s origin.

A cross-origin frame cannot be inspected from the host, so a page that 404s, gets blocked by CSP, throws before mountAsFrame, or simply forgets to load resize-iframe-child.js all look identical from outside: silent. Without a deadline the outlet stays blank forever and nothing is reported.

const billing = iframeFeature({
moduleId: "billing",
src: "https://billing.acme.com/widget",
title: "Billing breakdown",
readyTimeout: 10_000, // default; 0 disables
onError: (error) => showFallback(error),
});

Without onError the failure goes to console.error rather than nowhere. The timer is cleared on handshake and on unmount, so a frame that is torn down mid-load — a route change, say — does not report a failure afterwards.

The URL should still describe the whole application even though segments of it are owned by different teams. The host maps segments to features; each feature routes internally below its own segment.

import { createFeatureRouter } from "mountly/router";
const router = createFeatureRouter({
container: document.querySelector("#outlet")!,
routes: [
{ path: "/billing/*", feature: billing },
{ path: "/settings/*", feature: settings },
],
fallback: home,
});
router.start();

Navigating from /billing/1 to /billing/2 updates the mounted feature rather than remounting it — for a framed vertical a remount is a second full bootstrap, which is the cost this architecture is already trying not to pay twice. Crossing into /settings/* unmounts one feature and mounts the other.

A framed vertical never touches the host’s history. Prefer the standard protocol helpers from mountly/iframe:

import { bindFrameHistoryToRouter } from "mountly/iframe";
iframeFeature({
moduleId: "billing",
src: "https://billing.acme.com/widget",
title: "Billing",
channel: {
connect: (channel) => bindFrameHistoryToRouter(channel, router),
},
});

From the frame, requestHostNavigation(channel, { url: "/billing/2" }). The host stays the only writer, so a vertical cannot navigate the shell somewhere the shell did not agree to.

For modals and full-viewport UI that would clip inside the iframe, use host overlay breakout. To flip a vertical behind a frame via the manifest, see When to frame.

A framed vertical downloads and evaluates its own copy of everything. Browsers partition the HTTP cache, so one frame cannot borrow bytes another already fetched — the wins available are early bytes and repeat-visit bytes.

  • Prefetch on intent. iframeFeature warms the document on hover, viewport or idle, so mount pays only the frame’s own bootstrap. The bytes can be early; the JS context cannot be shared.

  • Externalise the framework. A dist/peer.js build leaves React to the host’s import map at a pinned immutable URL. This beats a vendor chunk: one copy serves every vertical that maps the same specifier, instead of each bundle caching its own.

  • Serve hashed assets as immutable. Content-hashed filenames only pay off with headers to match, or the browser revalidates every one on every visit:

    Cache-Control: public, max-age=31536000, immutable

    Apply it to hashed files only. The manifest and any unhashed entry HTML need no-cache, or a deploy never reaches anyone.

Framework code changes rarely and product code changes daily; keeping them in separate, separately-cached files means a deploy invalidates only what actually changed.

Good fit:

  • Teams that want manifest-driven composition with plain HTML or Vite hosts
  • Mixed-framework widgets where the host should not care about implementation details
  • Third-party or unknown hosts (CMS, static sites, legacy apps)
  • Marketing and community surfaces with independent interactive regions
  • Progressive migration from one framework to another
  • Islands-style activation outside a dedicated islands framework

mountly buys you portable mounting and explicit composition, not automatic dependency governance. Can each region own its data fetching, state, and lifecycle? If yes, mountly fits. If every region tangles into one app graph, use one app shell instead.


Start with the organization, not the technology. Count your teams, how independently they operate, and how much coordination you can tolerate. Your architecture follows from those answers.