Landing pages
How to declare landing pages (a place crossed with a category, a category alone, or a calendar month) on a route, the threshold that makes one live, the generated and editor-written copy, the template and its landing context, and the redirects the engine keeps.
engine-dmo
A landing page is a route’s index filtered by fixed values, published at a clean URL of its own: “Places to stay in Wynd Valley” at /wynd-valley/places-to-stay rather than /places?area=...&category=stay. The engine counts the published results of every pairing, publishes each page with enough of them, gives it a generated title, description, breadcrumbs and structured data, and lists it in the sitemap. When a page falls below its threshold or its address changes, the engine leaves a redirect. Nothing about landing pages is on by default: a project has them only by declaring them.
There are three kinds:
| Kind | Declared with | Page per | Address |
|---|---|---|---|
| Place and category | place: and category: | each place with each category value | under the place’s own address: /wynd-valley/places-to-stay |
| Category page | category: alone | each category value | under the route’s index: /places/places-to-stay |
| Month page | month: alone, on a route whose type has a recurrence field | each calendar month with upcoming occurrences | under the route’s index: /whats-on/may |
A minimal landing
This builds on the listing and area set up in Directory: a listing with an area relation and a category select, both declared as facets of its route, and a routed area type. Keep the page route with its priority:. The examples write en-GB and fr-FR: use your site’s locale codes in every map, since a slug under a locale the site does not enable only warns, and the raw option value is then used in the address.
routes:
- type: page
uri: "/{slug}"
priority: 10
- type: listing
uri: "/places/{slug}"
index: "/places"
paginate: 24
facets: [area, category]
landings:
- key: by-place
place: area
category: category
uri: "{place}/{category}"
slugs:
stay: { en-GB: places-to-stay, fr-FR: hebergement }
eat-drink: { en-GB: food-and-drink, fr-FR: ou-manger }
min_results: 3
title: { en-GB: "{category} in {place}", fr-FR: "{category} à {place}" }
description: { en-GB: "{count} {category_lower} in {place}, including {examples}." }
- key: by-category
category: category
uri: "{category}"
- type: area
uri: "/{slug}"
nest: true
index: "/areas"
home: { type: page, slug: home } Run engine-dmo check --fix to write each landing’s id (lnd_), then engine-dmo schema plan and schema apply. The pages appear once the engine has counted them, which a running server does in the background (see When a page is live): before that, engine-dmo render of a landing page’s address answers 404.
Landing keys
Each entry under a route’s landings: takes these keys.
| Key | Required | Default | Meaning |
|---|---|---|---|
key | yes | The landing’s handle on the route: lowercase letters, digits and hyphens, unique on the route | |
id | written by check --fix | The stable id (lnd_ and ten characters). Everything stored about the landing’s pages names it, so renaming key keeps them. | |
place | for a place and category landing | A facet of the route that is a single relation to one document type (such as area). Not a relation to tag, not multiple: true. | |
category | for a place and category landing, and a category page | A facet of the route that is a select, or a relation to one document type (not tag). Not the same facet as place. | |
month | for a month page | The name of the type’s recurrence field. A month page takes no place and no category. | |
uri | yes | The page’s path pattern, one string for every locale or one per locale. See Addresses. | |
slugs | no | the value itself (a select), the related document’s slug (a relation), the month’s name in the locale (a month page) | Each category value’s path segment per locale. A month page’s slugs are keyed january to december. |
min_results | no | 3 | The fewest published results a page needs to be live. At least 1. |
content | no | none | A project type an editor fills in for each page; see Editors’ copy |
title | no | {category}, {place}; a category page {category}; a month page {month} | The generated title pattern, one string or one per locale |
description | no | none | The generated description pattern, one string or one per locale |
Placeholders in title and description
| Placeholder | Replaced by |
|---|---|
{place} | The place’s title in the page’s locale |
{category} | The category value’s label in the locale (for a month page, the month’s name) |
{category_lower} | The same label lower-cased by the locale’s rules |
{month} | The month’s name in the locale (month pages) |
{count} | The number of published results |
{examples} | The titles of the first three results, separated by commas (description only; a listed page’s own title in entry.landings and landing.siblings has none) |
Addresses
| Kind | uri must hold | Result |
|---|---|---|
| Place and category | {place} and {category}, and nothing else in braces | {place} is replaced by the place’s own path on the site (with its parents, for a nested place), so "{place}/{category}" gives /wynd-valley/wyndcombe/places-to-stay |
| Category page | {category} alone | The path under the route’s index in the locale: /places/places-to-stay |
| Month page | {month} alone | The path under the route’s index: /whats-on/may |
uri needs a value for every locale the site enables when it is given per locale. Each slug must be one path segment of letters, digits and hyphens, in any script. check warns when a select category has slugs for some values or locales and not others (the value itself is used where a slug is missing), and when a slug names a locale the site does not enable.
A category page’s address has the same shape as the route’s own documents (/places/{slug}). A document whose slug equals a category’s slug wins and the page is not reachable, so choose slugs that no document uses. Two category pages of the same shape under one index may coexist only if no slug is shared between them; a relation category cannot share a shape, because its slugs are documents’ slugs.
Rules check enforces
- The route must have an
index:: a landing page is the index filtered. placeandcategorymust already be in the route’sfacets:.- The place’s type must be routed on the same site, since a landing page hangs under its place’s page.
- A
selectcategory’sslugsmay only name values the field has; a month page’s onlyjanuarytodecember. - A landing’s
contenttype must exist and must not be routed on any site: its address is the landing page’s.
When a page is live
The engine recomputes a site’s landing pages as a background job:
- when anything published on the site changes;
- when routes move (a renamed slug, a moved place);
- at every start;
- every 24 hours on a site with a month page, since time alone moves a month’s upcoming dates.
flowchart LR
change["Publish, unpublish,<br/>route change or start"] --> job["Recompute job<br/>(queued per site)"]
job --> count["Count each pairing's<br/>published results"]
count --> enough{"at least<br/>min_results?"}
enough -->|yes| live["Live: served,<br/>in the sitemap"]
enough -->|no| gone["Not live: 301 to the place<br/>or the index, if it was live"]The job runs inside engine-dmo serve, like every background job. A command that renders without a server (engine-dmo render, an agent’s verification loop) sees only what the last run counted, so on a fresh database with freshly seeded content every landing page answers 404 until a server has run. Start engine-dmo serve against the database, give it a few seconds (engine-dmo jobs list shows the recompute while it is queued), stop it, and render again; the counts stay in the database. seed apply counts the pages itself only when it seeds a promotion that needs them.
Each place and category pairing (each value, for a category page) with at least min_results published results is live. A month page counts the events with an occurrence from the start of the month (or from today, when the month is under way) to its end, in the site’s timezone; once a month has passed, its page stands for the same month next year.
The engine keeps a permanent (301) redirect wherever a page that was once live goes away, so links and search results keep working:
| What happened | The old address redirects to |
|---|---|
| The page’s address changed (a slug, pattern or place moved) | The new address |
The page fell below min_results | The place’s own page; for a category or month page, the route’s index |
The landing was removed from routes.yaml | The place’s own page; for a category or month page, the route’s index |
When the page is live again at that address, the redirect is switched off. These redirects appear in the admin’s redirect table with the origin auto_route.
Live pages are listed in the site’s sitemap, in every locale the site enables.
The template
A landing page renders with <type>/landing.html (listing/landing.html) when the site’s template chain has it, and with the route’s index template (listing/index.html) when it does not. It has everything an index page has (items, pagination, facets) and the landing context below. page.landing is true, and page.title is the generated title, so head() prints the title, description and canonical and the template writes only its body, with its one h1:
{% extends "base.html" %}
{% block body %}
<nav aria-label="{{ t('crumbs.label') }}">
<ol>{% for c in landing.breadcrumbs %}<li>{% if c.current %}<span aria-current="page">{{ c.title }}</span>{% else %}<a href="{{ c.uri|url }}">{{ c.title }}</a>{% endif %}</li>{% endfor %}</ol>
</nav>
<h1>{{ landing.heading }}</h1>
{% if landing.description %}<p>{{ landing.description }}</p>{% endif %}
{% if landing.content %}{% blocks landing.content.body %}{% endif %}
<ul class="cards">
{% for listing in items %}
<li data-mrd-impression="{{ listing.id }}"><a href="{{ listing.uri|url }}">{{ listing.title }}</a></li>
{% endfor %}
</ul>
{{ impressions() }}
{% if landing.faq %}
<h2>{{ t('landing.questions') }}</h2>
<dl>{% for qa in landing.faq %}<dt>{{ qa.question }}</dt><dd>{{ qa.answer }}</dd>{% endfor %}</dl>
{% endif %}
{% if landing.siblings %}
<h2>{{ t('landing.more') }}</h2>
<ul>{% for s in landing.siblings %}<li><a href="{{ s.uri|url }}">{{ s.title }}</a> ({{ s.count }})</li>{% endfor %}</ul>
{% endif %}
{% endblock %} The landing context
| Property | What it holds |
|---|---|
landing.key | The landing’s key |
landing.pageKey | The page’s own key, which promoted('slot', landing) reads for a slot sold per landing page |
landing.place | The place as an entry (title, uri, url and its fields); null on a category or month page |
landing.category | value, label and slug; null on a month page |
landing.month | On a month page: number (1 to 12), name, slug, from, to, start and year, in the site’s timezone; otherwise null |
landing.title | The page title: the generated one, the content document’s title, or its SEO title (in that order of precedence, last wins) |
landing.heading | The heading: the generated title, or the content document’s title |
landing.description | The generated description, or the content document’s SEO description |
landing.content | The content document as an entry, when an editor wrote one; otherwise null |
landing.faq | The content document’s questions as a list of question and answer (plain text); empty when there are none |
landing.count | The number of published results |
landing.uri, landing.url | The page’s path, and its absolute address |
landing.siblings | The place’s other live landing pages (for a category page, the landing’s other live pages). Each item: title, uri, url, count, landing, category, place, month. |
landing.parents | The same category’s live pages in the places above this one, root first |
landing.breadcrumbs | Home, the place’s ancestors, the place (or, on a category page, the index), then the page with current: true. Each crumb: title, uri, url. |
landing.jsonld | The page’s structured data: the page, its breadcrumbs, its results as an ItemList and, where the content type maps them, its questions as an FAQPage. The core head output uses it, so the template need not print it. |
A visitor who adds a facet beyond the page’s own values sees the filtered results at a query string on the page’s address; that filtered view is noindex and its canonical is the page itself.
Elsewhere, entry.landings lists a place’s live pages and, on a listed document, the pages it is a result of (see Directory).
Editors’ copy
Give a landing content: <type> to let editors write their own words for each page. The type is an ordinary project type with no route; the engine keys each document to its page by id and adds no field of its own.
key: landing_page
label: { en-GB: Landing page }
plural: { en-GB: Landing pages }
fields:
- name: title
type: text
max: 120
label: { en-GB: Page title }
- name: body
type: blocks
label: { en-GB: Content }
blocks:
- key: richText
label: { en-GB: Text }
fields:
- { name: content, type: portable_text, label: { en-GB: Text } }
- key: faq
label: { en-GB: Questions and answers }
fields:
- name: items
type: repeater
max: 12
label: { en-GB: Questions }
fields:
- { name: question, type: text, required: true, max: 200, label: { en-GB: Question } }
- { name: answer, type: portable_text, label: { en-GB: Answer } }
- { name: meta, type: seo, label: { en-GB: Search engines } }
structured_data:
faq: { items: body.faq.items, question: question, answer: answer } - key: by-place
place: area
category: category
uri: "{place}/{category}"
content: landing_page How the content document is used:
- its non-empty
titlereplaces the generated title and heading; - its first
seofield’stitleanddescriptionreplace the page title and description, and its social title and description become the page’s; - its questions, found through the type’s
structured_data.faqmapping, becomelanding.faqand the FAQPage in the structured data; - everything else is yours to render from
landing.content({% blocks landing.content.body %}above).
Editors write the copy from the place’s editor in the admin: the Pages for panel lists one row per category of each landing whose place type it is, with its result count, whether it is live and whether it has content, and a button that creates the content document (starting from the page’s generated title) or opens the one that exists. Category and month pages have no place, so they have no such panel; their copy is written through the content MCP, whose landing_content_get and landing_content_set tools cover every kind of landing page (see The content MCP).
Selling a place on a landing page
A promotion slot with scope: landing sells one inventory per live landing page, priced per place and category. See Promotions.
Troubleshooting
| Symptom | Cause |
|---|---|
type listing declares landings but has no index route | Add index: to the route. |
place "area" is not a facet of the listing route | Add the field to the route’s facets:. |
place "area" must be a single relation to one document type, such as area | The field is not a relation, relates to tag, has several targets, or is multiple. |
place area relates to area, which has no route on site main | Route the place type on the same site. |
landing uri "..." needs both {place} and {category} | A place and category landing’s uri must contain both placeholders and nothing else in braces. |
landing by-place has no uri for locale fr-FR | A per-locale uri needs every enabled locale. |
min_results must be at least 1 | A page with nothing in it is not allowed. |
| A page you expect is missing | It has fewer than min_results published results on that site, or a document with the same address shadows a category page. |
Directory
The listing and area types engine-dmo adds, how to extend them, route and facet them, link a listing to the business behind it, and show places, maps, nearby results and enquiries in templates.
CRM
Organisations, people and the links between them; custom fields on both; the timeline, tags and smart lists of organisations and people; broadcasts to member contacts; the CSV and XLSX import, merging, duplicates, trash and erasure; and every CRM command.