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.

src/server.ts
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)
text
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

  • prefix defaults 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/ingest browser-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:

src/admin/index.tsx
<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

ConcernBehaviour
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 streamMounted at {prefix}/_furin/sync; adapter identity and namespace control journal isolation
x-furin-build-idEach app reports its own build ID
404Root 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:

furin.config.ts
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:

bash
# 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 with basePath: "/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 runtime
  • index.js / index.d.ts — a createFurinApp() factory

Ship that directory in your package (e.g. under an ./furin export), then compose in the host:

ts
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 embed of 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 /admin to it — no code changes.

Comments