◆ Architecture and conventions
Moving parts
The platform is composed of five independent pieces that each do one thing:
| Package |
What it does |
@fe/cli |
The fe binary. Scaffold, build, dev, publish, serve. |
@fe/compiler |
Houses compileMfe and createJITBundler. Used by fe serve. |
@fe/runtime |
Browser-side loader. Reads config, resolves deps, injects import maps, mounts MFEs. |
@fe/core |
Shared TypeScript types and interfaces. No runtime code. |
platform.json |
Authoritative registry of routes and package versions. Read by both runtime and server. |
The fe() convention
fe(scope/name) is a plain string used as a package name everywhere: in
package.json, in import statements, and in the platform manifest. It is
a bare specifier that the browser resolves through injected import maps. There is no
special URL scheme.
// package.json
{ "name": "fe(acme/my-mfe)" }
// source
import { store } from "fe(acme/store)";
// platform.json
"fe(acme/store)": { "versions": { "1.0.0": { "url": "..." } } }
During build, fe(*) entries in devDependencies are treated as
externals. At runtime, the browser resolves each specifier to a URL via the injected
import map. The symlinks created by bun install satisfy TypeScript without
any tsconfig.paths configuration.
MFE exports
A MFE is a plain ES module and the platform is foundationally
TypeScript-based. It can export functions, stores, API clients, or
anything else that makes sense for its purpose. The platform treats publish,
versioning, and import-map resolution the same regardless of what the module exports.
The one special case is a MFE registered under routes in
platform.json. When the runtime matches a URL to that entry, it imports
the module and calls render() to mount the UI:
export function render(
container: HTMLElement,
props: Record<string, unknown>
): () => void {
// mount into container
// return a cleanup function that removes your DOM nodes
}
The contract is intentionally minimal: no framework lifecycle, no router coupling,
no shared state requirement. The cleanup function is called on navigation away.
React, Solid.js, and vanilla DOM all satisfy it without adaptation.
Deployment model
MFEs are deployed as raw TypeScript source, not as bundles. fe publish
uploads source files and registers a new version in platform.json. The
JIT server compiles source on first request and responds with
Cache-Control: immutable headers.
platform.json
new version registered
JIT server
compile on first request
browser
import map + render()
Artifact URLs include the version, so the compiled output is stable forever. A new
publish creates a new URL; old versions remain available at their original addresses.
Rollback is a one-line edit to platform.json.
Local development
fe dev <target> starts a dev server for one MFE with SSE-based hot module
reload. The devtools overlay (if installed) lets you redirect any
fe(scope/name) specifier to a local dev URL via sessionStorage. This
override affects only the current browser tab and can be shared as a URL parameter.
fe dev mfe-a # start dev server at localhost:3001
# in devtools panel: override fe(acme/mfe-a) → http://localhost:3001/
fe check <target> runs typecheck and simulates a build without writing
output. Use it in CI to catch errors early without running the full JIT pipeline.
Cross-MFE dependencies
When a MFE depends on another, declare it in devDependencies:
// mfe-b/package.json
{
"name": "fe(acme/mfe-b)",
"devDependencies": { "fe(acme/mfe-a)": "^1.0.0" }
}
The runtime reads the dep graph from platform.json and resolves the
highest compatible version using semver. All resolved specifiers are injected into
a single <script type="importmap"> before the MFE is imported.
Subsequent navigations skip specifiers already injected, so shared deps load once.
Extension points
Two plugin systems let organisations extend the platform without forking it:
-
CLI plugins — swap adapters at bootstrap time. Replace artifact
storage with S3, replace config reading with an environment-variable provider,
or add new CLI commands. Declare in
fe.config.json under
"plugins".
-
JIT plugins — transform
BuildOptions before each
compilation. @fe/jit-plugin-react and
@fe/jit-plugin-solid ship out of the box. Declare in
fe.config.json under "jitPlugins".
CLI quick reference
fe new <scope/name> scaffold a new MFE project
fe dev <target> live-reload dev server
fe check <target> typecheck + build simulation (CI)
fe publish <target> upload source + register version
fe link <mfe> <dep> wire a local fe() dependency
fe build shell compile the host shell
fe serve run the JIT server
◆ For product engineers
What you own independently
As a product engineer working on a MFE, you own the full lifecycle of your
feature from scaffolding through deployment. The platform team operates the
infrastructure; you work entirely from the command line without touching shared
CI/CD pipelines or coordination tickets.
fe new acme/my-feature — scaffold a new MFE in one command.
fe dev my-feature — hot-reload dev server, live in your browser.
fe check my-feature — typecheck before pushing; same check runs in CI.
fe publish my-feature — upload source and register the new version.
fe link my-feature toolkit/store — wire up a local toolkit dependency.
What your platform team provides
You do not need to understand how the platform works to ship features. Your platform
team provides:
| What | How you use it |
| Shell app | The host HTML page that loads your MFE. You do not build or deploy it. |
| JIT server URL | The base URL where compiled bundles are served. Embedded in the shell. |
platform.json | Routes and package registry. Your team or CI updates a route entry after publishing. |
| Toolkit packages | fe(acme/store), fe(acme/network) — resolved at runtime, no install step. |
Day-to-day workflow
A typical feature cycle looks like this:
- Scaffold once:
fe new acme/my-feature creates my-feature/ with a render() stub.
- Develop:
fe dev my-feature then open the shell with a sessionStorage override pointing at your dev URL.
- Type-check in CI:
fe check my-feature runs on every push.
- Publish when ready:
fe publish my-feature uploads source and prints the new version.
- Update the route in
platform.json from fe(acme/my-feature)@old to @new.
Steps 4 and 5 are independent of every other team. No build pipeline coordination.
No monorepo deployment lock. Old versions stay live at their original URLs until
the route is changed.
Testing against the production shell
The devtools overlay (accessible via the platform team) lets you redirect any
fe(scope/name) specifier to a local fe dev URL. Overrides
live in sessionStorage and affect only your current tab. No redeployment,
no asking anyone for access.
// manual sessionStorage override (without devtools)
sessionStorage.setItem(
"fe:override:fe(acme/my-feature)",
"http://localhost:3001/"
);
// then reload — the production shell loads your local build
The devtools share button encodes active overrides as URL parameters. Send the link
to a colleague and they open the same environment in their browser.
What your MFE exports
A MFE is a plain ES module and can export whatever its purpose calls for: functions,
a store instance, an API client, a collection of utilities. No particular shape is
required by the platform.
The exception is when your MFE is listed under routes in
platform.json. In that case the runtime calls render()
on navigation to mount your UI, so you must export it:
export function render(
container: HTMLElement,
props: Record<string, unknown>
): () => void {
// mount your UI into container
// return cleanup: remove your DOM, stop timers, unsubscribe listeners
}
The runtime calls render on mount and the returned function on unmount.
Any framework satisfies this contract without adaptation.
Using toolkit packages
Toolkit packages are resolved at runtime through import maps. Declare them in
devDependencies:
// package.json
{
"name": "fe(acme/my-feature)",
"devDependencies": {
"fe(acme/store)": "^1.0.0",
"fe(acme/network)": "^1.0.0"
}
}
// src/index.ts
import { createStore } from "fe(acme/store)";
const count = createStore("my-feature:count", 0);
export function render(container, props) {
// ...
return () => {};
}
◆ POC
◆ Cloud
◆ Enterprise
◆ Proof of concept
The fastest path to a running platform: two MFEs, one shell, everything local.
No CDN, no CI/CD, no cloud setup required. This walkthrough takes you from a
fresh workspace to a working browser demo.
1 · Install and scaffold
bun add -d @fe/cli
fe new acme/mfe-a
fe new acme/mfe-b
Each fe new creates a directory with package.json,
tsconfig.json, and a src/index.ts with a
render() stub. Give mfe-b a dependency on
mfe-a:
fe link mfe-b mfe-a
2 · Write a minimal shell
Create host-app/index.html with the platform config embedded and
host-app/src/index.ts that loads the runtime:
<!-- host-app/index.html -->
<script type="application/json" id="platform-config">
{ "routes": {}, "packages": {} }
</script>
<script type="module" src="./src/index.ts"></script>
// host-app/src/index.ts
import { load, loadDevtools } from "@fe/runtime";
await loadDevtools();
await load(location.pathname);
3 · Configure
Create configs/fe.config.json at the workspace root:
{
"shellDir": "host-app",
"manifestPath": "configs/platform.json",
"jitPlugins": ["@fe/jit-plugin-react"]
}
Create configs/platform.json with empty routes and packages:
{
"routes": {},
"packages": {}
}
4 · Publish both MFEs
fe publish mfe-a
fe publish mfe-b
Each command uploads source to sources/ and adds an entry to
platform.json. You will see version numbers printed to stdout.
5 · Add a route and serve
Edit configs/platform.json to map a route:
{
"routes": { "/": "fe(acme/mfe-b)@1.0.0" },
"packages": { ... }
}
fe build shell
fe serve
Open http://localhost:3000. The runtime resolves the route, injects
import maps for both mfe-b and its dep mfe-a, and
calls render(). The JIT server compiles each MFE on first request
and caches the result.
What CI/CD automates from here
The manual steps above map directly to CI jobs: run fe check on
every push, run fe publish on merge to main, and update the route
in platform.json via a CD step. Each team does this independently
for their own MFE.
◆ Cloud deployment
CDN and caching
JIT-compiled artifacts are served with Cache-Control: immutable
because the URL includes the version. Put a CDN in front of fe serve
with the following origin configuration:
- Forward all
/bundle/ requests to the JIT server origin.
- Cache responses that include
Cache-Control: immutable at the edge.
- The first request per version compiles; every subsequent request serves the CDN copy.
Content Security Policy
The minimum required CSP for a fe-based shell:
Content-Security-Policy:
script-src 'self' https://cdn.your-domain.com;
script-src-elem 'self' https://cdn.your-domain.com;
Import maps require no special directives beyond script-src. If
any MFE uses WebAssembly, add 'wasm-unsafe-eval' to
script-src.
Config management
Two config files serve different audiences:
| File | Who reads it | What it controls |
platform.json |
Runtime (browser) + JIT server |
Routes, package versions, artifact URLs |
fe.config.json |
CLI + JIT server bootstrap |
Plugin list, storage paths, shell dir |
In production, platform.json is often hosted on its own CDN URL
so the shell can fetch the latest version without a redeploy. The runtime
falls back to the embedded inline config if the fetch fails.
The JIT server
fe serve runs a stateless HTTP server. Every request is
independent. Scale horizontally behind a load balancer with no session
affinity required. The current implementation is Bun-native; a Rust or Go
implementation is a future possibility when Bun is not available in the
target environment.
Rolling back and promoting
Because artifact URLs are versioned and immutable, rollback is a config
change, not a deploy:
// platform.json: change this
"routes": { "/": "fe(acme/my-feature)@2.0.0" }
// to this
"routes": { "/": "fe(acme/my-feature)@1.0.0" }
No redeployment of fe serve is required. The JIT server
already has the compiled artifact for v1.0.0 in source storage.
◆ Enterprise and ecosystem
Toolkit packages shipped
Three reusable packages ship with the platform and are available to all MFEs
as devDependencies:
| Package | What it provides |
fe(acme/store) |
Framework-agnostic cross-MFE state. Pub/sub store with typed keys. Zero dependencies. |
fe(acme/network) |
Shared fetch with request deduplication, response caching, and interceptor hooks. |
fe(acme/devtools) |
Developer overlay for managing import map overrides. Solid.js, bundled. |
The composable toolkit pattern
Toolkit packages follow the fe() convention and are deployed like
any other MFE. MFEs declare them in devDependencies; the runtime
resolves them through import maps. Likely future additions to the toolkit:
- Framework glue packages (
react-glue, solid-glue) wrapping store and network into framework-native hooks.
- An auth primitive providing shared identity context across MFEs.
- A feature-flag primitive backed by
fe(acme/store).
- i18n, telemetry, service worker, and testing utilities.
Building your own toolkit package
A toolkit package is a standard MFE that exports utilities instead of a
render function. Create it with fe new and give it
a meaningful scope:
fe new acme/i18n
For packages that adapt a specific framework, use dynamic imports to defer the
framework load until the function is called:
// toolkit/react-glue/src/index.ts
export async function useStore<T>(key: string, init: T) {
const { useState, useEffect } = await import("react");
// build and return the React hook
}
This keeps the framework module out of the initial parse cost. The glue is
loaded lazily by the MFE that calls it, not at import-map injection time.
Marketplace potential
The current manifest is a hand-maintained JSON file. A natural evolution is a
central registry where fe publish pushes to a versioned store
(analogous to JSR or npm) and the runtime fetches the manifest from a
well-known URL. Publishing to a shared registry opens the door to discovery:
an organisation's MFE catalogue becomes queryable, and dependency graphs
become visible across teams.
Plugin management
Add CLI plugins to configs/fe.config.json:
{
"plugins": ["@acme/fe-plugin-s3"],
"jitPlugins": ["@acme/fe-jit-plugin-vue"]
}
Plugins that declare updatePolicy.onOutdated = "block" abort CLI
commands until the plugin is updated. Use "warn" to allow commands
to proceed with a notice. Check each plugin's changelog for when updates are
required.
◆ Tutorials
Each tutorial builds on the previous one. Start with hello world to verify
your setup, then progress to multi-team composition and toolkit integration.
1 · Hello world: one MFE, one shell
The shortest path to a running platform. By the end you will have one MFE
mounting in a shell via fe serve.
bun add -d @fe/cli @fe/jit-plugin-react
# scaffold the MFE
fe new acme/hello
# scaffold the shell (or use sandbox/host-app as a template)
# edit configs/fe.config.json and configs/platform.json
fe publish hello
# add route "/" : "fe(acme/hello)@1.0.0" to platform.json
fe build shell
fe serve
Open http://localhost:3000. You should see your render()
function mount into the shell container. Source: sandbox/.
2 · Two teams, one shell
Add a second MFE that imports a component from the first. This demonstrates
cross-team dependency resolution through import maps.
fe new acme/mfe-a # React MFE, no deps
fe new acme/mfe-b # Solid.js MFE, depends on mfe-a
fe link mfe-b mfe-a # adds mfe-a as devDependency of mfe-b
fe publish mfe-a
fe publish mfe-b
In mfe-b/src/index.ts, import from fe(acme/mfe-a).
The runtime will resolve both specifiers via the injected import map. The JIT
server compiles each independently using the appropriate JIT plugin.
Source: sandbox/mfe-a and sandbox/mfe-b.
3 · Using toolkit/store across MFEs
Share state between two MFEs without prop drilling or a global window variable.
fe(acme/store) provides a typed pub/sub store that persists across
MFE mounts and unmounts.
// in both mfe-a and mfe-b package.json
"devDependencies": { "fe(acme/store)": "^1.0.0" }
// mfe-a/src/index.ts
import { createStore } from "fe(acme/store)";
const counter = createStore("app:counter", 0);
export function render(container, props) {
const btn = document.createElement("button");
btn.textContent = "increment";
btn.onclick = () => counter.set(counter.get() + 1);
container.appendChild(btn);
return () => container.removeChild(btn);
}
// mfe-b/src/index.ts
import { createStore } from "fe(acme/store)";
const counter = createStore("app:counter", 0);
export function render(container, props) {
const span = document.createElement("span");
const unsub = counter.subscribe(v => { span.textContent = String(v); });
container.appendChild(span);
return () => { unsub(); container.removeChild(span); };
}
Both MFEs resolve to the same fe(acme/store) version through the
import map. The store instance is shared because the module is loaded once.
4 · Adding a JIT plugin for a custom JSX transform
If you need a JSX transform that is not covered by the built-in React and
Solid.js plugins, you can write your own:
// @acme/fe-jit-plugin-preact/src/index.ts
import type { JitPlugin } from "@fe/core";
export default {
name: "acme-preact",
"build:options"(options) {
return {
...options,
jsx: "automatic",
jsxImportSource: "preact",
};
},
} satisfies JitPlugin;
// configs/fe.config.json
{ "jitPlugins": ["@acme/fe-jit-plugin-preact"] }
The build:options hook receives Bun.BuildOptions and
returns a modified copy. Plugins run in declaration order; later plugins can
override earlier settings. The platform team declares the plugin on the server;
MFE teams do not need to configure anything.