Route Loaders

A loader is the data analogue of an API route: a plain server module sibling to the page.* it feeds. It never ships to the browser, so database drivers and secrets are safe to import.

app/todos/page.jsx        → the page
app/todos/loader.js       → its loader
app/items/[id]/loader.ts  → dynamic params work like pages
JavaScript
// app/todos/loader.js
export default async function loader({ params, query, request, locale }) {
  return { items: await db.todos.list() }; // JSON-serializable data
}

The page reads the reactive router.data, exactly like router.params:

JSX
// app/todos/page.jsx
import { router } from "@opentf/afw";

export default function Todos() {
  return (
    <ul>
      {(router.data ? router.data.items : []).map((t) => (
        <li key={t.id}>{t.title}</li>
      ))}
    </ul>
  );
}

Layout loaders

A layout.loader.js next to a layout.jsx loads data for every page beneath that layout.

TEXT
app/dashboard/
├─ layout.jsx
├─ layout.loader.js   # data for the dashboard shell and every page in it
├─ page.jsx
├─ loader.js          # data for this page only
└─ settings/page.jsx  # no loader of its own: still gets the layout's data
JavaScript
// app/dashboard/layout.loader.js
export default async function loader({ locals }) {
  return { user: await db.users.get(locals.userId) };
}
JSX
// app/dashboard/layout.jsx
import { router } from "@opentf/afw";

export default function DashboardLayout({ children }) {
  return (
    <div class="shell">
      <aside>{router.data?.user.name}</aside>
      <main>{children}</main>
    </div>
  );
}
  • One router.data. A page's loaders merge into one object: each layout loader from the root down, then the page's own on top. A page can read router.data.user too.

  • In parallel. All of a page's loaders run at once, and the browser makes one __data.json request for them.

  • Objects to merge. When more than one loader feeds a page, each must return an object. A page with a single loader can return any JSON value.

  • Same context. A layout loader gets params (all of the page's), query, request, locals and locale. It can call notFound(), throw a Response, or call a server function directly.

  • Every navigation. Layout loaders run again on each navigation to a page beneath them.

  • Needs its layout. A layout.loader.* without a layout.* in its folder is a build error.

  • A failed layout loader shows the error.jsx above that layout.

The loader context

FieldWhat it is
paramsRoute params from [param] / [...rest] segments, percent-decoded (catch-alls as arrays).
queryParsed query-string object, one value per name (a repeated name keeps its last value; use new URL(request.url).searchParams.getAll(name) for all). Empty at SSG time.
requestThe live Request when supplied by a request server; undefined at SSG prerender.
localeThe active locale (path-prefix i18n), or null.
localsPer-request bag from middleware (context.locals); {} when no middleware runs (e.g. SSG prerender).

Where it runs

  • Static builds (esdev build) — the project's prerender script runs loaders at build time: the data is baked into the prerendered HTML and written as a static <path>/__data.json file next to each page, so client-side navigation works on a purely static host — no server needed.

  • Request servers — the generated @afw/server handler runs loaders per request before rendering and serves the same data endpoint for client navigation. With Vite, vite build ships it in dist/server; with esdev, the app's server target imports it.

On first paint the data arrives inline with the server HTML (no extra request); on SPA navigation the router fetches <path>/__data.json before committing the navigation, so the page never renders with missing data and stale responses from superseded navigations are discarded. __data.json is a reserved filename.

Register loaders in the browser entry

Pass the patterns of pages with loaders to mountApp so the router knows which data endpoints to fetch. @afw/routes discovers them from the loader.* files:

JavaScriptentry.js
import { mountApp } from "@opentf/afw";
import { guard, loaderRoutes, pages } from "@afw/routes";

mountApp({ pages, guard, loaders: loaderRoutes, target: document.getElementById("app") });
  • loaderRoutes lists route patterns only ("/todos", "/items/[id]"); the loader modules never reach the browser.

Current limits

  • A query-dependent loader needs a request server — static __data.json files are rendered with an empty query.

  • Streaming is future work (see the roadmap). For writes, use server functions.

  • A loader.* without a sibling page.* is a build error.

  • Dynamic routes still need getStaticPaths on the page to prerender.

Last updated on
Edit this page