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 reactive router.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
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>
  );
}

The loader context

FieldWhat it is
paramsRoute params from [param] / [...rest] segments, percent-decoded (catch-alls as arrays).
queryParsed query-string object. 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 — 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 serve command 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

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

JavaScriptentry.js
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.json files are rendered with an empty query.

  • Redirects from loaders, streaming, and actions are future work (see the roadmap).

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

  • Dynamic routes still need getStaticPaths on 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:

JSX
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 reactive source changes; { initial } seeds data.

  • Each run aborts the previous run's AbortController (the fetcher receives { signal }) and late, out-of-order resolutions are discarded.

  • A rejection sets error and keeps the last good data; the next success clears it. refetch() re-runs manually.

  • A source returning null/false pauses fetching (conditional resources).

  • During SSG/SSR nothing fetches and loading stays true, so the prerendered HTML shows your loading branch and hydration stays aligned.

Last updated on
Edit this page