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// 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:
// 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.
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// app/dashboard/layout.loader.js
export default async function loader({ locals }) {
return { user: await db.users.get(locals.userId) };
}// 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 readrouter.data.usertoo.In parallel. All of a page's loaders run at once, and the browser makes one
__data.jsonrequest 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,localsandlocale. It can callnotFound(), throw aResponse, 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 alayout.*in its folder is a build error.A failed layout loader shows the
error.jsxabove that layout.
The loader context
| Field | What it is |
|---|---|
params | Route params from [param] / [...rest] segments, percent-decoded (catch-alls as arrays). |
query | Parsed 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. |
request | The live Request when supplied by a request server; undefined at SSG prerender. |
locale | The active locale (path-prefix i18n), or null. |
locals | Per-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.jsonfile next to each page, so client-side navigation works on a purely static host — no server needed.Request servers — the generated
@afw/serverhandler runs loaders per request before rendering and serves the same data endpoint for client navigation. With Vite,vite buildships it indist/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:
import { mountApp } from "@opentf/afw";
import { guard, loaderRoutes, pages } from "@afw/routes";
mountApp({ pages, guard, loaders: loaderRoutes, target: document.getElementById("app") });loaderRouteslists 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.jsonfiles are rendered with an empty query.Streaming is future work (see the roadmap). For writes, use server functions.
A
loader.*without a siblingpage.*is a build error.Dynamic routes still need
getStaticPathson the page to prerender.
Related
Loader Errors & Redirects:
notFound(), thrownResponses and failed fetches.resource(): data the browser fetches itself.