Microfrontends
Microfrontends (MFE) let different teams ship independently owned UI into the same product surface. Each team can build and deploy its feature in isolation, then the host composes those features at runtime.
Microfrontends give teams ownership rather than full autonomy. Teams own their feature end-to-end, but still coordinate on shared concerns like versioning, styling, and communication patterns.
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
MFE platforms also add complexity. mountly offers a smaller path for many cases: standard ES modules, custom elements, and framework adapters.
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";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-island payloads, use mountIslandFeature() instead; if your app owns the DOM node directly, createOnDemandFeature().attach() 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"; 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 |
Mapped from trigger |
Explicit preload trigger: hover, viewport, idle, media, false, or none. |
activate-on |
Mapped from trigger |
Explicit activation trigger: click, hover, focus, viewport, idle, media, or url-change. |
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. |
url-events |
All URL events | Comma-separated URL events: popstate, hashchange, pushstate, replacestate. |
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 trigger and mount target. |
The trigger preset maps to lower-level attach() options:
trigger |
preloadOn |
activateOn |
|---|---|---|
click |
false |
click |
hover |
hover |
hover |
focus |
false |
focus |
viewport |
viewport |
viewport |
idle |
idle |
idle |
media |
false |
media |
url-change |
false |
url-change |
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.
When to use mountly for MFE-style composition
Section titled “When to use mountly for MFE-style composition”Good fit
Section titled “Good fit”- Small to medium orgs with a few independently owned UI regions
- 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
Poor fit
Section titled “Poor fit”mountly is not the right tool if:
- 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.
- You need a central deploy control plane. mountly validates manifests and loads verticals. It does not route traffic, coordinate releases, or resolve semver at runtime.
mountly trades bytes for simplicity when:
- 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";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";
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.
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()