0022: Data loaders — loader named export per route + layout
Status: Accepted Date: 2026-05-11
Context
ADRs 0019, 0020, and 0021 ship a self-describing route manifest with layout chains, error boundaries, and a root 404. The module shapes were left intentionally loose:
- Routes —
default export = (params) => view - Layouts —
default export = (children, params?) => view - Error boundaries —
default export = (error) => view
What's missing is a server-first data fetching story. Today
apps wire resource() inside the component to fetch data; that
runs at render time and produces the SSR two-pass cycle (render
once to register pending fetches, await, render again). For a
typical "load data, then render" route the boilerplate is the
same in every component.
The shipping ecosystem has converged on loaders — a named function exported alongside the route's view. The framework calls the loader on the server before render, threads the resolved data into the component, and avoids the two-pass cycle:
- Remix —
export const loader = async ({ request, params }) => data. Data is read in the component viauseLoaderData(). Layouts get their own loaders that run in parallel with the route's. - SvelteKit —
+page.tsexportsload({ fetch, params }). Layout-level loaders in+layout.ts. Data is the component'sdataprop. - Next App Router — async server components: the route's
default export IS an
async function Page({ params })that awaits its own data. No separate loader.
Purity already has the per-directory chain machinery (ADR 0020)
and the request-scoped getRequest() (ADR 0009). The right
Phase-1 step:
- Add a manifest signal — a per-entry
hasLoader: trueflag — so the consumer's loadStack knows which modules to await before rendering. - Document the loader signature without enforcing it. The consumer composer wires loader data into the component the way the app needs.
Decision
Add hasLoader?: true to both RouteEntry and LayoutEntry.
The Vite plugin scans each route's and layout's source for a
named loader export at manifest-build time. When found, the
flag is set; consumers iterate the manifest to know which modules
need their loader called before render.
// src/pages/users/[id].ts
import { getRequest } from '@purityjs/core';
import { html } from '@purityjs/core';
export async function loader({ params, signal }) {
const res = await fetch(`/api/users/${params.id}`, { signal });
return res.json();
}
export default function UserPage(params, data) {
return html`<h1>${() => data.name}</h1>`;
}
// src/pages/users/_layout.ts
export async function loader({ request, signal }) {
// Layouts get their own loaders — run in parallel with the route's.
const session = await fetch('/api/session', {
headers: request.headers,
signal,
});
return session.json();
}
export default function UsersLayout(children, params, data) {
return html`<aside>Logged in as ${() => data.name}</aside>
<main>${children()}</main>`;
}
The manifest entries (abbreviated):
export const routes = [
{
pattern: '/users/:id',
filePath: 'users/[id].ts',
importFn: () => import('/abs/pages/users/[id].ts'),
hasLoader: true,
layouts: [
{ filePath: 'users/_layout.ts', importFn: …, hasLoader: true },
],
},
];
Concretely:
- Convention: a route or layout module exports a named
loader. The plugin recognises:export const loader = ...export let loader = ...export var loader = ...export function loader(...) { ... }export async function loader(...) { ... }export { loader }andexport { something as loader }from any module- The same with TypeScript type annotations
(
export const loader: LoaderFn = ...)
- Detection is regex-based, intentionally simple. The plugin
reads each route + layout file's source once per manifest
build (cached across routes that share the same layout). No
parser dependency, no ReDoS — the patterns anchor at line
start with
\s*export\s+. False positives: a comment containingexport const loader = ...would match — apps that hit this rename the comment. hasLoader?: trueis optional / present-only. Omitted from entries without a loader so existing manifest consumers see no behavior change. Always literaltruewhen present — nofalsevalue (consistent with the layouts ADR's "absent ≡ none" rule).- Loader signature (documented, not enforced):
type LoaderContext = { request: Request; // from getRequest() (ADR 0009) params: Record<string, string>; // from matchRoute (ADR 0011) signal: AbortSignal; // for cancel-on-navigate }; type Loader<T = unknown> = (ctx: LoaderContext) => T | Promise<T>; - Component-data plumbing is user-land in Phase 1. The
consumer's loadStack pattern threads loader data into the
component however the app prefers — typically as a positional
argument (
(params, data) => viewfor routes;(children, params, data) => viewfor layouts). When apps converge on a shared pattern, a future ADR can ship aloaderData()context primitive. Not yet. - Error boundaries + 404 do not get loaders in Phase 1. Loaders inside error contexts are subtle (the error might itself come from a loader). Defer.
- Layout loaders run in parallel with the route's loader by
convention. The consumer's composer awaits a
Promise.allover every entry withhasLoader: true. Sequential dependencies between layout and route loaders are not modeled — apps that need them sequence inside one of the two loaders.
Explicit non-features
- No runtime context primitive (
loaderData()). Apps wire loader data into components manually for Phase 1. Shipping a context primitive in@purityjs/corewould lock the framework into one component-signature shape; Phase 1 stays neutral so the ecosystem chooses the convention. Add the primitive once enough apps converge on a shape. - No loader on error boundaries / 404 modules. A
_error.tsloader would have to run after the route's loader threw — awkward to design without solidifying the failure semantics. A_404.tsloader is more reasonable but less commonly needed. Both deferred. - No automatic loader-data revalidation. Consumers re-call
loaders on navigation (the existing route change). Per-resource
revalidation (Remix
revalidate(), NextrevalidatePath()) is a separate ADR — needs a cache + invalidation primitive. - No streaming loader data. Loaders return synchronously or
via Promise. Async iterators / streams in loader returns are
out of scope. Apps that need streaming use
suspense()(ADR 0006) inside the component. - No loader-input validation. Apps that want to validate
paramsagainst a schema do it inside the loader (using Zod / Valibot / hand-rolled). The framework doesn't inject a validator. - No build-time enforcement that the
loaderexport is a function. The regex detects the named export; if the value is a string at runtime the loader call throws at the consumer. Catching that at build time needs a real parser pass with type inference. Defer. - No file-naming alternative (
*.loader.ts,_loader.ts). Loaders are co-located with their views in the same file. Splitting them across files would double the number of imports per route and lose the shared module-scope state (helpers, type definitions). Matches Remix / SvelteKit; rejects Next App Router's "the route IS the loader" pattern as too entangled.
Consequences
Positive:
- Closes the boilerplate gap of "fetch data → render" routes.
One named export per file replaces the per-component
resource()await r.data()pattern for the common case.
- Manifest stays plain data —
hasLoader: trueis one optional field per entry. Serializable, walkable, no runtime dispatch in the plugin. - Composes with
getRequest()(ADR 0009) — the loader signature'srequestfield is the sameRequestthe SSR renderers thread through. Cookies / headers / auth all work uniformly. - Composes with
serverAction()(ADR 0012) — POST handlers stay in*.server.tsmodules; loaders are GET-side counterparts in the route module. Clear which is which. - Layouts get loaders for free (same flag, same semantics). No separate convention.
Negative:
- Regex-based detection has false positives + negatives at the
margins. A line
// export const loader = …inside a comment triggershasLoader: true; a loader assigned via re-export from a non-loader-named identifier (export { foo as loader }— detected, butimport { foo } from './x.ts'; const loader = foo; export { loader };— not detected) escapes detection. Documented; apps that need parser-grade accuracy can opt into a future ADR. - Component-data plumbing is user-land. Apps will converge slowly
on the right
(params, data)shape. Documented; consistent with ADRs 0020 + 0021. - Reading every route + layout source at manifest build time is one extra fs read per file. Cost is linear in the number of files; cached per-build. Negligible at typical scales.
- Manifest size grows by
hasLoader: true,per entry that has one. ~18 bytes per loader-having entry — tiny.
Neutral:
- One additional optional field per entry (
hasLoader?: true). Existing consumers readingpattern/filePath/importFn/layouts/errorBoundarykeep working unchanged. - The plugin's
loadhook now reads file contents in addition to listing names. Adds one fs call per route + layout file per manifest build (HMR-invalidated, so cheap). - No new exports from the package —
hasLoaderrides on the existingRouteEntry/LayoutEntrytypes.
Alternatives considered
Named export as the route default (Next App Router pattern).
The route module's default export IS an async function that
awaits its own data. Rejected: blurs the loader vs. view
distinction, and forces server-only code to live in the same
function as client-rendered markup. Server-only code stripping
(ADR 0018) becomes harder.
Loader in a sibling *.loader.ts file. Cleaner separation
of concerns. Rejected: doubles the file count per route, splits
shared helpers (params validation, type definitions) across two
modules, and needs an additional convention (which loader pairs
with which route?). Co-locating in the route module is the
ecosystem default.
Build-time AST parse to verify the loader is a function. Catches mistakes earlier. Rejected for Phase 1: needs a parser dependency (esbuild / oxc-parser) for what is currently a declarative flag. The runtime call site already throws on a non-function value with a clear stack. Add the parse later if real-world false positives become a problem.
Pass a loader-options bag to the plugin
(routes: { loaderExportName: 'load' }). Lets apps pick a
different name. Rejected: convention beats config. The shared
name across the ecosystem is loader; renaming forks app
codebases into incompatible flavors.
Implicit loader from the route's first-param shape. If the
route's default export is async (params) => …, treat it as
both loader and view. Rejected: ambiguous (was the function
async because it awaits internally, or because it pre-fetches
data?). Explicit loader export keeps the contract crisp.
Manifest emits the loader's source-detected key list ({ hasLoader: { request: true, params: false, signal: true } })
so the consumer knows what context fields the loader accesses.
Rejected: brittle to detect from source (especially with
destructuring rename). Apps that care about the context shape
inspect the loader at runtime.
Ship loaderData() accessor in @purityjs/core immediately.
A per-render context primitive that the route reads. Rejected
for Phase 1: locks one component-signature pattern in before
apps have used the manifest. Composer-passes-data-as-arg covers
the gap; the accessor can land in a follow-up once the right
shape is clear.