Data Loading
Furin loaders run on the server before rendering. Route loaders live on createRoute(), and page loaders live on route.page().
Route Loader
Use createRoute() when the data should feed a layout or be shared with child pages.
import { createRoute } from "@teyik0/furin/client"
import { route as rootRoute } from "../root"
export const route = createRoute({
parent: rootRoute,
loader: async ({ request }) => {
const user = await getSession(request)
return { user }
},
layout: ({ children, user }) => (
<div>
<DashboardNav user={user} />
{children}
</div>
),
})
Page Loader
Use route.page() when the data is specific to one page.
import { route } from "./_route"
export default route.page({
loader: async ({ user }) => {
// user comes from the parent route loader — await it to access its value
const { id } = await user
const stats = await getDashboardStats(id)
return { stats }
},
component: ({ stats, user }) => <Dashboard user={user} stats={stats} />,
})
Loader Context
Loaders receive a context object with:
| Property | Type | Description |
|---|---|---|
request | Request | the incoming Request — direct value |
params | TParams | validated params from createRoute({ params }) — direct value |
query | TQuery | validated query values from createRoute({ query }) — direct value |
set, headers, cookie, redirect | — | server helpers — direct values |
| parent data fields | Promise<T> | each field returned by an ancestor loader is an individually-awaitable Promise<T> |
Why parent fields are Promises
All loaders in the chain start immediately in parallel. A child loader receives parent data as a Promise<T> so it can:
- Opt in to waiting —
const u = await user— only blocking when it truly needs the value - Parallelize multiple parent fields —
const [u, o] = await Promise.all([user, org]) - Stay fully parallel — if it never awaits a parent field it runs concurrently with all others
The flat-merge of results into component props is unchanged — components always receive all data as plain values.
Deferred parent fields. When an ancestor wraps a field with
defer()(so the raw value is already aPromise<T>), the descendant still seesPromise<T>— notPromise<Promise<T>>. A singleawaitis sufficient. The Furin runtime uses standard JS Promise chaining, which auto-flattens, and the types mirror that behaviour.
Typed Query Schemas
query belongs to createRoute(), not route.page().
import { t } from "elysia"
import { createRoute } from "@teyik0/furin/client"
import { route as rootRoute } from "../root"
export const route = createRoute({
parent: rootRoute,
query: t.Object({
page: t.Optional(t.Number({ default: 1 })),
tag: t.Optional(t.String()),
}),
})
import { route } from "./_route"
export default route.page({
loader: async ({ query }) => {
const posts = await db.getPosts(query)
return { posts }
},
component: ({ posts }) => <PostList posts={posts} />,
})
Query schema merging across the route chain
When a route inherits from multiple _route.tsx files (layout chain), their query schemas are merged into a single search type for that route. The generated furin-env.d.ts reflects the union of all query fields in the chain.
export const route = createRoute({
parent: rootRoute,
query: t.Object({ sort: t.Optional(t.String()) }),
});
// src/pages/products/index.tsx
export default route.page({
query: t.Object({ page: t.Optional(t.Number({ default: 1 })) }),
// generated search type: { sort?: string; page: number }
});
Merging rules. All query schemas in the chain must be TypeBox objects. Mixing a non-TypeBox schema (e.g. a plain Zod object) with a TypeBox parent or child throws a clear error during the build process.
Default values in generated types
Query fields declared with a non-null default are emitted as required properties in furin-env.d.ts. The runtime still validates them as optional (falling back to the default), but the type system reflects the guaranteed presence after Elysia validation.
query: t.Object({
page: t.Optional(t.Number({ default: 1 })), // emitted as `page: number`
tag: t.Optional(t.String()), // emitted as `tag?: string`
})
Typed Params
Params are also declared on createRoute().
import { t } from "elysia"
import { createRoute } from "@teyik0/furin/client"
import { route as rootRoute } from "../root"
export const route = createRoute({
parent: rootRoute,
params: t.Object({
slug: t.String(),
}),
})
Awaiting Parent Data
When a child loader depends on data returned by an ancestor, it awaits the relevant field(s).
Simple await
import { createRoute } from "@teyik0/furin/client"
import { route as dashboardRoute } from "../_route"
export const route = createRoute({
parent: dashboardRoute,
loader: async ({ user }) => {
// user is Promise<{ id: string; name: string }>
const { id } = await user
return {
posts: await getPostsForUser(id),
}
},
})
Parallel await with Promise.all
When a loader needs multiple parent fields, await them in parallel:
export const route = createRoute({
parent: teamRoute,
loader: async ({ user, org }) => {
// Start both Promises simultaneously
const [{ id }, { plan }] = await Promise.all([user, org])
return { canEdit: plan === "pro" || id === "admin" }
},
})
No await — fully parallel
A loader that doesn't need parent data at all starts immediately with zero waiting:
export default route.page({
loader: async ({ params }) => ({
analytics: await getAnalytics(params.id), // runs fully in parallel
}),
})
Redirects And Status
You can return a redirect from a loader.
export const route = createRoute({
loader: async ({ request, redirect }) => {
const user = await getSession(request)
if (!user) {
return redirect("/login", 302)
}
return { user }
},
})
Deferred Data
For slow data that should stream after the initial shell, see Deferred Data Streaming. defer() works in any loader — page (route.page({ loader })) and route/layout (createRoute({ loader })).