Working with Data
Your components need data — articles, team members, products. In a typical React app you'd write a useEffect, manage loading state, handle errors and work out caching yourself. In Uniweb the content says which data a page uses, the component says which data it renders, and the runtime does the fetching, caching and delivery in between. Your component reads content.data.
This guide follows one site from its query to its components: a list, a page per record, a "more to read" section beside each record, and a teaser on the home page — four uses of one query. The reference for each piece is linked as it comes up.
First, know which pattern you're in. Uniweb supports two ways a component ends up with data: the content names a query and the runtime fetches it (this guide), or a domain-aware component fetches its own data with standard React. If your component has to know about backend-specific things (query parameters, pagination cursors, filter shapes), you're in the second pattern — see Component Data Patterns for which is which.
The site
A research station keeps every kind of article side by side — lab news, press releases, field notes, expedition reports — and has a blog for the ones from the field:
site/
├── records/std/article/ # every article, one file each
├── queries.yml
└── pages/
├── home/
│ └── 3-from-the-field.md
└── blog/
├── page.yml
├── 1-list.md
└── [slug]/
├── 1-article.md
└── 2-more.mdThe articles are records: files in records/ (Records). Pages never read them directly. They name a query.
One query
# queries.yml
articles:
schema: '@std/article'
where: { tags: { in: [field-notes, expedition] } }
sort: date desc
limit: 100A query selects a set: here, the 100 most recent field notes and expedition reports. It is defined once, and every use below takes from it:
| where | what it takes from articles |
|---|---|
/blog | all of it |
/blog/:slug | the one article the URL names, whole |
| ↳ its "more to read" section | three others |
| a section of the home page | the first three |
Nothing outside the set can appear in any of them, and a change to the query reaches all four. Everything a query can say is in Queries.
A page names the query — /blog
# pages/blog/page.yml
title: Blog
query: articles<!-- pages/blog/1-list.md -->
---
type: ArticleList
---
# From the fieldThe page names the query, never a file or a URL. While you develop, the records come from the file a local build generates from the query; once the site is published to a host that serves records live, they come from there. Nothing in the page changes.
A page renders nothing itself — its sections do. A page's query reaches its own sections and the sections of its child pages. The component behind type: ArticleList says what it renders:
// src/sections/ArticleList/meta.js
export default {
title: 'Article List',
data: { articles: '@std/article' },
}// src/sections/ArticleList/index.jsx
import { DataPlaceholder, Link } from '@uniweb/kit'
export default function ArticleList({ content, block }) {
if (block.dataLoading) return <DataPlaceholder lines={4} />
const articles = content.data.articles ?? []
return (
<ul>
{articles.map((a) => (
<li key={a.$name}>
<Link href={a.$route}>{a.title}</Link>
</li>
))}
</ul>
)
}No fetch(), no useState, no cache. The component doesn't know which query, file or host its articles came from — which is what lets the same section type serve another site.
The declaration is the delivery
data: { articles: '@std/article' } does two jobs.
- Its key is what the section receives. A section's
content.dataholds the keys its component declares, and nothing else. A component that readscontent.data.articleswithout declaringarticlesreceives nothing there. - Its value is the shape of each record. It tells the editor what an article looks like and
uniweb validatewhat to check. The runtime changes no record: a field a record lacks is absent, so writea.title ?? ''where your component needs a value.'@std/article'is a shared standard schema;'@/article'would be one of your foundation's own (Data Schemas).
$route — the link is already built
Every record a query delivers carries $route, the URL of the page that shows it — here /blog/<slug>, because /blog/:slug (below) is the page for records of articles. Read it; don't compose `/blog/${a.slug}`. A composed URL is a second answer to a question the framework already answered, and the two drift apart on the first translated route or nested folder. A record with no page gets no $route, so a card can tell:
const Card = a.$route ? Link : 'div'A page for each record — /blog/:slug
pages/blog/[slug]/
├── 1-article.md # type: Article
└── 2-more.md # type: MoreToReadA folder named in brackets is a parametric page: one page, with a URL for each record. It declares no query of its own — it takes its parent's, articles, and each URL names one record of it. /blog/bamboo-season names the article whose handle is bamboo-season.
The set decides which URLs exist. /blog/:slug has a page for each of the 100 articles in the set, and no other: a press release, or a field note older than the 100 most recent, is not found there — even though it is a record of the same schema. A condition that should decide which pages exist belongs on the query, never on a list's fetch.
The full rules — which query a URL names, what the segment matches, [...path] for URLs of any depth — are in Parametric Pages.
The article section: the page's record
<!-- pages/blog/[slug]/1-article.md -->
---
type: Article
---// src/sections/Article/meta.js
export default {
title: 'Article',
data: { article: { schema: '@std/article', single: true } },
}// src/sections/Article/index.jsx
import { DataPlaceholder } from '@uniweb/kit'
export default function Article({ content, block }) {
if (block.dataLoading) return <DataPlaceholder lines={8} />
const article = content.data.article
if (!article) return <p>This article does not exist.</p>
return (
<article>
<h1>{article.title}</h1>
<p>{article.excerpt}</p>
</article>
)
}The section has no fetch of its own. The page's query reaches it narrowed to the record the URL names, and single: true says the key holds that one record, so content.data.article is the article — or null when the URL names no record of the set, in which case the page is marked not found and titled "Not found". On a hit, the page takes its title from the record.
Without single: true the key holds a list, as any key does: a list of one here, content.data.article[0], and [] when nothing matches. And the record arrives under article, not articles: a fetch fills the key named after its query when the component declares that key, and otherwise fills the component's first key of the query's schema. Article declares one @std/article key, so the record lands there. A component declaring articles would receive it under articles — the rule is in Which fetch fills a key.
"More to read": the rest of the set
<!-- pages/blog/[slug]/2-more.md -->
---
type: MoreToRead
fetch:
query: articles
current: exclude
limit: 3
---
# More from the field// src/sections/MoreToRead/meta.js
export default {
title: 'More to Read',
data: { related: '@std/article' },
}This section names the same query and narrows it: current: exclude takes out the article the page is about, and limit: 3 keeps three of the rest. The component receives them under related, its one key of that schema, and links each with $route exactly as the list does.
current: says how a section on a parametric page uses the page's record:
current: | the section receives |
|---|---|
only | the record — one, for a single: true key, as the article section got; a list of one otherwise. The default for the page's own query |
exclude | the set without it — "more to read", "related" |
include | all of it, the record among them — a previous / next pager |
Another page takes a few — the home page
<!-- pages/home/3-from-the-field.md -->
---
type: ArticleList
fetch:
query: articles
limit: 3
---
# From the fieldThe home page is not about articles; one of its sections shows three. It reuses ArticleList and names the same query, narrowed to three — and each card links to /blog/<slug>, because that is the page for records of articles, wherever the list appears.
A fetch narrows its query with where, sort and limit, applied after the query's own, and never widens it. { query: articles, where: { featured: true }, limit: 3 } shows the three newest featured articles among the 100 — never an older one and never a press release, even when fewer than three of the 100 are featured. That is why one well-chosen query serves the whole site: pages don't declare queries of their own to show less. See Narrowing a query.
Where the records come from
The four uses are four questions about one set:
| the section | asks for |
|---|---|
/blog's list | the set |
/blog/bamboo-season's article | the set, narrowed to bamboo-season, whole |
| its "more to read" | the set without bamboo-season, three of it |
| the home page's section | the set, three of it |
Who answers depends on where the site runs, never on what the page says:
- A static site. The build compiles the query into a file, and the framework evaluates the set and each narrowing over it — at build time for the pages it prerenders, in the browser for anything fetched later. The build also generates one page per record of the set: 100 article pages here.
- A host that answers queries. Each question goes to the host, which evaluates the set and the narrowing at the source, so the home page's section receives three articles, not a hundred. A page's questions travel together.
- An external API. The same site works over a public JSON endpoint: declare
articleswithurl:instead ofschema:(below).
In the browser, answers are cached by the question itself: a question already answered, or already on its way, is not asked again, so moving from /blog to an article and back asks only what hasn't been asked.
Loading, failure and not found
What a declared key holds tells a component where it stands:
content.data.<key> | means |
|---|---|
| a list | the records — [] means the question had no answers |
null, with block.dataLoading | its fetch has not answered yet |
null, with block.dataError[key] | its fetch failed; the message is there |
null | nothing on this page fills the key |
if (block.dataLoading) return <DataPlaceholder />
if (block.dataError?.related) return null
const related = content.data.related ?? []
if (related.length === 0) return nullA failure is never delivered as [], because [] is an answer. On a parametric page whose URL names no record, the key is [], page.notFound is true and the page's title is "Not found" — no useEffect, no document.title.
When a list carries less than a record
A list of a hundred articles doesn't need a hundred bodies. A list carries each record's brief — the card's fields — and a section that shows one record in full says so in its meta.js, with whole: true:
// src/sections/Article/meta.js
export default {
// the article whole: article.brief.title, article.body.content
data: { article: { schema: '@std/article', single: true, whole: true } },
}On the parametric page, that section receives the record whole — from the host that serves records live, from the file of its own a static build writes for every record, with nothing to configure — while a section that declares '@std/article' receives the brief the list holds (Component Metadata → Briefs or whole records). An external query whose endpoint lists summaries names the request for one whole record with record: (Queries → record); its records have no schema, and the page asks it for any section.
Anywhere else — a hover card, a modal — a component fetches the whole record on demand with useWholeRecord, which returns the record it was given when the query has nothing separate to fetch.
The same site over a public API
Nothing above depends on where the articles live. Point the query at a public JSON endpoint and every page and component stays as it is:
# queries.yml
articles:
url: https://api.example.com/articles
transform: data.items # the records sit under data.items
where: { tags: { in: [field-notes, expedition] } }
sort: date desc
limit: 100
record:
url: https://api.example.com/articles/{slug} # one whole article, for /blog/:slugThe framework fetches the endpoint in the visitor's browser and evaluates the query and each narrowing over what arrived. An API that needs a key, headers of its own, or paging is a foundation transport — see Data Sources.
Checking your data
The data: schema is a contract — it says what shape content.data.<key> will have. uniweb validate checks your file-based data against that contract, so a misspelled field or a value outside an enum surfaces while you're working, not as a blank slot on a live page.
uniweb validate # warn about any record that doesn't match its schema
uniweb validate # fails (exit 1) on a violation — wire this into CIIt reports the exact chain — route, section, data key, file, item, field — so you fix the record directly:
✗ /data/projects.json · schema @acme/project
used by /work › Projects › data.projects
• item "cinder" › name: missing required field 'name'
• item "cinder" › status: 42 is not one of ["active", "archived"]It also reports a section whose page fetches data none of which fills the keys its component declares. This is deliberately a gate you run, not part of every build: the runtime stays tolerant — it applies defaults and ignores the rest — so a data mistake degrades gracefully in production instead of breaking the build. Full reference: CLI Commands → uniweb validate.
See also
- Queries — everything a query can say:
schema,scope,where,sort,limit, external queries - Data Fetching —
query:andfetch:, narrowing, what a section receives,current: - Parametric Pages — one page per record: which query a URL names, what it matches,
$route - Records —
records/, itsfolder.yml, and what a compiled record holds - Component Metadata → Data — the
data:declaration and its three value forms - Data Sources — public APIs, a host's live records, foundation transports, and secrets
- Component Data Patterns — when a component should fetch its own data instead
- Data Fetcher Architecture — dispatcher internals, cache keys, delivery modes