Data Loading
Furin runs loaders on the server and passes their results into the matched layout and page tree. An SSR loader() may read request data. SSG and ISR loader() results are public and cacheable; use requestLoader() for request-private data beside those results.
Public Loaders
import { defineRoute } from "@teyik0/furin";
export const route = defineRoute("/blog")
.config({ mode: "ssr" })
.loader(async ({ log }) => {
const posts = await db.posts.findMany();
log.set({ postCount: posts.length });
return { posts };
})
.page(({ posts }) => <PostList posts={posts} />);
The return object is serialized into the initial document and route-data response, then exposed as flat props to page(), layout(), and head(). It must therefore contain values supported by Furin's Seroval transport. Keys beginning with __furin, plus children, key, params, path, query, and ref, are reserved.
Returning a plain promise inside a plain object does not opt into streaming: it is resolved as normal loader data. Use defer() explicitly when a field should stream.
Keeping Server Modules Out Of The Browser
Route files are also bundled for the browser. Furin removes loader(), requestLoader(), head(), staticParams() and config() (keeping only remountDeps) from that bundle, together with the module-level functions and imports only they use. Code the page or layout still references stays in the browser bundle, including its imports.
Mark modules that must never reach the browser, such as database clients or modules that read secrets, with furin/server-only. Any browser build that still imports one then fails instead of shipping it:
import "@teyik0/furin/server-only";
export const db = createClient(process.env.DATABASE_URL);
Reading Data In Descendant Components
Use getRouteApi() with a generated route pattern to read the active page's loader snapshot and validated params without passing them through every component:
import { getRouteApi } from "@teyik0/furin/client";
const postRoute = getRouteApi("/blog/:slug");
function PostToolbar() {
const { slug } = postRoute.useParams();
const { post } = postRoute.useLoaderData();
return <span>{slug}: {post.title}</span>;
}
Both hooks infer their types from the route definition. Params are the server-validated values, not raw URL strings. Loader data follows Furin's flat page data contract, including inherited loader fields; params, path, query, and internal transport metadata are not loader fields.
These hooks read the existing rendered snapshot in SSR and hydration. They do not call the loader again. Navigation, refreshes and optimistic projections update the snapshot. The pattern must match the active page: calling these hooks outside its rendered tree throws rather than returning unrelated data. To read search state, use useSearch.
Route definitions do not expose a route.useLoaderData() hook. getRouteApi() avoids importing the page module into its descendants.
Layout Data And Parallelism
Every layout and the page can have a loader. They start without waiting for the entire parent chain. A descendant loader receives each ancestor field as an individual promise; render callbacks receive the resolved fields as flat props:
import { defineRoute } from "@teyik0/furin";
export const route = defineRoute("/dashboard/settings")
.config({ mode: "ssr" })
.loader(async ({ organization, user }) => {
const [resolvedOrganization, resolvedUser] = await Promise.all([
organization,
user,
]);
return {
settings: await getSettings(resolvedOrganization.id, resolvedUser.id),
};
})
.page(({ settings, user }) => <Settings value={settings} user={user} />);
If the child does not read a parent field, its work stays parallel. Layout results are merged from root to leaf and the page result is applied last. Reusing a key overwrites the parent value; development logs a collision, so prefer unique field names.
Loader Context
For SSR routes, loader() receives the Elysia context plus typed route values and ancestor promises:
| Property | Description |
|---|---|
params | Validated values from the merged path schemas |
query | Validated values and applied defaults from merged query schemas |
request | Incoming Web Request |
cookie, headers, set, redirect | Elysia request/response helpers |
log | Request-scoped evlog logger |
| ancestor fields | Promise<T> values from layout loaders |
On SSG and ISR segments, loader() exposes only public route values (params, query, path, log, redirect, and public ancestor fields). Request-bound properties (request, cookie, headers, and set) are absent from its TypeScript context and throw if accessed dynamically, including in development. An SSR layout keeps its request context when it serves an SSG or ISR child; the child's public loader cannot access the layout's private fields, and the combined document is served without public caching.
Params And Query
import { defineRoute } from "@teyik0/furin";
import { t } from "elysia";
export const route = defineRoute("/blog/[slug]")
.config({
mode: "ssr",
params: t.Object({ slug: t.String() }),
query: t.Object({ preview: t.Optional(t.Boolean({ default: false })) }),
})
.loader(async ({ params, query }) => ({
post: await getPost(params.slug, { preview: query.preview }),
}))
.page(({ params, post, query }) => (
<Post post={post} preview={query.preview} slug={params.slug} />
));
The schemas are both runtime validators and TypeScript sources. Furin checks that dynamic filename segments and parameter schemas agree when generating the route graph.
Request-Private Data
requestLoader() receives a restricted request context. Each returned field is exposed as an individual promise to page() and layout():
Use it on cached SSG/ISR pages or shared layouts. An SSR page can read the request directly in loader() and stream fields with defer().
import { Await, defineRoute } from "@teyik0/furin";
import { Suspense } from "react";
export const route = defineRoute("/shop")
.config({ mode: "isr", revalidate: 60 })
.requestLoader(async ({ cookies, headers, log, params, query, request }) => ({
session: await readSession(cookies.get("session"), request.signal),
}))
.loader(async () => ({ catalog: await getPublicCatalog() }))
.page(({ catalog, session }) => (
<>
<Catalog items={catalog} />
<Suspense fallback={<AccountSkeleton />}>
<Await resolve={session}>
{(resolvedSession) => <Account session={resolvedSession} />}
</Await>
</Suspense>
</>
));
The context exposes cookies.get(name), a read-only snapshot of headers, log, params, path, query, and the original request. Request loaders across the route chain run in parallel. Furin infers their field names from TypeScript at build time, so the return type must have a finite set of keys. Each field becomes a real promise prop and streams separately; a missing optional public prop remains undefined. SSR descendant loader() functions can also await fields from ancestor requestLoader() functions; SSG and ISR loaders cannot read them. If an async request loader waits for several values before returning its object, those fields start streaming only after that wait. Their fields use the same reserved names as public loaders. The context object is shallowly frozen; the request, params, and query values are not immutable. Return only data safe to send to the current browser: request-loader output is serialized into the HTML stream or route-data response, so never return session tokens or server secrets.
For SSG or ISR on a Bun server, Furin caches only the public loader result, renders request data per request, and marks the final document private, no-store. Pure static export rejects every requestLoader() because no request runtime exists.
An SSR page cannot declare its own requestLoader(): use loader() for request data and defer() when some fields should stream independently. Its loader() and page component can inherit requestLoader() fields from a shared layout that also serves SSG or ISR pages. The SSR loader receives each field as a promise and awaits only the fields it needs.
Redirects, Missing Data, And Headers
- Use Elysia's redirect response from a loader for navigation redirects.
- Call
notFound()when the URL is valid but the resource is missing. - Set response headers through the Elysia context on SSR routes.
- Throwing from any public loader selects the nearest
error.tsx; rejecting a request-loader field is observed by React's Suspense/error boundaries.
For progressive public data, continue with Deferred Data.