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.md once 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

  1. A micro-frontend framework that uses native browser ESM + Import Maps.
  2. No sandbox, no proxy, no proprietary lifecycle. Each remote is a standard ES module the host imports.
  3. SSR by default, with hydration. Server-side renders via a real Node ESM loader (no jsdom).
  4. Bundler-agnostic: official integrations for Rspack, Rsbuild, and Vite 8. Same federation manifest format across all three.
  5. One package.json field (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.

┌──────────────────┐                      ┌──────────────────┐
│  host (entry)    │ ─── import map ────▶ │  remote (esm)    │
│  import {App}    │                      │  export App      │
│  from '@your/x'  │                      │  export server   │
└──────────────────┘                      └──────────────────┘
        │                                          │
        └──────────── single import graph ─────────┘
              (browser resolves; no proxy)

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

npm create esmx@latest my-app
cd my-app
pnpm install
pnpm dev

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

FieldMeaning
entryThe framework entries (client / server), declared like any other export. Library-only modules simply omit it.
exportsSubpath map with logical names: keys are ./<name>, values are relative source files or { client, server } forks (false disables a side). Consumers never see your physical paths.
providesPlain array of third-party packages this module re-exports for consumers (e.g. ["vue"]). The provided version is your resolved installed version, captured into the manifest at build time.
usesPlain array of module names you consume from. Not specifiers, not versions — just names. Order does not matter — it only fixes which modules are reachable (see the single-owner rule below).

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)

{
    "name": "shared",
    "version": "3.2.1",
    "esmx": {
        "entry": {
            "client": "./src/entry.client.ts",
            "server": "./src/entry.server.ts"
        },
        "exports": {
            "./ui": "./src/ui/index.ts",
            "./store": {
                "client": "./src/store.client.ts",
                "server": "./src/store.server.ts"
            }
        },
        "provides": ["vue", "@esmx/router"]
    }
}

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)

{
    "name": "cart",
    "version": "1.8.0",
    "devDependencies": { "shared": "^3.0.0" },
    "peerDependencies": { "vue": "^3.4.0", "@esmx/router": "^3.0.0" },
    "esmx": {
        "entry": {
            "client": "./src/entry.client.ts",
            "server": "./src/entry.server.ts"
        },
        "exports": { "./widget": "./src/cart-widget.ts" },
        "uses": ["shared"]
    }
}

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

Role 3 — composer (the host)

{
    "name": "host",
    "version": "2.0.0",
    "devDependencies": { "shared": "^3.0.0", "cart": "^1.5.0" },
    "peerDependencies": { "vue": "^3.4.0", "@esmx/router": "^3.0.0" },
    "esmx": {
        "entry": {
            "client": "./src/entry.client.ts",
            "server": "./src/entry.server.ts"
        },
        "uses": ["shared", "cart"]
    }
}

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:

bare specifier found in my supply table → externalized, wired to the owner
found nowhere                            → my own bundled copy, isolated by
                                           per-module import-map scopes

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.

CodeKindWhat it meansTypical fix
E_NOT_LINKEDerrorA name in uses is absent from the mount table.npm install the module (npm packages auto-mount at node_modules/<name>/dist) or add a links override.
E_NOT_BUILTerrorMounted, but no built artifact yet. Blocks only manifest-dependent checks, not declaration wiring.Build the listed modules first.
E_CYCLEerrorThe uses chain (or mount walk) revisits a module.Architecture error — break the cycle. Hard-stops resolution (supply withheld), so it is never wired on an order-dependent result.
E_DUP_PROVIDERerrorTwo distinct modules own the same (package, major). Names both owners.A shared dependency must have a single owner: delete the duplicate provides entry (consolidate into one shared module), or give one copy a distinct package identity via npm alias for intentional same-major coexistence.
E_VERSIONerrorIntent: the owner's resolved version violates a consuming layer's dependencies/peerDependencies range. Names the layer.Align the named layer's range, or upgrade/rebuild the owner.
E_TARGET_MISSINGerrorA declared entry/exports target file does not exist on disk. Root-module check only (mounted deps ship dist, not src). Emitted by esmx validate.Fix the path in the root esmx declaration, or create the missing file.
E_NOT_USEDerrorYou imported 'mod/export' but mod is not in your uses chain. Build-time / bundler-emitted — not emitted by esmx validate.Add "mod" to uses (and dependencies).
E_NO_EXPORTerrorThe export name is absent from the provider's declaration. Message lists the module's actual exports. Build-time / bundler-emitted — not emitted by esmx validate.Fix the import specifier, or add the export to the provider's esmx.exports.
E_PROTOCOLerrorA mounted manifest's protocol is newer than this linker supports.Upgrade @esmx/core.
E_PROTOCOL_IN_BEHAVIORerrorProtocol facts (a modules config) found in entry.node.ts.Move them to package.json esmx — a mechanical rewrite.
E_SCHEMAerrorThe esmx declaration is structurally invalid — wrong type, an unknown key, or an export/entry path that is not a ./ relative path. The single most common authoring error.Read the message: it names the offending field and the allowed shape; fix the declaration to match.
W_MULTI_MAJORwarningA package has coexisting major versions, each major an isolated island with its own single owner. Informational — cross-major collision cannot happen.Nothing, if coexistence is intended; otherwise align the owners' installed majors.
W_NO_RANGEwarningA layer consumes a provided package without declaring any version range.Add the package to dependencies or peerDependencies.
W_TYPE_DRIFTwarningYour devDependencies types copy diverges from the owner's resolved version (the version your code actually runs on).Align the devDependencies version.

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

