Meridian handbook
Destination platform (engine-dmo)

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

This feature is part of the DMO bundle (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:

KindDeclared withPage perAddress
Place and categoryplace: and category:each place with each category valueunder the place’s own address: /wynd-valley/places-to-stay
Category pagecategory: aloneeach category valueunder the route’s index: /places/places-to-stay
Month pagemonth: alone, on a route whose type has a recurrence fieldeach calendar month with upcoming occurrencesunder 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.

config/sites/main/routes.yaml
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.

KeyRequiredDefaultMeaning
keyyesThe landing’s handle on the route: lowercase letters, digits and hyphens, unique on the route
idwritten by check --fixThe stable id (lnd_ and ten characters). Everything stored about the landing’s pages names it, so renaming key keeps them.
placefor a place and category landingA facet of the route that is a single relation to one document type (such as area). Not a relation to tag, not multiple: true.
categoryfor a place and category landing, and a category pageA facet of the route that is a select, or a relation to one document type (not tag). Not the same facet as place.
monthfor a month pageThe name of the type’s recurrence field. A month page takes no place and no category.
uriyesThe page’s path pattern, one string for every locale or one per locale. See Addresses.
slugsnothe 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_resultsno3The fewest published results a page needs to be live. At least 1.
contentnononeA project type an editor fills in for each page; see Editors’ copy
titleno{category}, {place}; a category page {category}; a month page {month}The generated title pattern, one string or one per locale
descriptionnononeThe generated description pattern, one string or one per locale

Placeholders in title and description

PlaceholderReplaced 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

Kinduri must holdResult
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} aloneThe path under the route’s index in the locale: /places/places-to-stay
Month page{month} aloneThe 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.
  • place and category must already be in the route’s facets:.
  • The place’s type must be routed on the same site, since a landing page hangs under its place’s page.
  • A select category’s slugs may only name values the field has; a month page’s only january to december.
  • A landing’s content type 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 happenedThe old address redirects to
The page’s address changed (a slug, pattern or place moved)The new address
The page fell below min_resultsThe place’s own page; for a category or month page, the route’s index
The landing was removed from routes.yamlThe 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:

templates/_shared/listing/landing.html
{% 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

PropertyWhat it holds
landing.keyThe landing’s key
landing.pageKeyThe page’s own key, which promoted('slot', landing) reads for a slot sold per landing page
landing.placeThe place as an entry (title, uri, url and its fields); null on a category or month page
landing.categoryvalue, label and slug; null on a month page
landing.monthOn a month page: number (1 to 12), name, slug, from, to, start and year, in the site’s timezone; otherwise null
landing.titleThe page title: the generated one, the content document’s title, or its SEO title (in that order of precedence, last wins)
landing.headingThe heading: the generated title, or the content document’s title
landing.descriptionThe generated description, or the content document’s SEO description
landing.contentThe content document as an entry, when an editor wrote one; otherwise null
landing.faqThe content document’s questions as a list of question and answer (plain text); empty when there are none
landing.countThe number of published results
landing.uri, landing.urlThe page’s path, and its absolute address
landing.siblingsThe 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.parentsThe same category’s live pages in the places above this one, root first
landing.breadcrumbsHome, 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.jsonldThe 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.

config/schema/landing_page.yaml
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 }
config/sites/main/routes.yaml (the landing)
      - key: by-place
        place: area
        category: category
        uri: "{place}/{category}"
        content: landing_page

How the content document is used:

  • its non-empty title replaces the generated title and heading;
  • its first seo field’s title and description replace the page title and description, and its social title and description become the page’s;
  • its questions, found through the type’s structured_data.faq mapping, become landing.faq and 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

SymptomCause
type listing declares landings but has no index routeAdd index: to the route.
place "area" is not a facet of the listing routeAdd the field to the route’s facets:.
place "area" must be a single relation to one document type, such as areaThe 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 mainRoute 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-FRA per-locale uri needs every enabled locale.
min_results must be at least 1A page with nothing in it is not allowed.
A page you expect is missingIt has fewer than min_results published results on that site, or a document with the same address shadows a category page.