Data Fetching
Two primitives, one per side of the network:
Route loaders — a server-only
loader.{js,ts}file next to a page runs before the page renders (at build time, per request, or on navigation) and its result reaches the page as the reactiverouter.data.resource()— a client-side primitive that wraps a fetcher in signals:{ data, loading, error, refetch }, with abort and stale-response handling.
Rule of thumb: content the server should render (SEO, first paint) → a loader; per-user or interaction-driven data → resource().
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> ); }
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. 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 — an app's server entry can run loaders per request before rendering and serve the same data endpoint for client navigation. The compiler plugin does not create this server automatically. The retired
afw servecommand is unavailable; native fullstack starter integration is still pending.
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.
Not found & errors
import { notFound } from "@opentf/afw/server"; export default function loader({ params }) { const post = db.posts.find(params.id); if (!post) notFound(); // → the 404 page, with HTTP 404 return post; }
Any other thrown error is a 500. On the client, a failed data fetch is reported (phase: "data") and the navigation still commits with router.data undefined — pages should treat "no data" as a valid state.
Register loaders in the browser entry
Pass the patterns of pages with loaders to mountApp so the router knows which data endpoints to fetch. The native @afw/routes module exports pages and guard; it does not currently discover loader patterns for the client:
import { mountApp } from "@opentf/afw"; import { pages, guard } from "@afw/routes"; mountApp({ pages, guard, loaders: ["/todos", "/items/[id]"], target: document.getElementById("app"), });
Keep this list in sync with your loader files. The prerender script must also discover and run the loaders; a request server must serve their __data.json endpoints. Registering the client patterns alone does neither.
Current limits
Loaders are page-level (no layout loaders yet).
A query-dependent loader needs a request server — static
__data.jsonfiles are rendered with an empty query.Redirects from loaders, streaming, and actions are future work (see the roadmap).
A
loader.*without a siblingpage.*is a build error.Dynamic routes still need
getStaticPathson the page to prerender.
resource()
For client-side async data, resource() formalizes the fetch-into-signals pattern — components stay synchronous, and the view renders the resource's reactive states:
import { resource, router } from "@opentf/afw"; export default function UserProfile() { const user = resource( () => router.params.id, // reactive source — re-fetches when it changes async (id, { signal }) => { const response = await fetch(`/api/users/${encodeURIComponent(id)}`, { signal }); if (!response.ok) throw new Error(`Request failed: ${response.status}`); return response.json(); }, ); return ( <div> {user.error ? <p>Failed: {user.error.message}</p> : user.loading ? <p>Loading…</p> : user.data ? <h1>{user.data.name}</h1> : <p>No user selected.</p>} </div> ); }
resource(fetcher)fetches once;resource(source, fetcher)re-fetches when the reactivesourcechanges;{ initial }seedsdata.Each run aborts the previous run's
AbortController(the fetcher receives{ signal }) and late, out-of-order resolutions are discarded.A rejection sets
errorand keeps the last gooddata; the next success clears it.refetch()re-runs manually.A
sourcereturningnull/falsepauses fetching (conditional resources).During SSG/SSR nothing fetches and
loadingstaystrue, so the prerendered HTML shows your loading branch and hydration stays aligned.