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

src/api/index.ts
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

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

ts
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:

tsx
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:

ts
{
  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:

tsx
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:

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

tsx
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:

ts
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.

src/client.ts
import { treaty } from "@elysiajs/eden"
import type { App } from "./server.ts"

export const api = treaty<App>("localhost:3000")
src/pages/blog/index.tsx
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:

ts
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))
  )

Comments