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.
Import maps and manifests
Section titled “Import maps and manifests”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)”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:
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.
Custom element registration options
Section titled “Custom element registration options”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.
Custom element attributes
Section titled “Custom element attributes”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.
Version coordination
Section titled “Version coordination”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.
Deployment ownership
Section titled “Deployment ownership”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”Good fit
Section titled “Good fit”- 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
Limits and tradeoffs
Section titled “Limits and tradeoffs”- 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)”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):
import { createWidget } from "mountly-react";import { CheckoutCard } from "./CheckoutCard";
export const checkoutCard = createWidget(CheckoutCard);Marketing team (Svelte):
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:
- Each team publishes a browser-loadable ESM bundle.
- The host points mountly at the bundle URL.
- The host can namespace tags with
prefixto avoid collisions. - Version changes: update the source URL.
- No special bundler wiring on the host page.
Hybrid approach
Section titled “Hybrid approach”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.
Size and performance
Section titled “Size and performance”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.
Runtime isolation
Section titled “Runtime isolation”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.
Strict Content-Security-Policy
Section titled “Strict Content-Security-Policy”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.
Framed verticals
Section titled “Framed verticals”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.
Talking across the boundary
Section titled “Talking across the boundary”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.
When a frame never comes up
Section titled “When a frame never comes up”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.
Routing across verticals
Section titled “Routing across verticals”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.
Paying for the second bootstrap
Section titled “Paying for the second bootstrap”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.
iframeFeaturewarms 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.jsbuild 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, immutableApply 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.
Where mountly fits
Section titled “Where mountly fits”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.
Next steps
Section titled “Next steps”- See a working MFE example: Multi-instance mounts
- Learn about custom elements: Custom Elements
- API reference:
defineMountlyFeature()