a platform for microfrontends ✦

Ship independently. Compose natively.

Each team publishes TypeScript source. The JIT server compiles it on first request. The browser resolves every fe(scope/name) specifier through import maps at runtime. No shared build pipeline. No version negotiations at deploy time.

◆ MFE teams
fe publish · fe dev · fe new
◆ JIT server
fe serve · compiles on demand
◆ platform config
platform.json · routes + packages
◆ browser runtime
import maps · @fe/runtime · render()
▸ menu

◆ 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.

fe publish
upload source
platform.json
new version registered
JIT server
compile on first request
CDN
immutable cache
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:

WhatHow you use it
Shell appThe host HTML page that loads your MFE. You do not build or deploy it.
JIT server URLThe base URL where compiled bundles are served. Embedded in the shell.
platform.jsonRoutes and package registry. Your team or CI updates a route entry after publishing.
Toolkit packagesfe(acme/store), fe(acme/network) — resolved at runtime, no install step.

Day-to-day workflow

A typical feature cycle looks like this:

  1. Scaffold once: fe new acme/my-feature creates my-feature/ with a render() stub.
  2. Develop: fe dev my-feature then open the shell with a sessionStorage override pointing at your dev URL.
  3. Type-check in CI: fe check my-feature runs on every push.
  4. Publish when ready: fe publish my-feature uploads source and prints the new version.
  5. 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 () => {};
}

◆ 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:

FileWho reads itWhat 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:

PackageWhat 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.