@bkincz/spool
spool
spool is a small CLI for building micro frontends. It scaffolds a monorepo of Module Federation apps on Vite, runs them together with one command, and keeps all the wiring in a single spool.json. Describe your apps once and spool handles the rest.
Live demo: spool-demo-shell.pages.dev, a music UI where the browse view, search, and player bar are independently deployed remotes sharing one player state through @bkincz/clutch.
Install
npm install -g @bkincz/spool
You'll need Node 22.13 or newer. Workspaces run on pnpm, npm, or yarn.
Quick start
spool create acme --host shell --remotes "dashboard, profile"
cd acme
spool dev
Open http://localhost:5173 to see the host with its remotes mounted. Prefer prompts? Just run spool create on its own.
Commands
| Command | What it does |
|---|---|
spool create [dir] |
Scaffold a workspace |
spool dev [--only] [--rest] [--kill] |
Run all apps together, remotes first |
spool build [--only] [--env] [--json] |
Production build. Remotes first, then hosts, each tier in parallel |
spool preview [--only] [--kill] |
Serve the built apps locally |
spool types [--only] |
Type each remote's exposes from its real source |
spool add <name> |
Add an app and wire it in |
spool addon [list] [--only] |
Add extras to an existing workspace, optionally to some apps only |
spool remove <name> [--files] |
Remove an app and unwire it from every consumer. --files asks before deleting |
spool deploy [--only] [--env] [--json] |
Run each app's deploy command, remotes first, and record the deploy |
spool promote --env <env> <app>@<sha> |
Re-run a recorded deploy against another environment |
spool rollback --env <env> <app> |
Re-run an app's previous deploy for an environment |
spool ci [--force] [--pin] |
Generate GitHub workflows: one checking the workspace, one per deployable app |
spool upgrade [--dry-run] [--force] |
Regenerate spool-owned files and move dependencies forward to match the workspace |
spool doctor [--fix] [--json] |
Check ports, wiring, generated files, and dependencies. --fix repairs what it can |
spool graph [--json] [--dot] |
Print hosts, remotes, what each consumes and exposes, and the shared list |
spool affected --since <ref> [--json] |
List apps changed since a git ref, directly or through a package they import |
spool eject [--yes] |
Remove the spool dependency and keep everything it generated |
create
| Option | Description |
|---|---|
-n, --name <name> |
Workspace name (defaults to the folder name) |
--host <name> |
Host app, as name or name:framework |
--remotes <list> |
Comma-separated remotes, each as name or name:framework |
--framework <fw> |
Default framework: react, svelte, or vue |
--addons <list> |
Extras: ladle, playwright, lint, test, turbo, state, sentry, navigation, federation, or none |
--pm <manager> |
pnpm, npm, or yarn |
--here |
Scaffold into the current folder |
--no-install |
Skip the install step |
Anything you leave out, spool asks for. Names are lowercase with single hyphens, like acme-frontend.
add
| Option | Description |
|---|---|
-t, --type |
host or remote (default remote) |
-p, --port |
Dev server port (default: next free) |
--host <name> |
Host to wire the remote into (default: first host) |
--framework <fw> |
react, svelte, or vue |
--no-install |
Skip the install step |
spool add updates spool.json, typings, registry, and bridge files, then prints the mount snippet to paste in. It never edits your components.
dev
| Option | Description |
|---|---|
--only <list> |
Run a subset of apps |
--rest <mode> |
Where the remotes a subset needs come from: built previews their dist/, an env name uses their url |
--timeout <seconds> |
How long to wait for a remote before giving up (default 20) |
--no-ladle |
Skip the component workshop |
--kill |
Stop a previous run from another shell and free the ports |
Working on one remote does not need every server up:
spool dev --only shell,dashboard --rest staging # everything else from its staging url
spool dev --only dashboard --rest built # everything else from dist/
spool dev writes .spool/dev.pid while running. If a run is ever left behind, spool dev --kill cleans it up, and spool doctor tells you which process holds a port.
The manifest
Everything lives in one spool.json. Each app's vite.config.ts reads it through spool.vite.ts when Vite starts, so the manifest is the only thing you ever edit.
{
"name": "acme",
"packageManager": "pnpm",
"shared": ["react", "react-dom"],
"apps": {
"shell": { "type": "host", "path": "apps/shell", "port": 5173, "remotes": ["dashboard"] },
"dashboard": { "type": "remote", "path": "apps/dashboard", "port": 5174, "exposes": { "./App": "./src/app/app.tsx" },
"url": "https://dashboard.example.com/mf-manifest.json" }
}
}
| Field | Description |
|---|---|
shared |
Deps shared as singletons across apps |
shareStrategy |
Optional. loaded-first (default) or version-first |
overrides |
Optional. true keeps runtime remote overrides on in production builds (default false) |
server |
Optional. Dev server settings every app gets: proxy, headers, host, cors, fs.allow |
apps.<name>.type |
host consumes remotes, remote exposes modules |
apps.<name>.framework |
react (default), svelte, or vue |
apps.<name>.port |
Dev server port |
apps.<name>.remotes |
Remotes this app mounts. A remote can consume remotes too |
apps.<name>.exposes |
Modules a remote exposes, keys starting with ./ |
apps.<name>.url |
Optional. Deployed mf-manifest.json, used by host production builds |
apps.<name>.urls |
Optional. Per-environment manifests, e.g. { "staging": "https://..." }. --env selects one |
apps.<name>.deploy |
Optional. Shell command spool deploy runs in the app folder |
apps.<name>.headers |
Optional. Extra response headers for this app, in dev, preview, and the generated _headers |
apps.<name>.frameAncestors |
Optional. Who may frame this app: self, none, origins, or edge alone. Default self |
Settings every app needs from the dev server go in server, so you never edit a generated vite.config.ts:
{
"server": {
"proxy": { "/api": { "target": "${ACME_BACKEND:-http://localhost:3000}", "changeOrigin": true } }
}
}
${VAR} and ${VAR:-fallback} read the environment at startup.
Serving files outside the workspace
Vite's dev server only serves files inside the workspace, so a module linked in from a sibling folder gets a 403. List the folders in server.fs.allow, relative to the folder spool.json is in:
contracts/packages/schemas shared zod schemas, linked with "link:../contracts/packages/schemas"
frontend/spool.json
frontend/apps/shell
{
"server": {
"fs": { "allow": ["../contracts/packages"] }
}
}
Every app's dev server then allows the workspace root, as Vite does by default, plus each folder you list, as absolute paths. Vite's fs.deny defaults (.env, .env.*, certificates, .git) still apply inside them. preview serves only the built dist/ and ignores the setting.
An empty entry or the filesystem root is rejected. A folder that does not exist yet is only a spool doctor warning, since Vite ignores it. Entries may use ${VAR}; doctor skips those and the dev server refuses one that expands to nothing. A spool.vite.ts from before 3.1 ignores the setting, and spool dev and spool doctor say so; run spool upgrade.
The manifest is validated when it loads. A remote that names itself or an app that does not exist, a cycle through remotes, an expose outside its app folder, or an app path outside the workspace fails right away with the app named. spool doctor catches the rest after hand-editing.
Checks and upgrades
spool doctorchecks ports (including one another process holds), wiring, exposed files, registry and typings, generated files against what spool wrote, shared dep versions across every workspace member, shared entries an app never imports, and the environment the addons need.--fixrepairs what it safely can,--dry-runshows the changes first, and--jsonprints the problems for CI.spool upgradenever overwrites your edits. Spool hashes every file it writes into.spool/generated.json, so a later run can tell its own output from yours. Edited files are offered, not replaced.--forceskips the asking and takes paths to limit it. Commit.spool/generated.json;.spool/typesand pidfiles are ignored.- Versions only ever move forward, onto the highest range already in the workspace, so a workspace ahead of spool stays ahead. Prereleases and
>=ranges are never chosen as the target. spool buildruns a tier at once and then compares what each app resolved for every shared dep, failing only when one app would load a version another cannot accept.--concurrency <n>caps the parallelism.spool graphshows the workspace as a tree, or as--dotfor Graphviz.spool affected --since mainlists the apps a change touches, directly or through a workspace package they import.spool ejectremoves the spool dependency and leaves the rest in place.spool.json,spool.vite.ts,spool.workspace.tsand every generated config keep working without the CLI.
Types across the boundary
spool types emits declarations for every expose using the workspace's TypeScript into .spool/types, then rewrites each consumer's src/remotes.d.ts to point at them. <Remote name="dashboard/Panel" props={...} /> is typed against the real component, and a changed prop fails type-check in the host. spool dev and spool build run it first, so the types are current. Without a TypeScript install spool falls back to the prop-less typing and says so.
The app list
spool.workspace.ts reads the manifest when you import it, so nothing built on it goes stale:
import { apps, hosts, remotes, srcDirs, app, root } from '../../spool.workspace'
Each app has name, type, framework, port, absolute dir and src, remotes, and exposes.
Frameworks
Every app scaffolds into src/app/app.tsx with a co-located stylesheet, and main.tsx imports it through the @ alias.
Mix react, svelte, and vue freely. Every app picks its own framework. React remotes expose a component, svelte and vue remotes expose a mount function, and hosts consume each remote by its contract (non-react hosts get a small react bridge). Sharing applies per app: entries an app does not declare in its own package.json are dropped from its federation config, so a svelte remote never tries to share react.
Extras
spool create can also set up the tooling most workspaces end up wanting. Pick at the prompt, or pass --addons "ladle, playwright, lint, test, turbo, state, sentry, navigation, federation". Missed one? spool addon adds it to an existing workspace later.
- Ladle: a react design-system package in
packages/uiwith a component workshop.spool devstarts it alongside your apps, or open it on its own withpnpm --filter ui ladle. - Playwright: e2e tests in
packages/e2ethat boot the workspace and check every remote mounts. Runnpx playwright installonce, thenpnpm --filter e2e test. - ESLint: one flat config at the root, type-checked, with the plugin each framework you use needs.
- Vitest: a
vitest.config.tsper app, separate fromvite.config.tsbecause federation cannot run under a test. Any app withremotesgets them stubbed insrc/test, regenerated when the remote list changes.type-checkscripts come with or without this addon. - Turborepo: a
turbo.jsonthat putsspool.json, the runtime helper, and theSPOOL_ENV/SPOOL_REMOTE_*variables into the cache key, so a wiring change never gets a stale hit.spool devandspool buildare unchanged; turbo covers the tasks spool does not run. - Shared state: @bkincz/clutch shared as a singleton, plus a store module in every app so they read and write one state instance per page. The store validates its shape on every change with a plain predicate, swap in a zod or valibot schema if you want one. Bump its
versionand add amigratewhen the shape changes. - Sentry: each app gets its framework SDK and a
src/sentry.tswired into its entry. Errors are tagged with the app whose chunk threw them, so a remote failing inside a host is filed under the remote. SetVITE_SENTRY_DSNper app; every app gets a.env.examplenaming it. WithSENTRY_AUTH_TOKEN,SENTRY_ORG, andSENTRY_PROJECTin CI,spool builduploads hidden source maps, deletes them fromdist/, and tags the release. - Navigation: one url every bundle on the page agrees on, in
src/navigation.navigateanduseLocationride onwindow.history, so back and forward work across remotes and deep links resolve on load. - Federation: a
<Remote name="..." />primitive and the remote registry, insrc/federation. Mounts any remote by name, across frameworks. Every module a remote exposes gets a name, so<Remote name="dashboard" />is its app and<Remote name="dashboard/Panel" />is a widget it also offers. Those names also make up a generatedRemoteNametype, so a typo like<Remote name="dashbaord" />orpreloadRemote("serach")won't compile.
<Remote> keeps a remote that fails to load from taking the host down. If the remote is down, mid-deploy or ships a chunk that won't load, you get a placeholder with a retry button. Retry fetches the remote's manifest and entry again, so once the remote is back it renders without a page reload. A failed preloadRemote doesn't stick either. The first mount after it loads the remote again by itself, so a short outage during a boot preload recovers without anyone clicking retry. Every host on the page, nested hosts included, shares what it knows about each remote's entry. Once any of them has loaded a remote the rest reuse it, and no retry evaluates it twice. If one expose of a loaded remote fails, only that expose is retried and the remote stays as it is. Pass props for the remote, fallback for while it loads, renderError to style the failure and onError to report it. The sentry addon reports them for you.
In React, an error a remote throws while rendering, after it has loaded, isn't a load failure. <Remote> doesn't report it and passes it on exactly as it was thrown to your app's own error boundaries. So give the host an error boundary around its routes, or React will unmount the page.
Composing remotes is your own code, one region or many, persistent or routed:
import { useLocation, navigate, matchRoute } from '@/navigation'
import { Remote, preloadRemote, type RemoteName } from '@/federation'
const routes: Record<string, RemoteName> = { '/': 'browse', '/search': 'search' }
const active = matchRoute(useLocation().pathname, routes)
return (
<>
<Remote name="player" props={{ compact: true }} /> {/* always mounted */}
{active && <Remote name={active} />} {/* swaps with the url */}
<a href="https://github.com/bkincz/spool/blob/HEAD/search" onMouseEnter={() => preloadRemote('search')}>Search</a>
</>
)
To point a running host at a different build of one remote, set an override from the browser console. It is stored in localStorage, so it only affects you:
import { setRemoteOverride, listRemoteOverrides } from '@/federation'
setRemoteOverride('dashboard', 'https://dashboard-pr-42.example.com/mf-manifest.json')
setRemoteOverride('dashboard', null) // back to the build's url
Overrides are on in spool dev and spool preview. Production builds ignore them unless spool.json has "overrides": true.
Extras picked together at create time compose. With the state addon every remote renders a working counter and the host shows the live count, and the Playwright spec gains a test proving a remote's click updates the host. spool addon never rewrites components you may have edited, and --only limits it to some apps.
Deploying
Every app builds to a plain static site. Give apps a deploy command in spool.json, deploy the remotes, set each remote's url to its deployed mf-manifest.json, then rebuild and deploy the host:
spool build
spool deploy --env production
Before shipping, spool preview serves the built apps together on localhost, so you can click through the exact artifacts you are about to deploy.
A host resolves each remote in this order:
SPOOL_REMOTE_<NAME>env var (dev and build)- the remote's
urlsentry for--env/SPOOL_ENV(production builds only) - the remote's
urlinspool.json(production builds only) http://localhost:<port>/mf-manifest.json
For staging and friends, give a remote per-environment urls and build with spool build --env staging. spool deploy --env staging passes the name to your deploy commands as SPOOL_ENV, and spool doctor --remote --env staging probes those urls.
Records, promote, rollback
spool deploy --env <env> writes what it shipped to .spool/deploys.json: the commit, the url, the time, and the shared dep versions the app was built with. Commit that file. Before each deploy spool compares the app's shared versions with what the other apps last deployed to that environment and refuses when one would load a version another cannot accept, naming both.
spool promote --env production dashboard@3f9c2a1 # re-run a recorded deploy elsewhere
spool rollback --env production dashboard # re-run the one before the latest
Both need the working tree checked out at that commit, and say so if it is not.
Headers
Every app ships a generated public/_headers. Cloudflare Pages and Netlify read it as-is; on Vercel set the same headers in vercel.json.
X-Content-Type-Options: nosniffandContent-Security-Policy: frame-ancestors 'self'on every pageCache-Control: no-cacheonmf-manifest.jsonandremoteEntry.js,immutableon hashed assets- open CORS on those three paths for remotes, since hosts fetch them cross-origin
A host that must be framed by a partner sets frameAncestors: ["https://partner.example"]. When an edge layer in front of the app decides that per request, set frameAncestors: ["edge"] and spool writes no CSP for it, since two frame-ancestors headers intersect and would block the frame. Anything else goes in apps.<name>.headers. The same headers apply to dev and preview, so what you test is what you ship.
CI
spool ci writes a check workflow that runs whichever of doctor, type-check, lint, test, and build your root package.json has, plus a path-filtered deploy workflow per app with a deploy command. Deploy jobs wait for the check job, run with read-only permissions, and trigger on the app's folder, the workspace packages, and the workspace files. --pin resolves every action to a commit sha. Example deploy commands: wrangler pages deploy dist --project-name=<p>, netlify deploy --prod --dir=dist, vercel deploy dist --prod, aws s3 sync dist s3://<bucket> --delete.
Upgrading to 4
Run spool upgrade in every host, nested hosts too, then spool doctor. Two changes may need edits in your own code:
<Remote name>andpreloadRemotetake aRemoteNamenow, not just any string. Type route tables that hold remote names asRecord<string, RemoteName>, imported from@/federation, andmatchRoutewill hand back the same type.remotesno longer has a string index. If a name only arrives at runtime, check it withisRemoteName(name)first.- React's
<Remote>only handles load failures now. If a remote throws while rendering, the error goes to your app's error boundaries, and neitheronErrornor the sentry addon reports it from<Remote>. If the host doesn't have an error boundary yet, add one.
The full list is in the changelog.
Upgrading to 3
Run spool upgrade, then spool doctor. Generated files you edited are offered rather than replaced. Production builds now minify, every app gets a _headers, and <Remote> accepts props. The full list is in the changelog.
License
MIT
Related Packages
Scaffold React, Next.js, Vue, Nuxt, and SvelteKit projects with Feature-Sliced D...
Seamlessly integrate svelte with vue, react with vite, react with cra and next.j...
Version History
| Version | Released | License |
|---|---|---|
| 4.0.0 | MIT | |
| 3.1.1 | MIT | |
| 3.1.0 | MIT | |
| 3.0.1 | MIT | |
| 3.0.0 | MIT | |
| 2.7.2 | MIT | |
| 2.7.1 | MIT | |
| 2.7.0 | MIT | |
| 2.6.2 | MIT | |
| 2.6.1 | MIT | |
| 2.6.0 | MIT | |
| 2.5.0 | MIT | |
| 2.4.0 | MIT | |
| 2.3.3 | MIT | |
| 2.3.2 | MIT | |
| 2.3.1 | MIT | |
| 2.3.0 | MIT | |
| 2.2.0 | MIT | |
| 2.1.1 | MIT | |
| 2.1.0 | MIT | |
| 2.0.0 | MIT | |
| 1.4.0 | MIT | |
| 1.3.0 | MIT | |
| 1.2.0 | MIT | |
| 1.1.0 | MIT | |
| 1.0.1 | MIT | |
| 1.0.0 | MIT |