Server-side rendering
Fresh HTML streamed on every request, loaders run next to your Elysia handlers.
mode: "ssr"風鈴/React · Elysia · Bun
SSR, SSG & ISR per route with SPA navigation — on Elysia and Bun, fully typed end to end.
01 — The stack
Frontend and backend share a single Bun process. Elysia serves your pages as a plugin, React renders them — and your types cross every boundary.
02 — Types
Whatever .loader() returns is inferred straight into .page() — across layouts, through Elysia, without a codegen step.
import { defineRoute } from "@teyik0/furin"
export const route = defineRoute("/")
.config({ mode: "ssr" })
.loader(async () => ({
message: "Hello from Furin!",
renderedAt: Date.now(),
}))
.page(({ message, renderedAt }) => (
<h1>{message} · {renderedAt}</h1>
))03 — Rendering
Fresh HTML streamed on every request, loaders run next to your Elysia handlers.
mode: "ssr"Pre-rendered at build time. Ship to any CDN — this site is one.
mode: "ssg"Static speed, revalidated in the background on your schedule.
mode: "isr", revalidate: 60Every route hydrates into an SPA: <Link> fetches loader data, not documents.
<Link to="/docs" />04 — Sync
Give a GET an identity, say which identities a mutation changes, and describe the optimistic view. Furin does the plumbing: idempotent writes, invalidation and catch-up for every open tab.
// boards/index.ts
.get("/boards/:boardId", ({ params, problem }) => {
/* … getBoardData(params.boardId) */
})
// cards/index.ts: PATCH /api/cards/c1 refreshes GET /api/cards/c1
.patch("/cards/:id", {
body: t.Object({ column: t.Optional(columnType), /* … */ }),
}, ({ params, body, problem, mutation, invalidate }) => mutation((tx) => {
/* … const card = updateCard(tx, params.id, body) */
invalidate("/api/boards/:boardId", { boardId: card.boardId });
return card;
}))
// index.ts
declare module "@teyik0/furin" { interface Register { app: typeof api } }// api.ts
treaty<Api>(window.location.origin).use(furinSync).api
// card-mutations.ts · moveBoardCard
return api.cards({ id: cardId }).patch(
{ column, position },
{
optimistic: (cache) =>
cache.update(api.boards({ boardId }).get.live(), (data) => ({
...data,
cards: moveCard(data.cards, cardId, column, before)?.nextCards ?? data.cards,
})),
}
);A GET is identified by its URL: /cards/:id and its params. Nothing to declare.
A mutation refreshes the GET at its own URL; invalidate("/boards/:boardId") names the others, atomic with mutation(tx).
treaty<Api>().use(furinSync): Eden types, an Idempotency-Key on every write, safe retries.
Loader props read from that GET follow it, with rollback on failure. No route target, no local state.
invalidates board · every tab on that read follows
05 — Ship
--compile embed bundles Bun, Elysia, React, your pages and every client asset into a single executable. Copy it to a server and run it.
$ furin build --target bun --compile embed[furin] Production client build complete[furin] Server binary: .furin/build/bun/server$ ls -lh .furin/build/bun/server-rwxr-xr-x 67M server$ ./serverFurin app running at http://localhost:3000
67 MB · task-manager example · Bun 1.4.2, macOS arm64
06 — Batteries
Automatic route generation from your file structure. Dynamic routes, nested layouts, and catch-all patterns.
SSR for dynamic content, SSG for static pages, ISR for the best of both worlds.
Complete TypeScript inference across the stack. No code generation required.
Compose your UI with powerful layout patterns. Data flows flat through the component tree.
React Fast Refresh for instant feedback during development. Powered by Bun's speed.
Build your backend alongside your frontend with Elysia's powerful API capabilities.
Compile to a standalone binary with Bun. "server" separates client assets; "embed" produces a single executable.
Pass Bun plugins (e.g. Tailwind, custom transforms) directly in furin.config.ts. They run before the internal client transform.