Meridian handbook
Templates

Querying content

How a template fetches published documents with query() (where, tag, near, orderBy, limit, offset, exclude, organisation, onePer, with, and the terminal methods), nearby(), children(), ancestors(), smartList() and search(), with every rule, limit and error.

A template reads content, never writes it. Besides the page’s own entry and an index page’s items, a template fetches documents with a small set of functions:

FunctionUse it for
query('type')A list of published documents of one type, filtered and sorted
nearby(entry, km, type)Documents near a document, nearest first
children(doc), ancestors(doc)A nesting document’s tree
smartList('key')A list an editor maintains in the admin
search(q)The site’s search service, with facets and paging
singletons.<type>A singleton document (see Globals)

Every one of them returns only documents that are published in the request locale and shown on the current site. There is no way to read a draft or another site’s hidden content from a template.

{% set featured = query('listing').tag('eat-drink.*').where('dogFriendly', true).orderBy('title').limit(6).all() %}

<ul class="cards">
{% for place in featured %}
  <li><a href="{{ place.uri|url }}">{{ place.title }}</a></li>
{% empty %}
  <li>{{ t('places.none') }}</li>
{% endfor %}
</ul>

query()

query('listing') starts a query over one type the current site serves. Each method returns a new query, so a partial query can be kept and reused:

{% set places = query('listing').tag('eat-drink.*') %}
<p>{{ t('index.count', places.count()) }}</p>
{% for p in places.orderBy('title').limit(12).all() %}...{% endfor %}

A query covers one type. Passing several (query(['listing', 'article']) or query('listing', 'article')) returns nothing, with no error at check or render; run one query per type, or use search(), which searches every type, for a mixed list.

The query runs only when a terminal method (all(), first(), count(), exists() or ids()) is called. Each terminal call runs it again, so call all() once and keep the result with {% set %}.

Filtering on fields: index: true

A query can filter and sort only on fields declared index: true in the schema. title can always be filtered and sorted on, and the engine’s own dates (createdAt, updatedAt and publishedAt, on a type that does not declare a field of that name) can be sorted on with orderBy() but not filtered with where(), which keeps nothing. This keeps every query a database index lookup however large the site grows.

config/schema/listing.yaml
fields:
  - name: dogFriendly
    id: fld_ab12cd34ef
    type: boolean
    index: true

A query that filters or sorts on a field that is not indexed, or not a field of the type, fails the render with an error naming the field:

query().where("website"): website is not declared index: true, so it cannot be queried

engine-dmo check does not catch this; render the page (engine-dmo render <path>) to be sure.

Methods

MethodWhat it does
where(field, value)Keep documents whose field equals the value
where(field, op, value)Compare with an operator: eq, ne, in (the value is a list), gt, gte, lt, lte, exists (the value is true or false). Any other operator is an error
tag(key, ...)Keep documents carrying every tag given. A key matches that tag only; key.* matches the tag and every tag below it (eat-drink.*). Only public vocabulary tags count
near(point, km)Keep documents within km kilometres of a point, and order them nearest first unless orderBy() says otherwise. The point is anything with lat and lon (a geo value, an address’s geo) or a document with a geo field. km defaults to 10 and is capped at 500. The type needs a geo field declared index: true. A point that is empty keeps nothing
orderBy(field, dir)Sort by an indexed field, title, createdAt, updatedAt or publishedAt, or by distance after near(). dir is asc (the default) or desc
limit(n)At most n documents, at most 500
offset(n)Skip the first n documents
exclude(doc, ...)Leave documents out, given as entries or ids: exclude(entry) leaves the current page out
organisation(x)Keep the documents one organisation owns. x is the organisation’s id, an entry that has an organisation, or an organisation value
onePer('organisation')Keep the first document of each organisation in the query’s order (and every document no organisation owns), so a strip shows one item per business. limit() and count() count what is kept
with(paths, ...)Read relations and tree paths for every result up front. See Eager loading

Terminal methods

MethodReturns
all()The documents as a list of entries. Without limit(), at most 100
first()The first document, or nothing
count()How many documents match, exactly, ignoring limit()
exists()Whether any document matches
ids()The documents’ ids

Values

Each document is an entry, the same shape as the page’s entry (see The page context): title, uri, url, tags, dates and every field. Relations are read lazily: the first time a template reads a relation on one entry of the list, it is read for all of them in one go, so a loop that prints each item’s related document costs a query or two, not one per item. Each entry also carries organisationId, the id of the organisation that owns it, when one does.

Ordering

Without orderBy(), a query uses the type’s ordering from the schema, or title order when it has none; after near(), nearest first.

Where value types matter

where() compares as the field stores its value: a boolean as true or false, a number as written, a relation by the related document (pass the entry or its id), a select by its option value, a date or datetime field by a date written as text ('2026-09-01' or an RFC 3339 time), anything else as text.

A date value such as today or now is not accepted by where(): the query logs is not a date or an RFC 3339 time and keeps nothing. Pass it through |iso first.

{{ query('listing').where('priceBand', 'in', ['£', '££']).count() }}
{% set next = query('article').where('publishedAt', 'gte', today|iso).orderBy('publishedAt').limit(5).all() %}
{% set inArea = query('listing').where('area', entry).all() %}
{{ query('listing').where('phone', 'exists', true).count() }}

Errors

A misuse the author can fix fails the render with a message naming it, and the page answers 500 (under serve --dev, the development error page):

