Data Loading
Co-locate a server loader with a page; its data is rendered on the server and hydrated on the client: no waterfalls, no client fetch on first paint.
A loader runs on the server before a page renders. What awaits it, renders the page with the resolved data in scope, and serializes that data into the HTML so the client hydrates without re-fetching. This is the data path for SSR and ISR pages.
A loader only runs where something on the server runs it: a page reached through a server entry built on what-framework/server, or one pre-rendered by the static export. A client-only SPA never calls it, and useLoaderData() returns undefined there. Write these pages with h(), the way the fullstack scaffold does. what-compiler lowers JSX into module-scope templates that call document.createElement at import time, so a compiled JSX page cannot be imported by a server at all (see Server Rendering).
The loader export
Export a function named loader from a page module. It receives at least { params, query, request } and returns data (sync or async).
// src/pages/blog/[slug].js
import { h, useLoaderData } from 'what-framework';
export const loader = async ({ params, query, request }) => {
const post = await db.posts.find(params.slug);
return { post };
};
export default function Post() {
const { post } = useLoaderData();
return h('article', {}, h('h1', {}, post.title), h('p', {}, post.body));
}
params: dynamic route segments (e.g.{ slug: 'hello' }for/blog/[slug]).query: parsed query string as an object.request: the standardRequest(read cookies/headers for auth). It is only there when the page is rendered per request. The static export andwhat generatecall loaders with{ params, query: {} }and no request, so guard it, and expect that an auth-dependent page cannot be pre-rendered.csrfToken: a fourth field, present only sometimes. The request-time adapter adds it when it renders a page directly, which meansmode: 'server'or any route with no cache engine configured, and only while CSRF is on (the default). It is the per-visitor token, handed to the loader because the loader is the only per-request hook a page has before its component runs, so a server-rendered<Form>can put it in a hidden field for no-JS submits. It is deliberately absent on the cached branch, because cached HTML is shared between visitors and must never carry one visitor's token, and absent entirely undercsrf: false. Treat it as optional and never assume the object has exactly three keys.
Runs before render
The loader resolves outside the synchronous render, so the value handed to the component is plain data, never a promise. That keeps render fast and concurrency-safe.
useLoaderData
useLoaderData() returns the current page's loader data. It is isomorphic: on the server it reads the render-scoped context; on the client it reads the hydration payload (<script id="__what_data">). The branch is decided by whether a server render is in progress, not by whether a document exists, so a server render under a DOM shim (jsdom or happy-dom in a test) still returns that request's data. It is not a hook-slot consumer, so you can call it anywhere in a component.
import { useLoaderData } from 'what-framework';
function Post() {
const { post } = useLoaderData();
// ...
}
The server render paths do also hand the page component a loaderData prop, but no client path supplies it, so a component that reads the prop instead renders its empty branch on hydration and reports a hydration mismatch. Read the data with useLoaderData().
One API writes that payload: renderDocument(pageModule, reqCtx, options), the full-stack server entry. It runs the page's loader, renders through renderToStringAsync so createResource and <Suspense> resolve first, and returns a complete HTML document ending in <script id="__what_data"> holding { loaderData, resources, islandStores }, followed by a module script for options.clientEntry when you pass one. Every other entry point hands back pieces and emits no payload:
renderPagereturns{ body, head, loaderData }. This is the one to watch: likerenderDocumentit takes a page module and runs theloader, so it looks like the full-stack entry, but it stops at the body and writes nothing for the client to hydrate from.renderToStringAsyncreturns{ body, head, loaderData, resources, ctx }.renderToStringWithHeadreturns{ body, head }.renderToHydratableStringreturns body HTML with adata-hkkey on each component's root element.renderToStringandrenderToStreamreturn body HTML only, andgenerateStaticPagereturns a whole document without a payload in it.
Assemble a document from any of those and you have to serialize the payload yourself, or useLoaderData() finds nothing after hydration.
getStaticPaths
For dynamic routes that should be pre-rendered, export getStaticPaths. It returns the set of paths to build ahead of time.
export async function getStaticPaths() {
const posts = await db.posts.all();
return {
paths: posts.map((p) => ({ params: { slug: p.slug } })),
};
}
pathsis read at build time by the static export, which writes one HTML file per entry. A dynamic route that exports nogetStaticPathsis skipped, because there is nothing to enumerate.- A path that was not pre-built is rendered on demand at request time by the deploy adapter, and cached like any other page when a cache engine is configured. Nothing 404s for being absent from the list.
- A
fallbackfield is accepted and ignored today: nothing in the build or request path reads it, so a value there changes nothing.what-isrexports adecideFallbackhelper that implements the blocking / skeleton / 404 policies if you want to apply them in your own handler.
Because getStaticPaths is a function (it can't be JSON), it is a named export, never part of the page config object.
Loaders vs client fetching
Use a loader for data the page needs to render on the server (SEO content, the primary record). Use the client data-fetching hooks (useSWR, useQuery) for data that loads after hydration (user-specific widgets, polling dashboards). They compose: a loader seeds the first paint, hooks keep it live.
Loader data belongs to the document the server sent, not to the current route. The client router has no loader handling at all, and the hydration payload is parsed once and cached for the life of the page, so after a client-side navigate() useLoaderData() still returns the first page's data. A route entered by client navigation has to fetch what it needs with useSWR or useQuery, or be entered with a full page load.