API Routes
Furin pages and Elysia API routes run in the same server process. There is no separate frontend dev server and no proxy layer required.
Define An API Plugin
import { Elysia, t } from "elysia"
export const api = new Elysia({ prefix: "/api" })
.get("/posts", async () => {
return db.getAllPosts()
})
.post(
"/posts",
async ({ body }) => {
return db.createPost(body)
},
{
body: t.Object({
title: t.String(),
content: t.String(),
}),
}
)
Mount It After Furin
import { Elysia } from "elysia"
import { furin } from "@teyik0/furin"
import { api } from "./api/index.ts"
const app = new Elysia()
.use(await furin({ pagesDir: "./src/pages", sync: true }))
.use(api)
.listen(3000)
export type App = typeof app
Elysia instance order matters. Mount Furin first so it can install the page runtime and the opinionated sync stream, then mount your regular API plugins.
Sync Mutations
Use furinSync() in the same Elysia plugin that declares mutation routes. Every non-GET/HEAD/OPTIONS route is synchronized by default: Furin requires an idempotency key, stores successful responses for replay, and publishes any resulting cache invalidations.
import { furinSync } from "@teyik0/furin"
import { Elysia, t } from "elysia"
export const api = new Elysia({ prefix: "/api" })
.use(furinSync())
.post(
"/posts",
async ({ body }) => {
return db.posts.create(body)
},
{
body: t.Object({ title: t.String() }),
sync: { invalidate: { tags: ["posts"] } },
}
)
There are no mutation names, collections, or client wrappers to configure. The API route is the stable contract, Eden Treaty keeps the client typed, and the optional sync object only declares cache invalidations.
Tags come from page and route metadata:
export default route.page({
mode: "isr",
revalidate: 60,
tags: ["posts"],
loader: async () => ({ posts: await db.posts.findMany() }),
component: PostsPage,
})
Path-based invalidation is also available when a mutation targets a known URL:
{
sync: { invalidate: { path: "/blog", type: "layout" } }
}
Every synced mutation must send an Idempotency-Key header. Furin enforces this at runtime so retries, double-clicks, and mobile clients all use the same contract:
await api.api.posts.post(
{ title },
{
headers: {
"Idempotency-Key": crypto.randomUUID(),
},
}
)
Repeating the same request with the same key returns the original response without executing the handler again. Reusing a key with a different payload returns 409. Disable synchronization for payments, uploads, streams, or other non-replayable effects:
.post("/payments", createPayment, { sync: false })
The sync stream endpoint is internal and opinionated: /_furin/sync. SSE only announces the latest cursor; the client recovers ordered invalidations through /_furin/sync/changes. Enabling sync: true in furin() injects this runtime automatically, so you do not need a React provider or application wrapper.
The built-in implementation is process-local and intentionally non-durable. Responses and change history are lost when the process restarts, and replicas do not share state. Offline persistence and a distributed adapter are not included yet.
For client-side mutations with a body, useSync() is the optional ergonomic layer. It keeps the Eden Treaty route as the source of truth, adds the mandatory Idempotency-Key, and gives you one place to attach optimistic updates:
import { useSync } from "@teyik0/furin/client"
const updateCard = useSync(api.api.cards({ cardId }).patch, {
optimistic: ({ input }) => patchCardLocally(cardId, input),
})
await updateCard({ title: "Renamed" })
Auto-Invalidate Only
If you only want response-header invalidation without live SSE broadcast or idempotency enforcement, use the lower-level furinInvalidate() macro:
import { furinInvalidate } from "@teyik0/furin"
import { Elysia, t } from "elysia"
export const api = new Elysia({ prefix: "/api" })
.use(furinInvalidate())
.post("/posts", ({ body }) => db.posts.create(body), {
body: t.Object({ title: t.String() }),
invalidate: { tags: ["posts"] },
})
Typed Client Access
If you export typeof app, you can use Eden Treaty on the client side for end-to-end typing.
import { treaty } from "@elysiajs/eden"
import type { App } from "./server.ts"
export const api = treaty<App>("localhost:3000")
import { api } from "@/client"
import { route } from "./_route"
export default route.page({
loader: async () => {
const { data } = await api.api.posts.get()
return { posts: data ?? [] }
},
component: ({ posts }) => <PostList posts={posts} />,
})
Middleware And Guards
Use normal Elysia plugins, guards, and lifecycle hooks for auth, logging, rate limiting, or anything else:
import { Elysia } from "elysia"
import { jwt } from "@elysiajs/jwt"
export const api = new Elysia({ prefix: "/api" })
.use(jwt({ name: "jwt", secret: process.env.JWT_SECRET! }))
.guard(
{
beforeHandle: async ({ cookie, jwt, set }) => {
const user = await jwt.verify(cookie.auth.value)
if (!user) {
set.status = 401
return "Unauthorized"
}
},
},
(app) => app.get("/me", ({ cookie, jwt }) => jwt.verify(cookie.auth.value))
)