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.

src/pages/dashboard/_route.tsx
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.

src/pages/dashboard/index.tsx
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:

PropertyTypeDescription
requestRequestthe incoming Request — direct value
paramsTParamsvalidated params from createRoute({ params }) — direct value
queryTQueryvalidated query values from createRoute({ query }) — direct value
set, headers, cookie, redirectserver helpers — direct values
parent data fieldsPromise<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.

Typed Query Schemas

query belongs to createRoute(), not route.page().

src/pages/search/_route.tsx
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()),
  }),
})
src/pages/search/index.tsx
import { route } from "./_route"

export default route.page({
  loader: async ({ query }) => {
    const posts = await db.getPosts(query)
    return { posts }
  },
  component: ({ posts }) => <PostList posts={posts} />,
})

Typed Params

Params are also declared on createRoute().

tsx
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

src/pages/dashboard/posts/_route.tsx
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:

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

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

tsx
export const route = createRoute({
  loader: async ({ request, redirect }) => {
    const user = await getSession(request)
    if (!user) {
      return redirect("/login", 302)
    }

    return { user }
  },
})

Comments