Queries
A query is a named question over the site's records — or over a public JSON endpoint. Pages and sections never read a file or a URL: they name a query, and the query says what it selects. The same name reads the file a local build generates while you develop, and a host's live records once the site is published, with nothing in the page changed.
This page lists everything a query can say. How a page or section uses one is Data Fetching; what records are and where they come from is Records.
Declaring queries
Queries live in queries.yml, a bare map at the site root — name → query:
# queries.yml
articles:
schema: '@std/article'
sort: date desc
team: '@/person' # shorthand: the string is the schema
events: # bare name: the schema is @/eventsThe same map can sit under queries: in site.yml instead, if you prefer one file. When both declare a query, queries.yml wins, key by key.
| written | means |
|---|---|
events: | a query over @/events — the schema takes the query's name |
team: '@/person' | a query over @/person |
| a map | a query with the keys below |
A uniweb pull writes each query with its schema: events: comes back as events: { schema: '@/events' }, the same query. A bare name a data key types, below, comes back as you wrote it.
A bare name that is not a data schema takes the type your foundation's sections declare for the data key of the same name. With data: { team: '@/member' } in a section's meta.js and no team schema, team: is a query over @/member records, on a static site as on a backend (pushed, they are @/member entities), while their files stay in records/team/. Any query over @/member reads them, and a section's key of that type is filled from team: as from any query over @/member.
A page names a query with query: articles, or with fetch: when it narrows it — see Data Fetching.
What a query selects: its set
A query selects a set of records: the records its schema, scope and where match, in its sort, up to its limit. Every page and section that names the query takes from that set and nothing else.
# queries.yml
articles:
schema: '@std/article'
where: { tags: { in: [field-notes, expedition] } }
sort: date desc
limit: 100This query's set is the 100 most recent field notes and expedition reports. Three things follow from it:
- A fetch can narrow the set, never widen it. A section that asks for
{ query: articles, limit: 3 }gets the first three of those 100. Its ownwhere,sortandlimitapply after the query's — see Narrowing a query. - The set decides which records have pages. A parametric page whose URL names a record of
articleshas a URL for each of the 100, and no other: a press release, or a field note older than the 100 most recent, is not found there. - So a condition that decides which pages exist belongs on the query. A
whereorlimiton a list's fetch only changes what that list shows.
Keys
A query over the site's records:
| key | says |
|---|---|
schema | which records — the data schema whose records the query reads. Defaults to @/<query name> (below) |
scope | a branch of the site's folder: records placed in it and below (below) |
where | a condition the records must meet (below) |
sort | the order, by one field (below) |
limit | how many: the first N in sort. 0 or absent means all of them |
excerpt | how a markdown record's excerpt is derived (below) |
queryable | the fields a foundation may offer as filters (below) |
A query over a public JSON endpoint — an external query:
| key | says |
|---|---|
url | the address. Its presence is what makes the query external |
method, body | POST with a JSON body, for an endpoint that takes its question in the body. GET is the default |
transform | a dot-path to the records in the response |
where, sort, limit | the query's own set, evaluated over the records transform picked |
record | the request for one whole record, for a parametric page |
queryable | as on any query |
The build stops on a query that mixes the two: schema, scope and excerpt describe the site's records and are refused beside url:, and method, body, transform and record are refused without it.
schema — which records
schema: names the data schema whose records the query reads. The records of a schema are the files in records/ under the folder the schema names:
schema | its records sit in | the schema comes from |
|---|---|---|
@/article | records/article/ | the foundation's own schemas/article |
@std/person | records/std/person/ | the shared standard schemas, @uniweb/schemas |
@acme/project | records/acme/project/ | the @acme organization's schemas |
Leave schema: out when the query's name is the schema's: events: reads @/events. Any number of queries can read one schema — that is how one set of records is shown two ways.
schema: here is the Model the records are. It is not the key a component reads them under: that is the key the component declares in its meta.js data:, filled by a fetch whose as defaults to the query name (What a section receives).
scope — a branch of the folder
records/folder.yml can place records in folders (Records → Folders). scope: reads one branch:
spring:
schema: '@/post'
scope: '2024' # records in 2024 and 2024/spring — never 2024bA scope holds the named folder and everything below it, at segment boundaries. A record does not carry its folder, so a where cannot name one: path in a where is a field of the record's own, like any other name. What a record's entry in folder.yml says about it, a query reads like any field — where: { $tags: featured }, sort: $label (Records → Folders).
On a parametric page, scope: :dir reads the branch the URL names — see route variables.
scope belongs to the query. Which branch a query reads decides what the query is, so a fetch: that names the query cannot carry one; to read another branch, declare another query.
where — which records match
where: is a where-object: top-level keys are field names, joined with AND; a bare value matches by equality, and an operator is a nested object.
where:
department: biology # equality
start_year: { gte: 2010 } # comparison
rank: { in: [associate, full] } # one of a list
title: { starts_with: 'origin' } # text, case-insensitive
or:
- { tenured: true }
- { years_in_role: { gte: 10 } }
not:
status: emeritus| operator | holds when the field |
|---|---|
eq | equals the value (also a bare value) |
ne | does not equal it |
gt, gte, lt, lte | compares greater / greater or equal / less / less or equal |
in | equals one of the listed values |
not_in | equals none of them |
exists | true: has a value — not missing, null, empty text or an empty list (0 and false are values). false: has none |
contains | on a text, holds the value as a piece of it; on a list, holds an item equal to it |
starts_with, ends_with | is a text starting / ending with the value |
| composition | holds when |
|---|---|
and: [ … ] | every condition holds (the top level already is an AND) |
or: [ … ] | at least one holds |
not: { … } | the condition does not hold |
- Text operators take plain text, no wildcards, and ignore case.
- Values keep their type:
'3'does not equal3. - A list field meets a condition when any member does:
tags: featuredholds for[featured, sale];tags: { ne: featured }holds when no member isfeatured. - A missing field meets
ne,not_inandexists: false, and nothing else. - A dotted key descends into nested values:
tenure.start: { gte: 2015 }. A step that reaches a list descends into each item, soeducation.degree: PhDholds when any entry has that degree. - A condition outside the language — an unknown operator, an empty
and:oror:, a text operator with empty text — stops the build.likeandninare retired: writestarts_with,ends_withorcontains, andnot_in.
The same language is evaluated wherever the records come from: by the framework over a generated file or an endpoint's response, and by a host that answers queries at the source. A foundation transport decides for itself. Worked examples and saved views: Predicates.
sort — in what order
sort: date desc # `date`, `date asc` or `date desc`sort: names one field. A list of fields (order asc, title asc) stops the build rather than being partly honoured.
- Text sorts in the page's language.
Álvarezcomes beforeZamoraon a Spanish page, andapplebeforeBanana. Numbers inside text are not read as numbers:item 10sorts beforeitem 2. - A record with no value sorts last, in either direction — a missing field,
null, empty text, or a list. - Records that compare equal keep their order.
- A field holding different kinds sorts booleans first, then numbers, then texts.
- A dotted key (
tenure.start) sorts by a nested value; a path that meets a list gives no value.
limit — how many
limit: N keeps the first N records in sort. It is part of the set: a query with limit: 100 has 100 records everywhere it is used — in every list that names it, and in the pages a parametric page has. limit: 0, or no limit, keeps every record. A limit is a whole number: the build stops on limit: "5" or limit: -1 rather than reading either as no limit.
Route variables
On a parametric page, a query can use the parts of the page's URL:
| variable | is | on /blog/rust/2025/my-post under [...path] |
|---|---|---|
:path | the whole captured path | rust/2025/my-post |
:dir | everything before the last segment | rust/2025 |
:slug | the last segment | my-post |
Under a one-segment folder ([slug], [id]), :slug and :path are that segment and :dir is empty. A variable is a value — anywhere inside where, or the whole of scope — never a key or an operator:
posts:
schema: '@std/article'
scope: :dir # the URL's directory is the folder branchA variable with no value drops its clause. On a page with no URL parameter, and on a URL with no directory, scope: :dir reads the whole folder — so one query serves a query page and its parametric page alike. Only :path, :dir and :slug are variables. See Parametric Pages → Multi-segment routes.
excerpt — a record's summary
A markdown record gets an excerpt field. The query says how it is derived:
articles:
schema: '@/article'
excerpt:
maxLength: 200 # characters; default 160
field: description # use this frontmatter field when the record has itThe first of these that exists, cut to maxLength: the record's own excerpt:, the field field names, then the body's plain text, cut at a word boundary with ....
What a list carries
A list carries each record's brief (Entity Content → What a component receives), so a field a list should leave out belongs in a section of its own, outside the brief — @std/article's body is body, and no list carries it, on any site. A section that shows one record in full declares whole records ({ schema: '@std/article', whole: true }, Component Metadata → Briefs or whole records); anywhere else, a component fetches one with useWholeRecord.
Removed:
deferred:— the fields a static site's lists left out. A schema's brief decides what a list holds, on every site, and the build stops on the key.
queryable — fields a foundation can filter on
A site that lets readers filter a list — a department dropdown, a date-range slider — declares which fields can be filtered, with their type and type-specific details:
members:
schema: '@/member'
queryable:
department:
type: enum
label: Department
options: [biology, physics, chemistry, geology]
tenured:
type: boolean
label: Tenured
start_year:
type: range
label: Start year
min: 1800
max: 2025| type | details | typically rendered as |
|---|---|---|
enum | options: [...] | a dropdown, radio buttons or a checkbox list |
boolean | — | a toggle |
range | min, max, optional step | a slider or a number range |
text | optional placeholder | a text input |
A foundation reads the declaration with useQueryable and renders its own controls; the framework passes the declaration through as written and ships no filter UI. See Predicates → Queryable surfaces.
External queries
A query with url: reads a public JSON endpoint instead of the site's records:
# queries.yml
team:
url: https://api.example.com/team
transform: data.members # the records sit under data.members
sort: nameA page names it like any other query. Its records are fetched from the address by the visitor's browser — or by the build, when a fetch says prerender: true — and never through a host's records service; the build generates no file for it. A fetch narrows it like any query, over the records the endpoint returned, and its records carry $route like any others.
transform
Many endpoints wrap their records:
{ "status": "ok", "data": { "members": [ … ] } }transform: data.members picks the list. It is a dot-path, not a function, and it runs before where, sort and limit, which work only on a list.
method and body
An endpoint that takes its question in the body — GraphQL, a search API — is read with POST:
articles:
url: https://api.example.com/graphql
method: POST
body:
query: "{ articles { id slug title excerpt } }"
transform: data.articlesWhich field the URL names: :slug
A [slug] parametric page names a record by its $name — the name it has as an entry in its folder. An external API's records are entries in no folder, so they have none: the query binds the URL's last segment to one of their fields in its own where, and the page matches that field:
articles:
url: https://api.example.com/articles
where: { slug: :slug } # the URL's last segment is each article's `slug`
record:
url: https://api.example.com/articles/{slug}On the query's own page no URL binds :slug, so the clause drops and every record is listed; on the parametric page it selects the one the URL names. The query's records link to their page by the same field ($route), and a static build expands a page for each value. Only a clause whose whole value is :slug, at the top of the where, names the field. A page routed as [id] needs no binding: it matches a record's id field by name.
One record: record
On a parametric page the record the URL names is found in the query's list. When the list carries less than a record — an endpoint that lists summaries — record: names the request for one whole record. An external query's records have no schema, and so no brief: the page asks record: whatever its component declares.
posts:
url: https://jsonplaceholder.typicode.com/posts?_limit=12
record:
url: https://jsonplaceholder.typicode.com/posts/{id} # {id} — the page's [id] segmentrecord.url and record.method default to the query's. body and transform never carry over — a record response is rarely wrapped the way the list is — so a wrapped record says record.transform. In record.url and record.body, the name the page's folder uses — {id} for [id], {slug} for [slug] — is the value from the URL, and {param} is the same value under any folder name. The query's own url and body are sent as written:
articles:
url: https://api.example.com/graphql
method: POST
body:
query: "{ articles { id slug title excerpt } }"
transform: data.articles
where: { slug: :slug } # a [slug] page matches each article's `slug`
record:
body:
query: "query Article($slug: String!) { article(slug: $slug) { id title body } }"
variables: { slug: "{slug}" }
transform: data.articleA {name} with spaces inside the braces is not a placeholder, so a GraphQL selection set such as { id title } passes through unchanged.
What an external query is for
Every value in an external query reaches the browser, so it is for public, keyless endpoints. An API that needs a key or headers of its own, pages its results, or needs reshaping beyond a dot-path is a foundation transport — see Data Sources.
Removed
| removed | write instead |
|---|---|
route: on a query | nothing — every record carries $route |
detailUrl:, detail: | record: { url: … } on an external query |
where: { path: { under: … } } | scope: |
like, nin | starts_with, ends_with, contains; not_in |
| a sort by several fields | one field |
The build stops on each, with a message naming the replacement.
See Also
- Data Fetching — naming a query from a page or section, narrowing it, and what a section receives
- Parametric Pages — one page per record of a query
- Records —
records/, itsfolder.yml, and what a compiled record holds - Predicates — the
wherelanguage by example, and saved views - Data Sources — external queries, a host's live records, and foundation transports