{
    "diagnostics": [
        {
            "code": "E_VERSION",
            "check": "intent",
            "module": "base",
            "package": "vue",
            "found": "3.0.2",
            "required": "^3.4.0",
            "message": "what happened and why",
            "fix": "the declaration edit to make"
        }
    ],
    "supply": {
        "vue": {
            "groups": [
                { "major": 3, "provider": "shared", "version": "3.5.13" }
            ]
        }
    },
    "mounts": {
        "shared": {
            "name": "shared",
            "root": "/abs/path/to/shared",
            "artifactDir": "/abs/path/to/shared/dist",
            "built": true
        }
    }
}

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 provide the same (package, major), validate reports E_DUP_PROVIDER naming both owners — fix it by deleting one provides entry (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 green validate (empty diagnostics) plus a build / tsc pass is the whole loop.

A minimal Rspack remote

Three files plus the client entry. Copy-paste runnable.

package.json — protocol facts live here:

{
    "name": "my-remote",
    "version": "0.1.0",
    "type": "module",
    "private": true,
    "scripts": {
        "dev": "esmx dev",
        "build": "esmx build",
        "start": "esmx start"
    },
    "esmx": {
        "entry": {
            "client": "./src/entry.client.ts",
            "server": "./src/entry.server.ts"
        },
        "exports": {
            "./app": "./src/app.ts"
        }
    },
    "dependencies": {
        "@esmx/core": "^3.0.0-rc.117"
    },
    "devDependencies": {
        "@esmx/rspack": "^3.0.0-rc.117"
    }
}

src/entry.node.ts — behavior only (dev server + Node HTTP server), no protocol facts:

import http from 'node:http';
import type { EsmxOptions } from '@esmx/core';

export default {
    async devApp(esmx) {
        return import('@esmx/rspack').then((m) => m.createRspackApp(esmx));
    },
    async server(esmx) {
        const server = http.createServer((req, res) => {
            esmx.middleware(req, res, async () => {
                const rc = await esmx.render({ params: { url: req.url } });
                res.end(rc.html);
            });
        });
        server.listen(3000, () =>
            console.log('http://localhost:3000')
        );
    }
} satisfies EsmxOptions;

src/entry.server.ts — server render entry:

import type { RenderContext } from '@esmx/core';

export default async (rc: RenderContext) => {
    await rc.commit();
    rc.html = `<!DOCTYPE html>
<html>
<head>
    ${rc.preload()}
    ${rc.css()}
</head>
<body>
    <h1>Hello from Esmx</h1>
    ${rc.importmap()}
    ${rc.moduleEntry()}
    ${rc.modulePreload()}
</body>
</html>`;
};

src/entry.client.ts — client entry (hydration bootstrap):

console.log('hydrated');

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:

async devApp(esmx) {
    return import('@esmx/vite').then((m) => m.createViteApp(esmx));
}

…and the devDependency:

"@esmx/vite": "^3.0.0-rc.117"

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

{
    "devDependencies": { "my-remote": "^0.1.0" },
    "esmx": {
        "entry": {
            "client": "./src/entry.client.ts",
            "server": "./src/entry.server.ts"
        },
        "uses": ["my-remote"]
    }
}

Then in your code, import by logical export name:

import { App } from 'my-remote/app';

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:

modules: {
    links: {
        'my-remote': '/srv/deploy/my-remote/dist'
    }
}

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.

// Inside any remote's source — say src/entry.client.ts
import './styles/globals.css';

Or inside a component:

<script setup>
import './card.css';
</script>
import './card.css';
export const Card = ({children}) => <div className="card">{children}</div>;

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:

MethodEmits
rc.preload()<link rel="preload"> tags for every JS / CSS chunk this request touches.
rc.css()<link rel="stylesheet"> tags for the same.
rc.importmap()<script type="importmap"> mapping bare specifiers to chunk URLs.
rc.moduleEntry()<script type="module"> that imports the client entry.
rc.modulePreload()<link rel="modulepreload"> for transitive deps.
rc.html = '...'The final HTML the framework returns. Assign once after rc.commit().

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:

// src/entry.client.ts
import { hydrateApp } from './app';
await hydrateApp();

…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.json esmx declaration 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:

// entry.node.ts (LEGACY)
import type { EsmxOptions } from '@esmx/core';

export default {
    modules: {
        // Where mounted modules live (hand-written relative dist paths).
        links: {
            'shared': '../shared/dist'
        },
        // Manual specifier → provider wiring (replaced by the supply merge).
        imports: {
            'vue': 'shared/vue'
        },
        // String-prefix DSL:
        //   'pkg:vue'           → re-export the npm package "vue"
        //                         (new protocol: provides: ["vue"])
        //   'root:src/index.ts' → expose a source file; the PUBLIC name IS
        //                         the source path ("shared/src/index")
        //                         (new protocol: exports: {"./ui": "./src/ui/index.ts"})
        exports: [
            'pkg:vue',
            'root:src/index.ts'
        ]
    },
    async devApp(esmx) { /* ... */ },
    async server(esmx) { /* ... */ }
} satisfies EsmxOptions;

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 imports lines 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 / update lifecycle exports (qiankun style)
  • expose / shared / singleton / optional / resolutions / sealed config (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 is E_DUP_PROVIDER), and multi-instance coexistence is just a module bundling its own scope-isolated copy.
  • A resolution lockfile (esmx.resolution.json or similar). The import map emitted into dist IS the resolution result; declarations alone fully determine it. Diagnostics live in esmx validate and the build log, not in a committed artifact.
  • A specifier-level needs map inside esmx.usesuses is a plain array of module names; the bundler discovers specifiers itself.
  • useStyles() / injectGlobalStyles() hooks
  • <MicroApp /> JSX components
  • window.__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.