MessageCause
query("x"): "x" is not a type served on site mainThe type does not exist or is not on this site
query().where("f"): f is not a field of listingNo such field
query().where("f"): f is not declared index: true, so it cannot be queriedThe field is not indexed (also for orderBy())
query().where(): "like" is not an operator: eq, ne, in, gt, gte, lt, lte or existsUnknown operator
near() needs a geo field declared index: true on listingNo indexed geo field
query().onePer() takes 'organisation'Any other argument

A database error does not fail the page: it is logged and the query returns an empty list.

Paging

query() has no paging of its own beyond limit() and offset(). For a paged list of a type, use the type’s index page (a route with index: and paginate:), which gives items and pagination (see An index page). For a paged custom list, use search() with paginate().

Eager loading

with() names the relations and tree paths a template will read on every result, and reads each level for all results before the first renders:

{% set regions = query('area').where('kind', 'region').with(['heroImage', 'children', 'children.heroImage']).all() %}

{% set areas = query('area').with('children', limit=6, orderBy='-title').all() %}
  • A path is a field (a relation, or a field that holds one) or a tree relation (children, ancestors, siblings), with each deeper step after a dot.
  • A call that names one path takes keywords that shape the list each entry ends up with at the path’s last step: limit=, orderBy= (a field; a leading - sorts descending) and desc=.

Without with() the same values are still read efficiently, lazily, one group at a time. with() only says up front what the template needs.

nearby()

nearby(entry, km, type) is the documents of a type within km kilometres of an entry’s point, nearest first, with the entry itself left out. type defaults to the entry’s own type and km to 10. It returns at most 12 documents, as a list (no .all()):

{% for p in nearby(entry, 5) %}<a href="{{ p.uri|url }}">{{ p.title }}</a>{% endfor %}
{% for e in nearby(entry, 20, 'event') %}...{% endfor %}

The entry’s point is its type’s first indexed geo field, or a field named location, centre or geo. An entry without a point gives an empty list.

children() and ancestors()

On a type whose route nests (nest: true), the page’s own entry already has entry.children, entry.ancestors and entry.parent. The functions give the same for any document value, such as a related one:

{% for c in children(entry.parent) %}<a href="{{ c.uri|url }}">{{ c.title }}</a>{% endfor %}
{% for a in ancestors(entry) %}<a href="{{ a.uri|url }}">{{ a.title }}</a> / {% endfor %}

children(doc) is the published children in the request locale, in title order. ancestors(doc) is the ancestors, root first. Both are empty for a type that does not nest on the site. When a document came from a list, the first children() call reads the children of every document in that list at once, so a menu of regions listing each region’s places costs the queries of one region.

smartList()

A smart list is a saved query an editor maintains in the admin. A project can seed one in config/tags/smartlists.yaml; from then on the admin owns it.

{% set dogs = smartList('dog-friendly-places') %}
<h2>{{ t('lists.dogs') }} ({{ dogs.count() }})</h2>
{% for p in dogs.limit(12).all() %}<a href="{{ p.uri|url }}">{{ p.title }}</a>{% endfor %}
MethodReturns
.limit(n)The list limited to n documents
.all()The documents in the list’s own order. Without a limit, up to 500; a limit above 500 renders 500 and the engine logs it
.first()The first document, or nothing
.count()How many documents the list holds, exactly

Only a list marked public and belonging to this site (or to no site) renders. Any other key renders as an empty list, never an error, so a template cannot reveal a list the admin keeps internal.

search(q) runs a query through the site’s search service: the configured backend, its query log and, when the backend is unavailable, the browse fallback over published content.

{% set results = search(request.query.q).types('listing', 'event').paginate(20) %}
{% if results.degraded %}<p>{{ t('search.degraded') }}</p>{% endif %}
<p>{{ t('search.count', results.total) }}</p>
{% for item in results.items %}
  <a href="{% if item.clickUrl %}{{ item.clickUrl|url }}{% else %}{{ item.uri|url }}{% endif %}">{{ item.title }}</a>
  {% if item.highlight %}<p>{{ item.highlight }}</p>{% endif %}
{% endfor %}
MethodWhat it does
.types(key, ...)Only these types
.tags(key, ...)Only documents with these tags
.near(point, km)Only documents within km (default 10) of a point; with no words, nearest first
.sort(order)relevance, name, distance or recent. Without it, a search with words is by relevance
.facets(name, ...)Return only these facets
.paginate(n)Run it: the page ?page= names, n per page
.limit(n)Run it: page one, n results

n defaults to 20 and is capped at 100. The result:

KeyValue
itemsThe hits, each id, type, title, summary, uri, url, tags, position, fields (the document’s indexed scalar fields), image (its main image as an image value, or empty), and where the backend gives them highlight (safe markup), distance and clickUrl (a link that records which result was chosen)
totalHow many results
facetsPer facet name, a list of value, count, label
degradedtrue when the answer came from the browse fallback rather than the search backend
backendWhich backend answered
query, sortWhat was asked
page, pages, hasMoreThe page, how many pages, whether there are more
paginationAs an index page’s pagination, its links keeping the rest of the query string
didYouMeanAlways empty at present

A query shorter than the configured minimum length, the empty one included, is browse mode and is not logged. A search from a template is logged as the search route’s is. The search route itself renders search.html with the same object as results; see Search.

Performance

  • Every list’s entries, their relations, tags, dates and translations are read in a few statements per list, however many entries it holds.
  • Everything a response renders shares one cache for that response: the layout’s menus, the page’s lists and every entry’s relations read each document once between them.
  • To cache an expensive part of a page across requests, use {% cache %}; see Caching.
  • engine-dmo render <path> --json lists the queries a page made.