Rendering Modes
Every layout and page chooses its rendering mode explicitly in config(). The mode controls when its loader runs and which request APIs it can use.
| Mode | Public render | Best fit |
|---|---|---|
ssr | Every request | Personalized or request-time pages; progressive streaming |
ssg | Pre-rendered and cached without a time limit | Stable public content |
isr | Cached, then regenerated after revalidate seconds | Public content that can be briefly stale |
Modes can mix. SSR loaders run per request with Elysia context; SSG and ISR loaders use public context and can be cached by segment. An SSR page or ancestor loader makes the HTML private, while public loader data remains reusable. Otherwise, ISR pages or loaders set HTML revalidation to the shortest interval; all-SSG chains produce SSG HTML.
SSR
import { defineRoute } from "@teyik0/furin";
export const route = defineRoute("/dashboard")
.config({ mode: "ssr" })
.loader(async ({ request }) => ({ user: await getSession(request) }))
.page(({ user }) => <Dashboard user={user} />);
SSR returns Cache-Control: no-store, no-cache, must-revalidate. Its loader() can read the request and stream slow fields with defer(). An SSR page cannot declare requestLoader().
SSG
import { defineRoute } from "@teyik0/furin";
export const route = defineRoute("/features")
.config({ mode: "ssg" })
.loader(async () => ({ features: await getFeatures() }))
.page(({ features }) => <FeatureList items={features} />);
The Bun server stores SSG HTML and route data in the build snapshot or warms them at startup. A static build writes them as files. Public SSG loaders cannot read cookies, headers, the incoming request, or mutate response state.
Dynamic SSG routes enumerate their buildable paths with staticParams():
import { defineRoute } from "@teyik0/furin";
import { t } from "elysia";
export const route = defineRoute("/blog/[slug]")
.config({
mode: "ssg",
params: t.Object({ slug: t.String() }),
})
.staticParams(async () => (await getPublishedSlugs()).map((slug) => ({ slug })))
.loader(async ({ params }) => ({ post: await getPost(params.slug) }))
.page(({ post }) => <Post value={post} />);
A pure static export requires staticParams() for dynamic SSG routes. The Bun server can still render an uncached concrete SSG path on demand.
staticParams() is a build-time builder stage, so it must appear directly after config() and before requestLoader() or loader(). On nested routes it receives the params already produced by ancestor staticParams() stages plus the parent loader fields as typed promises. Parent loaders stay lazy: Furin runs them only when the callback reads one of those fields, and repeated field reads share one execution for the current branch.
export const route = defineRoute("/shop/[category]/[product]")
.config({
mode: "ssg",
params: t.Object({ category: t.String(), product: t.String() }),
})
.staticParams(async ({ params, products }) => {
const categoryProducts = (await products).filter(
(product) => product.category === params.category
);
return categoryProducts.map(({ slug }) => ({ product: slug }));
})
.loader(async ({ params }) => ({
product: await getProduct(params.category, params.product),
}))
.page(({ product }) => <Product value={product} />);
ISR
import { defineRoute } from "@teyik0/furin";
import { t } from "elysia";
export const route = defineRoute("/products/[id]")
.config({
mode: "isr",
params: t.Object({ id: t.String() }),
revalidate: 60,
})
.loader(async ({ params }) => ({ product: await getProduct(params.id) }))
.page(({ product }) => <Product value={product} />);
The first request renders and stores a healthy response. After 60 seconds, the next request may receive the stale entry while one background regeneration starts. Concurrent stale requests share that regeneration, and a failed regeneration keeps the last good entry.
staticParams() is optional for ISR. When provided, it warms known paths; unknown paths remain renderable on demand.
Cached Public Data With Personalization
SSG and ISR can cache public data while rendering a request-private section on each request:
import { Await, defineRoute } from "@teyik0/furin";
import { Suspense } from "react";
export const route = defineRoute("/catalog")
.config({ mode: "isr", revalidate: 300 })
.requestLoader(({ cookies }) => ({
account: getAccount(cookies.get("session")),
}))
.loader(async () => ({ catalog: await getCatalog() }))
.page(({ account, catalog }) => (
<>
<Catalog value={catalog} />
<Suspense fallback={<AccountSkeleton />}>
<Await resolve={account}>
{(resolvedAccount) => <Account value={resolvedAccount} />}
</Await>
</Suspense>
</>
));
Furin caches the public loader result, not the personalized document. The final response is private, no-store. This pattern requires a Bun server and is rejected by pure static export.
Composite Server Markup
React server-component transport is a loader value, not another route mode. createCompositeComponent() encodes server-rendered markup and <CompositeComponent> fills declared interactive slots in the browser. Use it when a server-only component tree materially reduces client code; use ordinary React components for the common case.
Furin's composite API is intentionally narrower than the Next.js React Server Component model: it does not provide Server Actions, and mutations remain Elysia routes.
Function components with hooks can cross this boundary when their module starts with "use client". Furin sends a Flight reference, renders the component with React DOM during SSR, and loads its browser module for hydration. Imports used only by a loader are included in the client build too.
Client references support function exports and components wrapped in memo(), forwardRef(), or lazy(). Each exported wrapper keeps its own reference, even when several wrap the same function.
Client barrels retain their original exports' Flight identities, including when several applications share a component. CommonJS modules preserve Bun's default interop; their native RSC graph supports literal export assignments and object literals assigned to module.exports. Use an ESM wrapper for dynamically generated CommonJS exports.
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
The loader builds the server tree once and returns it as loader data. children is a slot: the server tree marks where it goes, and the layout fills it with live React elements when it renders <CompositeComponent>.
import { defineRootRoute, HeadContent, Scripts } from "@teyik0/furin";
import { Link } from "@teyik0/furin/link";
import { CompositeComponent, createCompositeComponent } from "@teyik0/furin/rsc";
import type { ReactNode } from "react";
import { Counter } from "../components/counter";
import { UserMenu } from "../components/user-menu";
export const route = defineRootRoute()
.config({ mode: "ssr" })
.loader(async () => ({
header: await createCompositeComponent<{ children: ReactNode }>(({ children }) => (
<header>
<Link to="/">Home</Link>
<Counter />
{children}
</header>
)),
}))
.layout(({ children, header }) => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<CompositeComponent src={header}>
<UserMenu />
</CompositeComponent>
{children}
<Scripts />
</body>
</html>
));
header is typed from the loader, so <CompositeComponent> checks the slots it receives against the props declared on createCompositeComponent(). UserMenu here is an ordinary component of the layout: it is rendered in the slot, not sent through Flight.
Furin's Link already declares this client boundary. It keeps its router context and SPA navigation when rendered through Flight. Props passed from a server tree to a client component must be serializable by React; event handlers belong inside the client component.
Content Security Policy
Install furinCsp() before Furin routes. Its policy callback receives a fresh nonce for live SSR and undefined for cached SSG/ISR HTML. Furin adds the SSR nonce to its own scripts, React's streaming scripts, and scripts and styles declared through head(). The callback lets the application choose the rest of the policy.
import { furin, furinCsp } from "@teyik0/furin";
import { Elysia } from "elysia";
export const app = new Elysia()
.use(furinCsp({
policy: (nonce) => [
"default-src 'self'",
`script-src 'self' ${nonce ? `'nonce-${nonce}'` : "'unsafe-inline'"}`,
`style-src 'self' ${nonce ? `'nonce-${nonce}'` : "'unsafe-inline'"}`,
"object-src 'none'",
"base-uri 'self'",
].join("; "),
}))
.use(await furin({ pagesDir: "./src/pages" }));
export const route = defineRoute("/account")
.config({ mode: "ssr" })
.head(() => ({ scripts: [{ children: "window.accountReady = true" }] }))
.page(() => <main>Account</main>);
The sample keeps inline scripts working on cached pages with 'unsafe-inline'; that branch of the policy is weaker than the SSR nonce branch. A nonce changes for every request, so it cannot match scripts in reusable HTML. This is the same cache tradeoff documented by Next.js. For a stronger policy on cached pages, move their executable inline scripts to external files or use stable hashes and serve the corresponding header with the cached HTML. That requires a separate review of Furin's cached transport scripts. Pure static exports do not run the Elysia plugin: configure the CSP header at the static host.
Inline scripts or styles written directly in a custom root document do not automatically receive a nonce. Move them into head() or provide your own nonce handling. furinCsp() is opt-in; other directives, third-party hosts, and reporting endpoints remain application choices. Set reportOnly: true to emit Content-Security-Policy-Report-Only while checking a new policy.
Continue with Caching for headers and on-demand invalidation.