Meridian handbook
For agentsRecipes

Listing page with filters and pagination

How an agent builds a type's index page in Meridian with route facets for filtering, a translated result count, cards and an accessible pager, and verifies the first, last and filtered pages with render.

A listing page is a route’s index: the type’s published documents on the site in the request locale, a page at a time, filtered by the facets the route declares. Filtering and paging are done by the engine from the query string (?difficulty=easy&page=2), so the page works without JavaScript and every filtered state has its own address.

flowchart LR
  req["GET /walks?difficulty=easy&page=2"] --> route["routes.yaml<br/>index /walks"]
  route --> filter["Published walks in the locale,<br/>filtered by the facets"]
  filter --> page["Page 2 of paginate: 12"]
  page --> ctx["items, facets,<br/>pagination"]
  ctx --> tpl["walk/index.html"]

Configure the route

Every facet must be a field declared index: true on the type.

# config/sites/main/routes.yaml
routes:
  - type: walk
    id: rte_34agwcmxkh            # keep the id the verb wrote
    uri: "/walks/{slug}"
    index: /walks
    paginate: 12
    facets: [difficulty]

Write the strings

# config/strings/en.yaml
strings:
  walks.title: Walks in the valley
  walks.count:
    one: "{count} walk"
    other: "{count} walks"
  walks.none: No walks match these filters.
  walks.distance: "{km} km"
  facets.label: Filter these walks
  facets.clear: Clear
  pager.label: Pages
  pager.previous: Previous
  pager.next: Next
  pager.page_of: "Page {page} of {pages}"

Write the template

{# templates/_shared/walk/index.html #}
{% extends "base.html" %}
{% block body %}
<h1>{{ t('walks.title') }}</h1>
{% if facets %}
<nav aria-label="{{ t('facets.label') }}">
  {% for facet in facets %}
  <h2>{{ facet.label }}</h2>
  <ul>
    {% for v in facet.values %}<li><a href="{{ v.url|url }}"{% if v.selected %} aria-current="true"{% endif %}>{{ v.label }} ({{ v.count }})</a></li>{% endfor %}
  </ul>
  {% if facet.selected %}<p><a href="{{ facet.clearUrl|url }}">{{ t('facets.clear') }}</a></p>{% endif %}
  {% endfor %}
</nav>
{% endif %}
<p>{{ t('walks.count', pagination.total) }}</p>
<ul class="cards">
  {% for walk in items %}
  <li>
    {% if walk.photo %}{{ walk.photo.img("card", alt="") }}{% if walk.photo.credit %}<small>{{ walk.photo.credit }}</small>{% endif %}{% endif %}
    <h2><a href="{{ walk.uri|url }}">{{ walk.title }}</a></h2>
    {% if walk.summary %}<p>{{ walk.summary }}</p>{% endif %}
    {% if walk.distanceKm %}<p>{{ t('walks.distance', 'km', walk.distanceKm|number) }}</p>{% endif %}
  </li>
  {% empty %}
  <li>{{ t('walks.none') }}</li>
  {% endfor %}
</ul>
{% if pagination.pages > 1 %}
<nav aria-label="{{ t('pager.label') }}">
  {% if pagination.prev %}<a rel="prev" href="{{ pagination.prev|url }}">{{ t('pager.previous') }}</a>{% endif %}
  <span>{{ t('pager.page_of', 'page', pagination.page, 'pages', pagination.pages) }}</span>
  {% if pagination.next %}<a rel="next" href="{{ pagination.next|url }}">{{ t('pager.next') }}</a>{% endif %}
</nav>
{% endif %}
{% endblock %}

Points that matter:

  • The card image is alt="" because the linked title beside it names the walk; an image that carries information gets its library alt text instead (leave alt out).
  • Print numbers through |number before passing them to t(); a raw decimal prints as 6.500000.
  • Link with walk.uri|url (a path on the current origin). walk.url is the absolute address on the site’s configured domain.
  • pagination.prev and pagination.next keep the selected facets; v.url and facet.clearUrl keep the rest of the query. pagination.range holds page numbers only, with no addresses: a numbered link written as href="?page={{ n }}" drops the selected facets, and check does not notice. Use prev and next, as here, on a filtered index.
  • The h1 comes from t(). On an index, page.title is the type’s plural label, an admin word such as “Walks”, and printing it makes that word the page’s public heading. See The index page’s title.
  • Facets are one <nav> named from t(), holding a heading (facet.label, the field’s label) and a list of links for each facet. The values are links, not form controls, so they take no <fieldset>, and one landmark keeps a page with several facets from adding a landmark for each.
  • A facet value’s label is its option’s label in the request locale. An option with no label prints its raw value (easy), and check does not warn about it: label every option (Add a field).
  • A filtered index is noindex and its canonical is the unfiltered index; head() writes both.
  • For a component per card, see Components: {% component 'walk-card' walk=walk %}.

The index page’s title

An index has no document behind it, so it has no fields of its own, and a route has no key for a title or a description.

What an index gets
page.titleThe type’s plural label in the request locale
<title>, from head()The same, through seo.title_template
Meta descriptionNone. head() prints no description on an index

So write the plural label as words you would accept in a search result, because it is the <title> whatever the h1 says, and take the h1 from t(). For a page with an editor-written title and description, either use a landing page, which has both, or route the index elsewhere and make a page document at the address, listing the documents with query() (below). A document at an index’s own address replaces the index, so its facets and pager go with it.

Verify

engine-dmo render /walks --json
engine-dmo render '/walks?difficulty=easy' --json
engine-dmo render '/walks?page=2' --json

Quote paths with ? in your shell. Each must answer 200 with empty errors and a11y. Render a filtered page past the first too ('/walks?difficulty=easy&page=2') and check its rel="prev" link still carries difficulty=easy. Generate enough documents to see the pager: engine-dmo seed generate --type walk --count 14 --publish.

Lists that are not an index

A list inside another page (the three newest walks on the home page, the walks in an area) is a query, not an index:

{% set latest = query('walk').orderBy('publishedAt', 'desc').limit(3).all() %}
{% for w in latest %}<a href="{{ w.uri|url }}">{{ w.title }}</a>{% endfor %}

query() filters and sorts only on index: true fields (and title, plus sorting on createdAt, updatedAt and publishedAt). A non-indexed field fails the render, not check, so render every page that queries. See Querying content.