SEO and structured data
What head() and jsonld() print, how titles, descriptions, canonical URLs, hreflang, robots and Open Graph are decided, how to map a content type to schema.org, and how the sitemap and robots.txt are configured.
You do not hand-write metadata or JSON-LD. Two calls in your layout print it, from the page, the document, the site’s configuration and the type’s schema.org mapping:
head()prints the title, the meta description, the canonical link, hreflang alternates, the robots directive,rel=prevandrel=next, and the Open Graph and Twitter card tags.jsonld()prints the page’s structured data as one JSON-LD@graphwhose entities reference each other by@id.
The sitemap and robots.txt are generated from site.yaml.
A minimal setup
{# templates/_shared/base.html #}
<!doctype html>
<html lang="{{ locale.code }}" dir="{{ locale.direction }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{ head() }}
{{ jsonld() }}
{{ feedLinks() }}
{{ assets.css('main') }}
</head>
<body>{% block body %}{% endblock %}</body>
</html> # config/sites/main/site.yaml
seo:
title_template: "{{ title }} | {{ site.name }}"
default_image: /img/share.jpg
sitemap: { enabled: true }
robots: { enabled: true }
structured_data:
organisation: { type: TouristInformationCenter, logo: /logo.svg } # config/schema/event.yaml (a type's mapping to schema.org)
structured_data:
type: Event
map:
description: description
image: heroImage
offers: bookingUrl
location: venue
organizer: { value: "Riverside Arts Centre" } engine-dmo check warns when a layout’s <head> has no head() and its own tags have no meta description. The base.html that engine-dmo init writes has neither, so a new project prints this warning until you replace its <title> and canonical link with {{ head() }}. The warning is not counted in the check’s summary line (Testing).
head()
head() prints, in this order:
| Tag | Source |
|---|---|
<title> | The document’s seo field title where written; else the page title through seo.title_template; else the site’s name |
<meta name="description"> | The page’s description (an index or landing page’s own); else the document’s seo field description where written; else the document’s summary field as plain text; left out when empty. Whitespace is collapsed |
<link rel="canonical"> | page.canonical |
<link rel="alternate" hreflang> | page.alternates, plus x-default, only when the page exists in more than one locale on this site |
<meta name="robots"> | index, follow, max-image-preview:large, or noindex, follow (below) |
<link rel="prev">, <link rel="next"> | The pagination’s neighbours, absolute |
og:type | article when the type maps to a creative work (an Article, say), else website |
og:title, og:description | The seo field’s og_title and og_description where written, else the title and description above |
og:url, og:site_name, og:locale | The canonical, the site’s name, the locale with an underscore (en_GB) |
og:image, og:image:width, og:image:height, og:image:alt | The image the type’s structured_data maps as image, else seo.default_image (below) |
twitter:card | summary_large_image with an image, else summary. Twitter reads the Open Graph tags, so no twitter:title is printed |
The response also carries Content-Language with the page’s locale.
A type with no summary field and no seo field has no description. The core page type declares only title and slug, so extend it with a summary (engine-dmo dev field add page summary --type text --param multiline=true --apply) or an seo field before you rely on head() for the home, about and contact pages.
Titles
seo.title_template takes two placeholders, written exactly as {{ title }} and {{ site.name }} (with or without the inner spaces). Nothing else in it is evaluated. A page without a title gets the site’s name.
To let editors write a search title and description per document, give the type a field of type seo (the seo field). It stores title, description, og_title and og_description, and warns editors past 60 and 160 characters (title_max, description_max).
entry.seo gives the same decisions to a template that writes its own tags: title (the title template applied), description (the summary as plain text), image (the site’s default image, absolute), noindex and canonical. A type that declares its own field named seo keeps its field’s value there instead.
The share image
The Open Graph image is the image field the type’s structured_data.map names as image. When config/images.yaml defines a transform named social, the image is cut through it (its first width) and og:image:width and og:image:height are printed; otherwise the original is shared. A common definition:
# config/images.yaml
transforms:
social:
width: 1200
aspect: "1.91:1"
formats: [jpeg] Without a mapped image, seo.default_image (a path on the site) is shared, absolute.
width: 1200 makes social a fixed-width transform, which is right for a share image of one known size. A transform written with widths: { min, max } instead is accepted too; the share image is then its first width. Both forms are described under A transform.
Canonical URLs
page.canonical is absolute, on the site’s first configured domain with the scheme of server.base_url, never the host the request came in on.
| Page | Canonical |
|---|---|
| A document | Its own address |
| A document shared to a second site | Its address on the site that owns it, in the same locale, when it has a live page there |
| An index page | The unfiltered index (with its page number) |
| A filtered index page | The most specific landing page whose filters it holds, else the unfiltered index |
| A landing page | Itself |
hreflang
Alternates list only locales in which the page is published with a live address on this site, never another site’s pages. x-default is the site’s default locale when the page exists in it, else the first alternate. With one locale, no alternates are printed. Each item of page.alternates has locale, hreflang (the code in lower case), url and path.
noindex
The robots directive is noindex, follow when any of these holds; none is configurable:
- the page is a filtered view of an index (any facet selected);
- the page is a preview;
- the site is in embed-only mode (
embed.only), which also sendsX-Robots-Tag: noindex, follow; engine.environmentisstaging.
page.noindex is true for a filtered index and a preview.
jsonld()
jsonld() prints one <script type="application/ld+json"> holding the page’s @graph:
| Node | @id | When |
|---|---|---|
WebSite | {root}/#website | Every page; with a SearchAction where the site routes search, unless structured_data.search: false |
| The publisher | {root}/#organization | Every page, unless structured_data.organisation: false |
WebPage (CollectionPage on an index, a guide, a collection or a landing page) | {canonical}#webpage | Every page; isPartOf the site and about the document’s entity, with datePublished and dateModified |
BreadcrumbList | {canonical}#breadcrumb | Home, the document’s ancestors and the page, unless structured_data.breadcrumbs: false |
| The document’s entity | {canonical}#place, #event, #article, #organization or #thing by its types | A document whose type has a structured_data mapping |
| Further event dates | {canonical}#event-{local start} | Up to twelve upcoming dates in all, each with its eventStatus |
ItemList | A guide’s or a collection’s items, and a landing page’s results, each item referencing its entity where it has one | |
FAQPage | A type that maps questions (structured_data.faq) |
Every URL in it is on the configured domain. jsonld(entry) prints one document’s entity alone, for a card or a fragment. Under engine-dmo serve --dev the graph is checked against the vocabulary as it is printed, and an unknown type or property fails the render.
Guides and collections also carry the graph as entry.jsonld, and landing pages as landing.jsonld. Print either with the json filter inside a script element, if you build the head yourself:
<script type="application/ld+json">{{ entry.jsonld|json }}</script> Site-wide nodes
# config/sites/<key>/site.yaml
seo:
structured_data:
organisation:
type: TouristInformationCenter # an Organization subtype; Organization when not given
name: Visit Wyndmouth # site.organisation, else the site's name, when not given
logo: /logo.svg # a path on the site or an absolute URL
same_as: ["https://example.org/wyndmouth"]
breadcrumbs: true
search: true | Key | Values | Default |
|---|---|---|
organisation | true, false, or a map of type, name, logo, same_as | true |
breadcrumbs | boolean | true |
search | boolean | true |
A type that is not an Organization is an error: PerformingArtsTheater is not an Organization; the site's publisher is one, such as TouristInformationCenter.
Mapping a type to schema.org
A type’s structured_data block in its schema file declares what its documents are:
structured_data:
type: TouristAttraction # a schema.org type, or a list
subtype: # an extra type by a select field's value
field: category
values: { stay: LodgingBusiness, eat-drink: Restaurant, shop: Store }
map:
description: description
image: heroImage
geo: location
address: address
telephone: phone
sameAs: website
openingHoursSpecification: openingHours
amenityFeature: accessibility
isAccessibleForFree: true
eventAttendanceMode: { value: "https://schema.org/OfflineEventAttendanceMode" }
faq: { items: body.faq.items, question: question, answer: answer } | Key | Meaning |
|---|---|
type | A schema.org type or a list of them. A general type gives way to a more specific one: [Place, Restaurant] is Restaurant |
subtype.field, subtype.values | A select field and a map of its values to extra types |
map | Each line is a schema.org property and its source: a field name, "@self" for the canonical URL, true, false, a number, or { value: ... } for any other literal |
faq | Questions and answers: items is a dotted path through repeaters and blocks (a blocks field is followed by the block’s key) to the items, question and answer the item fields. engine-dmo check resolves the path against the schema |
Choosing a type
Pick the most specific schema.org type that describes what a visitor would go to, do or read. The entity’s @id suffix follows from the type, so other nodes can reference it:
| What the document is | type | @id |
|---|---|---|
| A place to visit: an attraction, a viewpoint, a park, the start of a walk | TouristAttraction (or Park, Museum, LandmarksOrHistoricalBuildings) | #place |
| A business: somewhere to stay, eat or shop | LodgingBusiness, Restaurant, Store, or LocalBusiness with a subtype | #place |
| A walk, trail or itinerary described as a journey | TouristTrip | #thing |
| Something on at a time | Event | #event |
| A story, guide text or blog post | Article (NewsArticle, BlogPosting) | #article |
| An organisation’s own page | Organization or a subtype | #organization |
A walking route can reasonably be either: TouristAttraction with geo mapped to its start point puts it on the map as a place, and TouristTrip describes the journey. Choose one; a list of both ([TouristAttraction, TouristTrip]) gives one node both types, with the #place id.
# config/schema/walk.yaml
structured_data:
type: TouristAttraction
map:
description: summary
image: heroImage
geo: startPoint Without a line of its own the engine fills name from the title and url from the canonical address; on a creative work headline, datePublished, dateModified and inLanguage; on a place containedInPlace, the place above it in the hierarchy; on an event its dates and status from its occurrences.
What each field type gives
| Field type | Value in the graph |
|---|---|
text, slug | The text. For a property whose value is a node (organizer, author, location and the like) a node of the property’s first type named by it; for address a PostalAddress with it as the street |
portable_text | Its words as plain text |
select | Its label in the page’s locale (labels for a multiple select); for amenityFeature, a LocationFeatureSpecification each |
number, boolean | As it is |
date, datetime | The date, or the local time with its offset |
phone | E.164 |
money | An Offer with price and priceCurrency |
asset | The image’s absolute URL, or a list |
link | The URL; for offers, an Offer with it as url |
geo | GeoCoordinates; for location, a Place with them |
address | A PostalAddress; for geo, its point; for location, a Place with both |
opening_hours | The OpeningHoursSpecification list for the coming week, seasons and exceptions resolved |
relation | A reference ({"@id": ...}) to the related document’s entity when its type fits the property, else a node with its name and URL |
A repeater, blocks, form, seo, video or geo shape field cannot be a source. A property the document’s own types do not take is left out of its entity.
Validation
engine-dmo check validates every mapping against the schema.org vocabulary the engine ships:
| Problem | Severity | Example |
|---|---|---|
| Unknown type or property | Error | "headlin" is not a schema.org property in the vocabulary the engine validates against (2026-10.1), with a “did you mean” |
| Property on a type that does not take it | Error | starRating is not a property of Article; it applies to FoodEstablishment, LodgingBusiness |
| A field that can never fill the property | Error | A date mapped to organizer |
A missing field, or an faq path step that does not exist | Error | At its line |
| A property a rich result requires or recommends is not mapped | Warning | the structured_data mapping of event has no organizer, which the event rich result recommends |
The rich-result warnings are guidance from a dataset versioned with the engine, never a guarantee.
The sitemap
seo:
sitemap:
enabled: true
max_urls_per_file: 10000 | Key | Default | Meaning |
|---|---|---|
enabled | false | Serve the sitemap |
max_urls_per_file | 50,000 | Above this many URLs, the sitemap becomes a sitemap index of numbered files (/sitemap.xml?page=2); at most 50,000 |
It is served at /sitemap.xml, or at the path special.sitemap gives in routes.yaml, with Cache-Control: public, max-age=3600. It lists every document with a live page on the site in each enabled locale (lastmod the later of its locale’s and its shared revision), each type’s index page in every enabled locale, and the live landing pages. A page in more than one locale lists its alternates on this site as xhtml:link. A site in embed-only mode lists nothing. The sitemap lists pages only: it has no image or video entries. A filtered index page (any facet selected) is never listed, since it is noindex; to keep a whole path out of search engines, use robots.disallow.
robots.txt
seo:
robots:
enabled: true
disallow: ["/search"]
allow: ["/search/help"]
agents:
- { user_agent: GPTBot, disallow: ["/"] } | Key | Meaning |
|---|---|
enabled | Generate robots.txt; without it, a robots.txt file in public/ is served as it is |
disallow, allow | Paths for every crawler |
agents | A group of its own for each named crawler (user_agent, disallow, allow), written after the group for every crawler |
The admin, the member portal (with the portal module) and the tracked-redirect paths are always disallowed, and repeated in every named group, since a crawler that finds its name reads only its own group. The sitemap is declared when it is enabled. A staging environment serves a blanket Disallow: /. engine-dmo check refuses a rule that is not a path, a group for *, a crawler named twice and a group that says nothing.
Feeds
feedLinks() in the head prints the <link rel="alternate"> tags of the site’s feeds in the request locale, for feed readers to discover. Feeds are configured on a route (routes reference); see Links and URLs for feedUrl().
Checking the output
engine-dmo render /whats-on/film-club --jsonreturns the page’s HTML with the head and graph as printed (Testing).- A template test can assert on the graph:
jsonld: { "@type": "Event", "name": "Film Club" }passes when some object in the page’s JSON-LD holds those values.
Related
- Site configuration reference: every
seokey. - Landing pages: their own titles, descriptions and graphs (
engine-dmo). - Images: the
socialtransform.
Links and URLs
How a template builds links with path(), url(), siteUrl() and localeUrl(), prints an entry's address, renders link and asset fields, and why every variable in href or src goes through the url filter.
Forms in templates
How a template draws a form that posts to a declared endpoint (form field render(), enquiryForm(), subscribeForm() or hand-built markup), carries the CSRF token and abuse controls, and renders the success and error responses.