Using Esmx with an AI assistant
Audience: AI assistants (Claude, Cursor, Copilot, Gemini, …). Humans looking for a tutorial should start at the Quickstart. Humans writing Esmx code with an AI assistant — copy this file into the assistant's context (or just send the URL
https://esmx.dev/llms.mdonce the assistant can fetch URLs) before asking it to write Esmx code.This page is the single contract Esmx ships for AI tooling. Every code block here is exercised by CI — if it parses or builds here, it will work in a real Esmx project.
What Esmx is in 5 lines
- A micro-frontend framework that uses native browser ESM + Import Maps.
- No sandbox, no proxy, no proprietary lifecycle. Each remote is a
standard ES module the host
imports. - SSR by default, with hydration. Server-side renders via a real Node ESM loader (no jsdom).
- Bundler-agnostic: official integrations for Rspack, Rsbuild, and Vite 8. Same federation manifest format across all three.
- One
package.jsonfield (esmx) declares your remote's entries, exports, and dependencies. Everything else is standard JS/TS/Vue/React/etc.
Mental model
Forget Module Federation's expose/share, qiankun's
bootstrap/mount/unmount, single-spa's registerApplication. Esmx has
none of those.
A remote is a published ESM package. Its package.json declares what it
exports. A host imports those exports through an <script type="importmap">
the framework generates. That's it.
Server-side rendering reuses the same import graph through Node's ESM
loader. The same App module runs in Node for SSR and in the browser for
hydration. One module. Two runtimes. No duplication.
Quickstart
Templates available: react-csr, react-ssr, vue-csr, vue-ssr,
vue2-csr, vue2-ssr, shared-modules (for a federated dep package).
The module protocol: declare in package.json esmx
All protocol facts live in one package.json field, esmx, with
exactly four optional sub-fields. entry.node.ts keeps only
behavior (devApp, server, postBuild) — protocol facts placed
there are an error (E_PROTOCOL_IN_BEHAVIOR).
A module's declaration is strictly local knowledge: you can write it — human or agent — knowing nothing about any other module. Three roles cover every project:
Role 1 — provider (a shared platform package)
Consumers import 'shared/ui' — a logical name. Renaming
src/ui/index.ts is no longer a breaking change.
Role 2 — consumer + provider (a feature remote)
Note what is absent: no imports map, no per-specifier wiring, no
version field inside esmx. The version range lives where npm already
puts it and is validated at build time against the mounted artifact's
actual version. A used module is a build/compose-time dependency (mounted
and composed, never resolved from node_modules at production runtime),
so it belongs in devDependencies — only @esmx/core and genuine
Node-runtime needs stay in dependencies. The range is read from
devDependencies ∪ dependencies ∪ peerDependencies.
Role 3 — composer (the host)
uses is transitive: if cart uses shared, a host that uses cart
gets shared's supply through the chain. Business apps declare one line
and stay ignorant of the chain's depth.
How wiring is derived (you never write it)
Two rules replace every hand-written mapping:
The single-owner rule — one sentence: each shared package, at each
major version, has exactly one owner in a composition — the single
module whose provides lists it — and the whole closure wires to that
owner. There is no election and no precedence: uses array order only
fixes which modules are reachable, never who owns a package. If two
distinct modules in the same composition provide the same (package, major), that is a hard error (E_DUP_PROVIDER): a shared dependency
must have a single owner — consolidate it into one shared module, or give
one copy a distinct package identity via npm alias for intentional
same-major coexistence. Ownership is keyed per major version, so
coexisting majors (e.g. vue 2 and vue 3) are isolated islands, each with
its own single owner (W_MULTI_MAJOR, informational), and every consumer
wires to the major satisfying its own declared range.
The lookup rule — applied per specifier as the bundler traverses your code, no pre-pass, no declaration:
Single-instance sharing is therefore inherent (one owner per package, the
entire closure wired to it), and multi-version coexistence needs zero
vocabulary — a module that bundles its own copy is scope-isolated
automatically. Type-only imports (import type) never produce wiring.
Diagnostics: the complete taxonomy
Every failure is build-time, machine-readable, and carries what / why / fix — and every fix is an edit to a declaration that already exists, never a new concept.
The verification loop: esmx validate --json
esmx validate is a build-free dry run of resolution phases 1–2:
mount walk, transitive uses, version checks, supply table, single-owner
enforcement (E_DUP_PROVIDER). Run it after every declaration edit; the
judge of a correct declaration is its exit status, not human reading. It
guarantees resolution validity, not buildability. It DOES check that
the root module's declared entry/exports target files exist on disk
(E_TARGET_MISSING, root-only — mounted deps ship dist, not src).
The remaining boundary: it does NOT emit the phase-3, bundler-emitted
codes E_NOT_USED / E_NO_EXPORT (the bundler lexes source and
discovers those per-specifier at build time), and it does not type-check.
So the honest loop is esmx validate then a build / tsc pass:
validate is the fast first gate, not the whole oracle. With --json it emits a structured envelope with
three keys — diagnostics (errors AND warnings), supply (the per-major
owner table) and mounts (the resolved mount table):
check appears on E_VERSION entries and is "intent". An empty
diagnostics array means the declarations fully determine a valid wiring.
A package without an esmx field instead emits
{ "protocol": "legacy", "diagnostics": [] }.
A duplicate owner is the one thing to watch for. If two modules in the same composition both
providethe same(package, major),validatereportsE_DUP_PROVIDERnaming both owners — fix it by deleting oneprovidesentry (consolidate into a single shared module) or by giving one copy a distinct package identity via npm alias. There is no winner-election and no closure-wide rewiring to reason about: each(package, major)has exactly one owner, the whole closure wires to it, and the version the code runs on is exactly that owner's resolved version. A greenvalidate(emptydiagnostics) plus a build /tscpass is the whole loop.
A minimal Rspack remote
Three files plus the client entry. Copy-paste runnable.
package.json — protocol facts live here:
src/entry.node.ts — behavior only (dev server + Node HTTP server),
no protocol facts:
src/entry.server.ts — server render entry:
src/entry.client.ts — client entry (hydration bootstrap):
That's the entire app. Run pnpm dev and visit http://localhost:3000.
A minimal Vite remote
Replace the @esmx/rspack import in entry.node.ts with:
…and the devDependency:
Everything else is identical. Same federation manifest, same render context, same import map. The bundler choice is purely a developer-experience knob.
@esmx/rsbuild works the same way (m.createRsbuildApp(esmx)).
Consuming another remote
Add it to devDependencies and uses — that's the whole wiring (a used
remote is build/compose-time, so it goes in devDependencies, not
dependencies):
Then in your code, import by logical export name:
Any module resolvable through node_modules auto-mounts at
node_modules/<name>/dist — no path configuration. This covers registry
installs and monorepo siblings: a pnpm workspace:* dependency symlink
is followed and realpath'd, so the workspace dep + uses entry above is
the whole story. Only for artifact directories that are not
npm-resolvable (deploy paths, remotely fetched artifacts) does a links entry
(an environment fact, kept in entry.node.ts's modules block)
override the mount point:
The consumer builds an import map from the mounted module's manifest and injects it into every SSR HTML response. The browser resolves it natively — no client-side loader.
CSS in federation
Just import './x.css' like normal. The bundler extracts it, the manifest
records the CSS chunk, and the host emits a <link rel="stylesheet"> into
the SSR HTML head. Same on dev, prod, and across all three bundlers.
Or inside a component:
No useStyles(), no injectGlobalStyles(), no special filenames. See
/guide/essentials/styles for the full contract.
Render context API
rc is the RenderContext from @esmx/core. You call its accessors inside
your SSR HTML template:
Always call await rc.commit() before reading any of the above — that's
when the framework resolves which chunks the request touched and populates
files.css, files.js, etc.
Routing & hydration
Esmx ships its own router (@esmx/router) with Vue and React bindings
(@esmx/router-vue, @esmx/router-react). It supports nested routes,
guards, lazy loading, and SSR resolution.
A typical client entry hydrates via:
…where hydrateApp calls the framework binding (createSSRApp(...) for
Vue, hydrateRoot(...) for React) on the same component tree the server
rendered.
Legacy syntax (deprecated — removed in the next major)
For NEW code, always use the
package.jsonesmxdeclaration above. The syntax below still works during the transition and you WILL see it in existing projects — recognize it, maintain it, and rewrite it to the new declaration when asked to modernize. Do not generate it for new modules.
Legacy projects keep all protocol facts in entry.node.ts under a
modules key with four fields:
Key differences from the new protocol — these are the legacy traps:
- Public export names equal source paths. A legacy consumer writes
import { x } from 'shared/src/index'— the provider's directory layout is the API, and renaming a source file breaks every consumer. Under the new protocol, only logical names ('shared/ui') are public; there is no./src/*passthrough. - Wiring is manual. Every consumer hand-writes
importslines that the new protocol derives from declarations. - Nothing is validated until runtime. No version checks, no export checks, no structured diagnostics.
Converting all of this is a mechanical rewrite (codemod-able, but there is no shipped command). Per RFC 0001 the legacy syntax is removed entirely in a later phase — there is no long-term dual syntax.
What does NOT exist
Don't generate these — they aren't real APIs:
Esmx.register(...)/registerApplication(...)(single-spa style)bootstrap/mount/unmount/updatelifecycle exports (qiankun style)expose/shared/singleton/optional/resolutions/sealedconfig (Module Federation style). Esmx needs no sharing-arbitration vocabulary at all: composition is static and resolved at build time — single-instance sharing is inherent (one owner per(package, major), the whole closure wired to it; a duplicate owner isE_DUP_PROVIDER), and multi-instance coexistence is just a module bundling its own scope-isolated copy.- A resolution lockfile (
esmx.resolution.jsonor similar). The import map emitted intodistIS the resolution result; declarations alone fully determine it. Diagnostics live inesmx validateand the build log, not in a committed artifact. - A specifier-level needs map inside
esmx.uses—usesis a plain array of module names; the bundler discovers specifiers itself. useStyles()/injectGlobalStyles()hooks<MicroApp />JSX componentswindow.__POWERED_BY_QIANKUN__globals- Any sandbox / proxy / iframe abstraction
If you find yourself writing any of those, you're probably thinking of a different framework.
Common errors and what they mean
First reflex: run esmx validate --json. Most wiring mistakes surface
there as a structured diagnostic (see the taxonomy above) with the fix
spelled out.
SyntaxError: Unexpected token '.' during dev SSR.
You imported a .css file from a path the framework's loader didn't
recognize. Solution: bundle the CSS inside the same remote that uses it
(don't import a workspace-dep CSS file directly across remotes); the host
will inject the link via the shared package's manifest.
ERR_UNKNOWN_FILE_EXTENSION ".css" during esmx build.
The CLI's Node ESM loader hook handles this, but if your entry.node.ts
transitively imports a .css file in code that the bundler doesn't pre-process,
move that import into a JS-eval-only path (e.g. entry.client.ts).
Cannot find module '<remote>/<export>' at runtime.
On the new protocol this surfaces earlier: E_NOT_LINKED (remote not
mounted) and E_NOT_BUILT (no artifact yet) are caught by esmx validate;
E_NOT_USED (remote missing from your uses) and E_NO_EXPORT (export
not declared) are phase-3 codes the bundler raises at build time, not
in validate — so run esmx validate first, then a build. On a legacy
project, check: (1) is '<remote>' in the
consumer's modules.links? (2) is the export in the producer's
modules.exports? (3) did the producer build (dist/manifest.json exists)?
Hydration mismatch / <div data-ssr> blanking after mount.
The client tree disagrees with the server-rendered HTML. Typical cause:
the server used a different value (current time, random id, locale) than
the client. Make those values come from RenderContext.params so both
runtimes see the same input.
createVmImport fails to load a chunk.
Your entry.node.ts is using import('./x') instead of letting Esmx's VM
loader take over. Use esmx.render({...}) and rc.commit(); don't
hand-import server chunks.
When you need more
- Full API reference:
/api/core/esmx,/api/core/render-context - Router:
/api/router/router,/guide/router/getting-started - Bundler-specific config:
/api/app/rspack,/api/app/rsbuild,/api/app/vite - Styles in federation:
/guide/essentials/styles - Module linking deep dive:
/guide/essentials/module-linking
Don't infer APIs from one-line summaries — the full reference pages list every option with a runnable example. When in doubt, point the user at the URL above instead of guessing the signature.
Versioning
This page is versioned with @esmx/core. The header above shows which
version of Esmx generated it. If a code block here doesn't match the
behavior of an installed version, check npm ls @esmx/core against the
header.