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 (leavealtout). - Print numbers through
|numberbefore passing them tot(); a raw decimal prints as6.500000. - Link with
walk.uri|url(a path on the current origin).walk.urlis the absolute address on the site’s configured domain. pagination.prevandpagination.nextkeep the selected facets;v.urlandfacet.clearUrlkeep the rest of the query.pagination.rangeholds page numbers only, with no addresses: a numbered link written ashref="?page={{ n }}"drops the selected facets, andcheckdoes not notice. Useprevandnext, as here, on a filtered index.- The
h1comes fromt(). On an index,page.titleis 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 fromt(), 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
labelis its option’s label in the request locale. An option with nolabelprints its raw value (easy), andcheckdoes not warn about it: label every option (Add a field). - A filtered index is
noindexand 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.title | The type’s plural label in the request locale |
<title>, from head() | The same, through seo.title_template |
| Meta description | None. 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.
Add a route and its template
How an agent gives a content type addresses on a site with dev route add, writes the entry and index templates a route needs, and verifies them with describe route and render.
Navigation menu
How an agent adds header and footer menus to a Meridian site with navigation slots in site.yaml, a seeded navigation set and the nav tag in the layout, marking the current page and hiding an empty menu.