API Routes
Furin pages and Elysia APIs run in the same application. There is no frontend proxy layer: define ordinary Elysia plugins, then compose them with the Furin plugin.
Define An API Plugin
import { Elysia, t } from "elysia";
const postsPlugin = new Elysia()
.get("/", () => db.posts.findMany())
.post(
"/",
{
body: t.Object({
title: t.String({ minLength: 1 }),
content: t.String(),
}),
},
({ body }) => db.posts.create(body)
);
export const api = new Elysia().group("/api/posts", (app) => app.use(postsPlugin));
export type Api = typeof api;
import { furin } from "@teyik0/furin";
import { api } from "./api";
export const app = api.use(await furin({ pagesDir: "./src/pages" }));
const port = Number(process.env.PORT ?? 3000);
if (import.meta.main) app.listen(port);
export type App = typeof app;
export default app;
Continue chaining Elysia instances so route types remain intact. Elysia plugins are isolated by default: an API plugin must explicitly .use() the auth, database, logging, macro, or model dependencies required by its own routes.
The exported api is also the root application extended by Furin. Kiana's AOT manifest owns one Elysia application per process, so an in-process Eden client must call this same instance instead of a second standalone app.
Mounted Request Handlers
Mount external Request/Response handlers under an API prefix before adding Furin:
const externalApi = new Elysia({ prefix: "/api" }).mount(externalHandler);
export const app = new Elysia()
.use(externalApi)
.use(await furin({ pagesDir: "./src/pages" }));
The root [...rest] page runs after Elysia has checked mounted API routes. An unprefixed .mount(handler) claims every URL, including page URLs. Elysia strips a mount prefix from the request URL passed to the handler, so configure the external handler's internal base path and callback URLs accordingly. Furin skips Elysia's build-time AOT step for applications with .mount() because that compiler does not support mounted handlers; the Bun server still compiles and serves them at runtime.
Middleware, Guards, And Scope
Install the JWT plugin before using this example:
bun add @elysia/jwt
import { Elysia } from "elysia";
import { jwt } from "@elysia/jwt";
export const accountApi = new Elysia({ prefix: "/api/account" })
.use(jwt({ name: "jwt", secret: process.env.JWT_SECRET! }))
.guard(
{
beforeHandle: async ({ cookie, jwt, problem }) => {
const user = await jwt.verify(cookie.auth?.value);
if (!user) return problem(401, { detail: "Authentication required" });
},
},
(app) => app.get("/me", ({ cookie, jwt }) => jwt.verify(cookie.auth?.value))
);
Plugin order matters when hooks or macros must exist before a route is declared. Keep related dependencies and routes inside one plugin where possible. Use Elysia's local, plugin, and global lifecycle scope intentionally; a global hook can affect pages and sibling plugins.
For a guarded page, use beforeHandle to return a redirect or denial. SPA navigation fetches the page URL itself with ?_furin=data, and Furin removes that marker before routing, so the same page route and Elysia guard run before loaders for both document requests and SPA navigation. The browser follows an HTTP redirect to another Furin page as a client navigation. A derive hook can add context such as user, but returning a redirect from derive does not stop Elysia's handler.
A parent Elysia guard also runs for public assets and framework endpoints. Use isFurinPageRequest(request, prefix) from @teyik0/furin when the guard should protect only registered pages in a mount. This covers both document requests and SPA navigation without listing Furin's internal URLs in your application.
Only use this guard as an access boundary for registered SSR page routes. Root catch-all pages and pages added at runtime in development can be rendered through Furin's 404 fallback without running the parent guard. SSG and ISR output can be served from a cache without reaching Elysia. Authorize sensitive loaders and API routes directly in those cases.
Cookie-Authenticated Mutations And CSRF
Furin does not add CSRF protection to arbitrary Elysia routes. For mutations authenticated with cookies, install an Elysia-compatible CSRF plugin on the API instance before declaring those routes, and verify its compatibility with the Elysia version used by your app. Elysia lists community CSRF plugins. Keep cross-origin webhooks and token-authenticated endpoints in separate plugins so their policies can differ. Authenticate and authorize each mutation; CSRF protection does not grant access. Use HttpOnly, Secure, and SameSite=Lax or Strict for session cookies, but do not rely on SameSite alone. Do not perform mutations with GET.
Typed Browser Client
Eden Treaty can derive a client from the exported Elysia type:
import { treaty } from "@elysia/eden";
import type { Api } from "../api";
export const api = treaty<Api>(window.location.origin);
For a module imported by both server loaders and browser components, use createIsomorphicFn() so loaders call Elysia in-process and the browser uses HTTP:
Keep the factory in one fluent chain, using a named import or direct namespace access such as Furin.createIsomorphicFn(). Constant aliases of the factory are supported; aliasing or destructuring the Furin namespace, or selecting its exports with a runtime key, is rejected so unresolved server branches cannot reach the browser bundle.
import { treaty } from "@elysia/eden";
import { createIsomorphicFn } from "@teyik0/furin";
import { api, type Api } from "../api";
export const getPostsApi = createIsomorphicFn()
.server(() => treaty(api))
.client(() => treaty<Api>(window.location.origin));
export const route = defineRoute("/posts")
.config({ mode: "ssr" })
.loader(async ({ request }) => {
const result = await getPostsApi().api.posts.index.get({
headers: request.headers,
});
if (result.error) throw result.error;
return { posts: result.data };
})
.page(({ posts }) => <PostList posts={posts} />);
Throw the Eden error wrapper so Furin can preserve a Problem Details response's HTTP status and public message in the nearest error boundary.
The server Treaty client contains only the hooks and dependencies composed into api; hooks on an unrelated instance are not inherited. Direct execution also does not automatically forward the incoming page request, so pass headers or cookies when identity matters.
Accelerating External Schemas
Kiana compiles TypeBox schemas such as t.Object() directly. When an API deliberately uses an external Standard Schema validator such as Zod, t.Accelerate() converts its JSON Schema representation into the TypeBox compiler path:
import { Elysia, t } from "elysia";
import { z } from "zod";
new Elysia().post(
"/api/posts",
{ body: t.Accelerate(z.object({ title: z.string().min(1) })) },
({ body }) => db.posts.create(body)
);
Do not wrap schemas already created with Elysia's t: they already use the optimized path. t.Accelerate() is still marked alpha in Elysia 2 beta, so keep its use explicit at external-schema boundaries.
API Problem Details
Use Kiana's problem() helper for expected HTTP failures. It emits an RFC 9457 body and the application/problem+json content type while preserving Eden's status-based inference:
new Elysia().get("/api/posts/:id", ({ params, problem }) => {
const post = db.posts.find(params.id);
return post ?? problem("Not Found", { detail: "Post not found" });
});
Invalidation After A Mutation
For same-response cache invalidation without idempotent replay or cross-client events, install furinInvalidate() before declaring the mutation:
import { furinInvalidate } from "@teyik0/furin";
import { Elysia, t } from "elysia";
export const postsApi = new Elysia({ prefix: "/api/posts" })
.use(furinInvalidate())
.post("/", { body: t.Object({ title: t.String() }) }, ({ body, invalidate }) => {
invalidate({ tags: ["posts"] });
return db.posts.create(body);
});
invalidate() accepts the same targets as in Furin Sync: a GET pattern and its params, { tags } or { path, type }. The mutation's own URL is always invalidated. Successful responses invalidate the origin cache and carry X-Furin-Revalidate, allowing the initiating tab to evict prefetched data and refresh an affected page.
If mutations can be retried, or other tabs and replicas must learn about invalidations, use the stronger Sync & Invalidations contract instead.
Ownership Recommendation
Keep APIs as independent Elysia plugins and let page loaders call a shared service layer or an in-process Eden client. Do not hide API behavior inside page files: explicit plugin boundaries preserve Elysia's inference, lifecycle scope, and testability.