Multi-Instance & Packaged Apps

One Elysia server can mount several Furin page trees. Each instance has a unique prefix and owns its route graph, client assets, document template, build ID, render caches, framework endpoints, and not-found behavior.

ts
import { Elysia } from "elysia";
import { furin } from "@teyik0/furin";

new Elysia()
  .use(await furin({ pagesDir: "./src/pages" }))
  .use(await furin({ pagesDir: "./src/admin", prefix: "/admin" }))
  .listen(3000);
text
src/pages/index.tsx       → /
src/pages/blog.tsx        → /blog
src/admin/index.tsx       → /admin
src/admin/users.tsx       → /admin/users

Prefix Rules

  • The root instance uses "" or /.
  • A non-root prefix starts with / and has no trailing slash after normalization.
  • Two instances cannot claim the same prefix.
  • Route ids start with the prefix, so each instance mounts its own pages directory. Mounting one pages directory under two prefixes of an application fails at startup, and furin build rejects it, naming the directory and both prefixes.
  • Prefixes that would produce the same client-directory slug are also rejected.
  • The prefix is the full physical path. The client bundle is built for it, as with a Next.js or TanStack Start basePath, so Furin does not adapt to a prefix added by a parent Elysia instance.
  • Each furin() call serves one application. Call it again for another application instead of reusing the plugin or a container that holds it.

Mount Furin on an Elysia instance without a prefix and repeat the parent path in prefix. Guards and hooks still apply from unprefixed parents:

ts
const admin = new Elysia()
  .onBeforeHandle(requireAdmin)
  .use(await furin({ pagesDir: "./src/admin", prefix: "/backoffice/admin" }));

new Elysia().use(admin).listen(3000);

Mounting furin({ prefix: "/admin" }) under new Elysia({ prefix: "/backoffice" }) fails at startup. The error shows the declared prefix and the path the app is served at, /backoffice/admin, which is the value to declare. Use the same prefix in furin.config.ts apps or furin build --prefix.

The most specific prefix owns a request whatever the mount order, so /admin/users belongs to the /admin instance even when the root instance is mounted first.

Everything an instance owns is physically prefixed: pages, /_furin/* endpoints, /_client/*, /public/*, and the favicon.

Inside the pages tree, URLs stay logical:

src/admin/index.tsx
<Link to="/users">Users</Link>

The admin bundle renders /admin/users and sends route-data requests below /admin. Keep page code independent of its deployment prefix.

Use a plain anchor for navigation between separate Furin instances:

tsx
<a href="/admin">Open admin</a>

That intentionally loads the other document and browser bundle.

Isolation And Shared Operations

ConcernBehavior
SSG, ISR, PPR, and dev loader cachesPer instance by default; public production artifacts can share one pageCache adapter
Build ID and document templatePer instance
Root/prefixed 404 handlingPer instance
revalidatePath()Applies the logical path across the instances mounted in the requesting application, or every live instance outside a request
revalidateTag()Finds tagged paths across instances, then evicts only from each owner
Sync journalDetermined by the configured adapter and namespace
CDN purge pathsConverted to physical prefixed URLs and sent to each instance's cachePurger
CDN purge tagsSent once to each distinct cacheTagPurger

Cross-instance path and tag invalidation is deliberate for applications sharing data. If two apps use the same logical path for unrelated content, prefer distinct tags and avoid broad path invalidation.

When several processes host the same set of mounts, reuse one page-cache adapter in each process and pass it to every mount. The prefix is part of Furin's cache identity, so / and /admin remain isolated even when they use the same Redis namespace:

ts
import { RedisClient } from "bun";
import { Elysia } from "elysia";
import { furin } from "@teyik0/furin";
import { redisPageCache } from "@teyik0/furin/cache/redis";

const redisUrl = process.env.REDIS_URL;
if (redisUrl === undefined) {
  throw new Error("REDIS_URL is required");
}

const pageCache = redisPageCache({
  client: new RedisClient(redisUrl),
  namespace: "backoffice:production",
});

new Elysia()
  .use(await furin({ pagesDir: "./src/pages", pageCache }))
  .use(await furin({ pagesDir: "./src/admin", prefix: "/admin", pageCache }));

Use separate namespaces only for deployments that must never invalidate each other, such as staging and production. Do not use a different namespace per replica or per build.

Build Discovery

Furin can statically detect literal mounts in server.ts. Declare dynamic mounts explicitly when values come from variables or factories:

furin.config.ts
import { defineConfig } from "@teyik0/furin/config";

export default defineConfig({
  apps: [
    { pagesDir: "./src/pages" },
    { pagesDir: "./src/admin", prefix: "/admin" },
  ],
});

The Bun target produces one client directory per app and one server artifact. Static export selects the root-mounted app only. The generated furin-env.d.ts declares the route files of every app, under ids that start with each prefix, but its RouteMap covers the root-mounted app only: cross-instance links are intentionally not one combined typed SPA route map.

Packaged Furin Apps

The package target builds one page tree as a publishable Elysia plugin:

sh
furin build --target package --pagesDir ./src/pages --prefix /admin

.furin/build/package contains:

  • client/: content-hashed browser assets and the document template;
  • public/: copied public assets when present;
  • register.js: bundled page modules and compile-context registration;
  • index.js and index.d.ts: the createFurinApp() factory.

Dependencies stay external so the host and packaged app share one Furin runtime.

ts
import { Elysia } from "elysia";
import { createFurinApp as createAdminApp } from "@org/admin/furin";
import { furin } from "@teyik0/furin";

new Elysia()
  .use(await furin({ pagesDir: "./src/pages" }))
  .use(await createAdminApp())
  .listen(3000);

The factory bakes in pagesDir, prefix, and clientDir; the host may still pass logger or sync options. In a monorepo with sources present it supports live development. A published artifact without page sources is production-only.

--compile embed on the host does not currently re-embed a packaged app's client directory. Prefer a disk-mode host when composing packaged apps into one process.

Comments