Multi-Instance & Micro-Frontends
A furin() call returns a standard Elysia plugin — and you can mount several of them in one server, each under its own prefix. Every mounted app is fully isolated: its own pages tree, client bundle, build ID, caches, sync stream, and 404 page.
import { Elysia } from "elysia"
import { furin } from "@teyik0/furin"
import { api } from "./api"
const app = new Elysia()
.use(api)
.use(await furin({ pagesDir: "./src/pages" })) // → /
.use(await furin({ pagesDir: "./src/admin", prefix: "/admin" })) // → /admin/*
.listen(3000)
src/
pages/ ← root app (prefix "")
root.tsx
index.tsx → /
blog/[slug].tsx → /blog/:slug
admin/ ← admin app (prefix "/admin")
root.tsx ← its OWN layout, error and not-found boundaries
index.tsx → /admin
users.tsx → /admin/users
server.ts
Prefixes
prefixdefaults to""(root). It must start with/and have no trailing slash (/admin,/admin/v2).- Everything the app serves lives under it: pages,
/_furin/data,/_furin/sync, optional/_furin/ingestbrowser-log intake,/_client/*assets,/public/*. - Mounting two different apps on the same prefix throws at startup — prefixes are the collision guard.
Write pages as if you were at the root
Inside a mounted app, paths are logical — the prefix is invisible:
<Link to="/users">Users</Link>
// renders <a href="/admin/users">, SPA-fetches /admin/_furin/data?path=/users
Each app's client bundle is built with its own basePath, so Link, prefetching, SPA navigation, the sync stream, and browser logging all resolve under the right mount automatically. Moving an app from /admin to /back-office is a one-line change in server.ts — pages don't change.
To link across apps, use a plain absolute anchor (<a href="/admin">): the apps are separate bundles. Furin leaves the anchor to the browser for a full-document navigation, even when the current app declares a catch-all route.
Isolation semantics
| Concern | Behaviour |
|---|---|
| Caches (SSG/ISR/dev loaders) | Per instance — same logical path in two apps never collides |
revalidatePath(path) | Invalidates that logical path in every mounted app (shared-data semantics) |
revalidateTag(tag) | Cross-app by design — a mutation on a shared API invalidates pages in any app using the tag |
| Sync stream | Mounted at {prefix}/_furin/sync; adapter identity and namespace control journal isolation |
x-furin-build-id | Each app reports its own build ID |
| 404 | Root app keeps the historical global handler (a parent .onError registered before .use(furin) still wins); prefixed apps render their own not-found via an instance catch-all |
Building
furin build statically scans server.ts and builds every detected furin({ pagesDir, prefix }) call — one client bundle per app (client/, client-admin/, …) and a single server artifact (disk server.js or --compile binary, embed included).
If your mounts are dynamic (variables, imported factories), declare them explicitly instead:
import { defineConfig } from "@teyik0/furin/config"
export default defineConfig({
apps: [
{ pagesDir: "./src/pages" },
{ pagesDir: "./src/admin", prefix: "/admin" },
],
})
--target static exports the root-mounted app only.
Packaged apps (micro-frontends)
An app can be developed in its own package and published as a prebuilt Elysia plugin:
# in the admin package
furin build --target package --pagesDir ./src/pages --prefix /admin
This emits .furin/build/package/:
client/— content-hashed chunks + SSR template, built withbasePath: "/admin"register.js— a side-effect module that registers the app's compile context. Page modules are bundled in; everything from node_modules (including@teyik0/furin) stays external, so the host and the package share one furin runtimeindex.js/index.d.ts— acreateFurinApp()factory
Ship that directory in your package (e.g. under an ./furin export), then compose in the host:
import { createFurinApp as adminApp } from "@org/admin/furin"
new Elysia()
.use(await furin({ pagesDir: "./src/pages" }))
.use(await adminApp())
.listen(3000)
In a monorepo the factory's baked pagesDir still points at real sources, so bun --hot dev keeps working (live scan + HMR). A published package without sources is production-only — its compile context is resolved by prefix.
Current limitations
- In multi-instance dev,
furin-env.d.ts(typed links) is generated by the root app only. --compile embedof a host does not re-embed a packaged app's client assets — packaged apps serve them from disk (clientDir). Use disk-mode (server.js) hosts, or mount packaged apps behind a proxy for single-binary deploys.- Deploying apps as separate processes behind a reverse proxy also works: each app is built standalone with its prefix, and the gateway routes
/adminto it — no code changes.