Data Fetcher Architecture
How Uniweb's data-fetching layer works end to end — dispatcher, default fetcher, cache keys, placeholder substitution, delivery paths, gotchas. For the "how to use it" view, see Data Sources.
Audience: Framework contributors, foundation authors writing custom fetchers, anyone debugging a fetch that didn't fire or cached wrong.
The dispatcher in one diagram
Every data fetch goes through the FetcherDispatcher — a small object on website.fetcher that decides which fetcher handles a given request. Selection is a name lookup, not a route walk:
┌──────────────────────────────────────────────────────────────────────┐
│ Request (from EntityStore or build prerender) │
│ { as, query?, path? | url?, transform?, method?, body?, │
│ scope?, where?, sort?, limit?, narrow?, dynamicContext? } │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ Runtime transport override (if set) │
│ Passed via initUniweb({ transport }) by a host that supplies all │
│ data itself (an editor's preview); handles every request. │
└──────────────────────────────────────────────────────────────────────┘
│ not set
▼
┌──────────────────────────────────────────────────────────────────────┐
│ Site-selected named transport │
│ site.yml fetcher.transports[request.as] │
│ → registry = primary foundation.transports ∪ extension transports│
│ (primary wins on collision; bad extensions skipped with warning) │
└──────────────────────────────────────────────────────────────────────┘
│ no match (or site didn't pick)
▼
┌──────────────────────────────────────────────────────────────────────┐
│ site.yml fetcher.transports.default (if set) │
│ Same registry lookup for every unclaimed `as`. │
└──────────────────────────────────────────────────────────────────────┘
│ not set (or name unregistered)
▼
┌──────────────────────────────────────────────────────────────────────┐
│ Framework default fetcher (terminal — always present) │
│ createDefaultFetcher({ basePath }) │
└──────────────────────────────────────────────────────────────────────┘The default fetcher is the terminal resolver. It always exists. Sites that declare no fetcher.transports (the common case — docs, marketing, starter templates) run every request through this path.
There is no match() predicate, no route-walking, no silent foundation-owned routing. The site always picks — and the pick is visible in site.yml, auditable. Foundations contribute named transports; extensions contribute by name too; the site selects per data key (as).
The default fetcher's role
Narrow on purpose. Its job is to cover a site reading its own records and public, keyless JSON endpoints, without the site needing a foundation written for it.
What the default handles, all of it read from the query a fetch names:
- The file the build compiles from a query (
/data/<query>.json), and the file it writes for each of its records, under the site's base path. - A host's live records service, when the host offers one — the questions a page asks, in one request.
- An external query's
url,method: POST+body(with placeholder substitution fromdynamicContext), andtransform:— and on a parametric page, itsrecord:request. - The query's
scope/where/sort/limit, then the fetch'snarrowof them, evaluated over what arrived.
It reads no site-level fetcher: keys. The baseUrl / headers / envelope / supports / request.* vocabulary it once read is retired — the build warns and drops it. A backend that needs a base URL, headers or its own wire is a transport.
What the default deliberately does not do:
- Pagination — too diverse (cursor / offset / page-number). Foundation work;
@uniweb/fetchersmiddleware. - Retries / timeouts — middleware territory.
- Mutations (PUT / PATCH / DELETE) — optimistic updates, CSRF, action semantics — different feature entirely.
- Query-language compilation — the default forwards what the site author wrote; it doesn't translate between query languages.
- Response normalization — snake_case → camelCase, date parsing, field renames. Foundation work.
- Secrets / private credentials. Any value in
site.ymlorqueries.ymlends up in the served HTML. The framework doesn't offer a feature that pretends otherwise. See Secrets posture.
The scope test is capability vs. cost. Every line in the default ships in the runtime on every site. A capability that benefits only a subset, or requires conditional logic (route matching, per-endpoint rules), belongs in middleware or a custom fetcher.
Request, context, return
The fetcher contract is one method:
resolve(request, ctx): Promise<{ data, error?, meta? }>Request
Normalized from the author's fetch: / query: config. Carried fields:
| Field | Required | Description |
|---|---|---|
as | yes | Key under content.data the result will be stored at. |
query | no | The query the request reads, by name. |
path | either this | A compiled file under the site's base — /data/<query>.json, or a per-record file. Resolved from the query; never authored. Mutually exclusive with url. |
url | or this | An external query's address, sent as written. Mutually exclusive with path. |
transform | no | Dot-path to the records in the response — the query's transform, or record.transform on a record request. |
method | no | GET (default) or POST. Unsupported values warn and fall back to GET. |
body | no | Arbitrary object (POST only). Supports {paramName} placeholder substitution from dynamicContext. |
record | no | An external query's record: — { url?, method?, body?, transform? } — which buildDetailConfig turns into a parametric page's record request. |
detail | no | Set by resolution, never authored: the query has a per-record source — a compiled query's per-record file pattern, or true for an external query's record: or a host's records service. |
scope / where / sort / limit | no | The query as saved (folder branch, predicate, order, count) — together they select the query's records, its limit included. |
narrow | no | What this fetch takes of the query's records: its own where, sort and limit, applied after the query's — and on a host's record question, match for the one record a parametric page names and cursor to resume an answer. Absent when the fetch takes all of the query's records. The default fetcher evaluates both levels client-side over what arrived — scope over the folder each compiled record is held with — and each combination is its own cache entry; a host that answers queries evaluates them at the source; a transport decides for itself. |
dynamicContext | no | Present on a parametric page's record fetch: { paramName, paramValue }. |
Context
Handed to the fetcher directly — no globalThis reads needed:
| Field | Description |
|---|---|
website | The active Website. |
page | Page scope; null for site-level. |
block | Block triggering the request; null for page/site-level. |
signal | AbortSignal. Aborts on block unmount or route change. Pass to fetch(). |
Return
Always the same shape:
{ data, error?, meta? }data— never undefined. Empty is[]ornull/{}.error— string presence signals failure. May coexist with stale data.meta— fetcher-specific metadata stored alongside the cache entry.
Throwing works but isn't idiomatic. The runtime catches and surfaces { data: [], error: String(err) }.
Lifecycle of a dispatch
When website.fetcher.dispatch(request, ctx) is called:
- Select fetcher. Runtime
transportoverride wins if set; otherwise look upctx.website.config.fetcher.transports[as](the fetch's ownas) →.transports.defaultin the named-transport registry; otherwise the framework default fetcher. Record: a specific fetcher instance. - Derive cache key. Call
fetcher.cacheKey(request)if defined, else the framework default. - Cache hit?
dataStore.get(key)— return cached{ data, meta }synchronously-wrapped. - In-flight? Attach this request's signal to the existing promise's abort set; await the same promise.
- Execute. Call
fetcher.resolve(request, ctx). Store the promise in in-flight underkey. - On success. Write
{ data, meta }to the cache, fire listeners (global + keyed), return. - On failure. Surface
{ data, error }; do not cache error states.
Signals and dedup. When two blocks request the same key concurrently, they share one fetcher call. The in-flight entry carries a Set of signals; the underlying fetch is aborted only when every attached signal has aborted. Cancelling one block doesn't abort the other.
EntityStore calls website.fetcher.dispatch(request, ctx) — it never touches DataStore directly. DataStore's surface is has, get, set, subscribe, clear, plus inflight for dedup.
Cache keys
The default derivation lives in @uniweb/core as deriveCacheKey(request). Used by the dispatcher (when the selected fetcher doesn't define its own cacheKey) and by the build-time preload path.
Fields that contribute:
JSON.stringify({
path, // one of path or url — what resource
url,
as, // which content.data key
transform, // per-fetch unwrap path
method, // only when non-GET; POST shares URLs with GET
body, // only on POST; two POSTs to the same URL with different
// bodies are different queries
scope, // the view taken of what came back: evaluated client-side,
where, // so two views of one file are two entries — each cut from
sort, // one read of the file when they are asked together
limit,
narrow, // the fetch's own where / sort / limit, in one field order
locale,
})A request with no address — a question to a host that answers queries — is identified by the question itself instead: query, schema, scope, where, sort, limit, narrow (whose match is the one record a parametric page asks for), whole, plus as, transform and locale.
Fields that do not contribute:
detail/record— a record request is its own request, with its own URL, path or body, which already splits the key.dynamicContext— carried on the request for resolution; the cache key'sbodyalready contains the substituted values.
Custom fetchers with specialized needs (e.g., a request where the response varies by some out-of-band value) can override cacheKey(request) on the fetcher object.
Fetch lifecycle vs. state changes
Today's model:
BlockRendererruns the block's fetch once per block lifecycle. The fetch effect is keyed on[block]— it fires at mount and re-fires on SPA navigation that changes the block, and nowhere else.page.state/website.statedrive React re-renders of subscribing components via kit hooks. They do not drive re-dispatches.- Filter-state patterns (academic-metrics-style) are implemented by fetching a query's records once and re-computing client-side from them as state changes.
Interactive re-fetching (search boxes, pagination, drill-downs) is handled by domain-aware components — components that know the backend and fetch their own data with standard React (useEffect + fetch). The framework doesn't bridge this case because a component that knows what variables to send already has backend domain knowledge, which is definitionally outside the "runtime hands me data" contract. See Component Data Patterns.
Placeholder substitution
@uniweb/core's substitutePlaceholders(value, context, { encode }) handles {name} substitution in two places:
- Record addresses. An external query's
record: { url: 'https://api.example.com/articles/{slug}' }, or a compiled query's/data/articles/{slug}.json—buildDetailConfigfills in the value from the parametric page's URL. Encoding ON. - POST body objects.
body: { variables: { slug: '{slug}' } }— arecord.bodyis filled bybuildDetailConfig, and the default fetcher fills a request's body fromdynamicContextbefore JSON-serializing. Encoding OFF (JSON will serialize).
The names available are the route's own param ({slug} for [slug], {id} for [id]), {param} as an alias for it, and {slug} as the record's slug when the record is already in hand.
The matcher is strict: \{([A-Za-z_][A-Za-z0-9_]*)\}. Whitespace inside the braces disqualifies the match — so GraphQL selection sets like { id name } pass through unchanged when they appear in a body string.
Only keys actually present in the context substitute. Unknown keys pass through as literal {name}. Null/undefined values also pass through.
Delivery paths
The same dispatcher runs however the site reaches the browser:
- A static build (
uniweb build). Site content is embedded into the built HTML at__SITE_CONTENT__(a Vitedefine). The fetches a page needs are executed by the build pipeline (in Node), and their results are embedded in the page and hydrated into theDataStoreon runtime startup. - A host that serves the site. The host serves an HTML shell and stamps the site's payload (
__DATA__) into it at request time. A host that prerenders runs the runtime's own page prefetch over a transport it supplies and passes the answers in, which hydrate theDataStorethe same way.
The dispatcher's behavior is identical in both — same routing, same cache, same contract. The only difference is where fetches that happen before the page reaches the browser run, which is a concern of the preload path, not the runtime fetcher.
The build reads an external query as the browser does
When a fetch of an external query says prerender: true, the build-time fetch path (build/src/site/data-fetcher.js) reads it with the runtime default fetcher's vocabulary: url, method: POST with its body, and transform, then the query's where / sort / limit and the fetch's narrow with the same evaluator. A fetch of an external query defaults to prerender: false, so it runs in the browser unless it says prerender: true.
From a fetch to content.data
The dispatcher answers requests; the entity store (@uniweb/core) decides which requests a section needs and what its component receives. For each section it:
- Collects the fetches that reach the section, one level at a time: the section's own, its page's, its parent page's, and the site's (for a layout area or a top-level page only).
- Pairs each key the component declares with the fetch that fills it —
fillDeclaredKeys: a fetch fills its ownaswhen the component declares it, and otherwise the component's first still-empty key of the query's schema. A key already held by the section (a tagged data block, or an answer a static build embedded) is filled from what it holds. - Resolves each filling fetch to a request: the query as saved, with route variables bound, and the fetch's narrowing as
narrow. Only fetches that fill a key are dispatched. - On a parametric page, applies
current:— decided by the query the fetch names (currentFor): a fetch of the route query takes the page's record by default;excludeasksnarrow.limitone higher and removes it. - Links records: each record gets
$route, the URL of the parametric page whose route query is the fetch's query (or itsdetailPage), filled at render time on every lane. - Delivers the declared keys: the records as a list,
nullfor a key nothing fills or whose fetch failed — with the message onblock.dataError— andblock.dataLoadingwhile a fetch is pending.
A host's prefetch resolves the fetches with the same functions — every fetch of every level, a superset — so everything the render asks for is among the answers it embeds. The rules are in Data Fetching → What a section receives and Parametric Pages.
Post-processing
where: / sort: / limit: are evaluated over what the source returned, with the one evaluator in @uniweb/core (evaluateQuery) — the same code in both places, so the browser orders and filters exactly as the build did. It works in two levels: the query's scope, where, sort and limit select its records, and then the fetch's narrow — its own where, sort and limit — takes from them, so a fetch never receives a record its query leaves out:
- Static build: applied in
build/src/site/data-fetcher.jsbefore embedding into__SITE_CONTENT__. - Runtime: applied by the default fetcher after the response arrives (
runtime/src/default-fetcher.js). A host that answers queries evaluates them at the source instead, and reports what it served.
Secrets posture
The framework's config is public to the browser by construction. In baked-in builds the site config is inlined into HTML via __SITE_CONTENT__; when a host serves the site, it's stamped into __DATA__ at request time. Either way, values in site.yml and queries.yml — an external query's url and body, a transport's binding under fetcher:, anything — are visible to anyone viewing the page source.
That means site.yml is the wrong place for secrets. The framework doesn't pretend otherwise: no auth: knob, no env-var resolution into a request, no "private" channel. The honest pattern is same-origin proxying:
- The site's external query references a URL on the site's own origin (
url: /api/articles). - A layer in front of the site — an edge worker, a backend service, whatever the deployment provides — intercepts matching paths, attaches credentials server-side, forwards to the upstream backend, returns the response.
- The framework sees a plain URL. The secret never leaves the server.
A host may provide that layer; on a self-hosted deployment it is whatever proxy you put in front of the site. Either way the site's configs only ever contain same-origin URLs.
Publishable tokens (Mapbox public, Algolia search-only, Stripe publishable, GitHub public-read) are a distinct category — they're designed to be sent from browsers, and rate-limited at the API. Put them directly in an external query's URL or body; no proxy needed. Calling them "secrets" would be a misnomer.
Gotchas
POST requests miss CDN caches at build time
Edge CDNs cache GET aggressively and treat POST as bypass. prerender: true with method: POST will run against the origin every build, skipping the CDN. Usually fine — builds are infrequent — but surprises when expecting build speed to benefit from upstream caching. Not a framework bug; HTTP semantics.
POST body cache-key size
The default deriveCacheKey stringifies the body into the cache key. Typical bodies are under a few KB and Map handles that easily. A site embedding multi-KB fixtures in body: would bloat memory. If it ever shows up, hash the serialized body instead. Defer until observed.
Body placeholder collision with GraphQL { field } braces
GraphQL selection sets contain { id name }. The placeholder matcher requires no whitespace between the braces (\{([A-Za-z_][A-Za-z0-9_]*)\}), so { id } and {slug} are distinguishable: the first has whitespace and isn't matched; the second has no whitespace and gets substituted when slug is in the parametric page's context. Tested explicitly.
A non-2xx response is an error, not data
The default fetcher reports a non-2xx response as HTTP <status>: <statusText> and delivers no records; the key it fills is null in content.data and the message lands on block.dataError. A transform: never reads an error body.
Foundation fetchers reading ctx.website.config.fetcher see the same block
site.yml fetcher: is one block. The dispatcher reads fetcher.transports; the default fetcher reads nothing from it; a transport reads its own binding, conventionally under fetcher.<name>. Unknown keys are ignored — no framework-level schema validation. Custom fetchers should document the keys they read.
Filter state doesn't re-fetch
page.state changes trigger React re-renders of subscribing components. They do not trigger re-dispatches of block fetches. Sites that need re-fetching on user action use domain-aware components with standard React (useEffect + fetch) — see Component Data Patterns.
When the default is the wrong answer
From the contract's angle:
- The response shape requires a transform that isn't expressible as a dot-path.
- Multiple endpoints compose into one logical fetch.
- Pagination the default doesn't support.
- Response retry / rate-limit policies tied to specific status codes or headers.
- A non-JSON wire protocol.
- Interactive re-fetching on user action. (This is a component-owned concern — domain-aware components fetch their own data with standard React; see Component Data Patterns.)
Write a custom transport. Compose @uniweb/fetchers middleware around it. See Foundation Configuration → Data Transports.
See also
- Data Sources — User guide with recipes.
- Foundation Configuration → Data Transports — Writing and registering a named transport.
- Working with Data — Narrative guide: one query across a site, what each section receives, whole records.
- Data Fetching — Reference for
fetch:/query:, narrowing, and what a section receives. - Queries — Everything a query can say, external queries included.
- Extensions Architecture — How extensions contribute named transports.