Every page file in your repository opens with a block between --- fences. CMS-written .md pages and hand-authored .hbs pages both have one, and in both cases the block is JSON, not YAML.
That distinction is the first thing to internalize, because it fails badly. A trailing comma, an unquoted key or a single-quoted string is not front matter with a warning — it is a build error. And nothing catches it before then: leed site validate does not parse front matter at all, so a malformed block passes validation cleanly and then breaks the build. Write it as JSON, and let your editor’s JSON linter do the work.
The shape
The CMS serializes a published page as the front-matter fences, a two-space-indented JSON object, and the page body:
- CMS-written page
- Your own file
---
{
"authors": [
"usvc"
],
"disableCta": false,
"disableAutolink": false,
"labels": [
"pcyk31"
],
"keywords": [
"Content Management",
"Personalization"
],
"pageId": "ca9c8ebb-89cd-4426-b748-dc4cda9fa8f8",
"pageTypeId": "100111",
"title": "6 Top Reasons to Switch",
"slug": "6-top-reasons-to-switch",
"summary": "Businesses striving to stay ahead …",
"wordCount": 916,
"publishedAt": "2024-12-18T21:15:00.000Z",
"modifiedAt": "2024-12-18T21:05:37.359Z",
"previewOnly": false,
"eleventyExcludeFromCollections": false,
"aliases": []
}
---
Body content starts here.---
{
"title": "Compare Leed and Everything Else",
"pageTypeId": "110011",
"pageId": "6f0f9d5e-2a71-4a24-8d2f-4e6d1b4a9c07",
"publishedAt": "2026-09-01T12:00:00.000Z",
"modifiedAt": "2026-09-01T12:00:00.000Z",
"summary": "A feature-by-feature comparison."
}
---
{{#> site-template }}
<h1>{{ title }}</h1>
{{/site-template}}Keys appear in schema order, and the whole block is rewritten wholesale on every publish. Hand-editing a CMS-written page file is therefore pointless as well as blocked — your change survives until the next publish of that page and is then replaced by the serializer’s output. Page-type folders are read-only for exactly this reason; see Editable and Read-Only Files.
If you are here to write one file by hand, this table is the whole answer:
| Key | Example | What breaks without it |
|---|---|---|
title | "Compare Leed and Everything Else" | No <title>, no heading for templates that render it, nothing usable in listings |
pageTypeId | "110011" | No layout can be resolved — the build throws |
pageId | "6f0f9d5e-2a71-4a24-8d2f-4e6d1b4a9c07" | pageid: links to the page cannot resolve; analytics cannot attribute it |
publishedAt | "2026-09-01T12:00:00.000Z" | Sorts arbitrarily in collections, empty <lastmod>, empty <pubDate>, invalid JSON-LD |
modifiedAt | "2026-09-01T12:00:00.000Z" | Empty Atom <updated>, empty article:modified_time, no dateModified |
summary | "A feature-by-feature comparison." | No <meta name="description">, blank card and search-result blurbs — and a documentation page cannot be published at all |
The rest of this page is the complete reference.
Keys the CMS writes
Twenty-one keys, in schema order. Everything a published page carries is here — there is no hidden set.
Identity
pageId, pageTypeId, title and slug. pageId is the page’s permanent identity and is what pageid: links, analytics and the search index all key on. slug is the last segment of the URL and is derived by the CMS from the title, not chosen independently — renaming a page changes its URL.
Metadata and SEO
summary, keywords, ogCard, featureImage, featureVideo and wordCount. summary becomes the meta description and the blurb on cards and search results. ogCard is a {title, description, image} object for social previews. featureImage and featureVideo are asset references — {assetId, src, width, height, alt, svg} for an image — so a template gets everything it needs to render responsive markup without a second lookup. wordCount is computed on save and is what reading-time estimates are built from.
Relationships
authors and labels hold ids, not names — resolve them through the collection-lookup helpers rather than printing them raw. formId names the form a layout should render, which is how Placing a Form on a Page works. aliases is an array of full paths that should redirect to this page; it is always emitted, as [] when there are none, and it is how an old URL keeps working after a slug change — see Aliases and Redirects.
Dates
publishedAt, modifiedAt and plannedDate. The format is ISO-8601 UTC with milliseconds and a trailing Z — "2026-09-01T12:00:00.000Z" — because that is what JSON.stringify produces for a Date. A date-only YYYY-MM-DD never appears in this block, and you should not write one.
Writers guess wrong about which date does what, so here it is explicitly:
| Field | Read by |
|---|---|
publishedAt | Collection sort order; sitemap <lastmod>; RSS <pubDate>; Atom <published>; JSON Feed date_published; JSON-LD datePublished |
modifiedAt | Atom <updated>; JSON Feed date_modified; JSON-LD dateModified; the article:modified_time OG meta tag |
plannedDate | The editorial calendar and scheduling; display order in planning views |
Listing templates sort on publishedAt, and the comparison falls back to 0 when the value is missing — so every undated page compares equal to every other undated page and their relative order is decided by URL, not by anything you intended.
Neither date hides a page. Visibility is a build-inclusion question, not a date question: a publishedAt in the future does not hold a page back once it is in the repository.
Render switches
| Key | Default | What it does |
|---|---|---|
eleventyExcludeFromCollections | false | Keeps the page out of every Eleventy collection — no sitemap entry, no feed entry, no listing page |
disableAutolink | false | Suppresses auto-linking inside this page’s body, leaving the text exactly as written |
disableCta | false | Makes the CTA shortcode emit nothing on this page, so no dynamic call-to-action is injected |
previewOnly | false | Excludes the page from production builds. Written by Leed — not a control you set |
previewOnly is in this table because you will see it in every CMS-written file, not because it is a switch you should reach for. It has no CMS surface, it defaults to false, and setting it by hand on a page of your own is a quiet way to make that page disappear from your live site while it continues to work in preview. Leave it out of files you author.
disableAutolink and disableCta switch off the two site-wide injections, documented on Autolinks and Dynamic CTAs respectively.
The complete reference
| Key | Type | Always present? | Written from | What the build does with it |
|---|---|---|---|---|
pageId | string | Yes | Minted by the CMS on create | Keys pageid: link resolution, analytics, the search index |
pageTypeId | string | Yes | The page type chosen on create | Resolves the layout and the sitemap priority |
title | string, 2–150 chars | Yes | Page editor | <title>, headings, listing entries; the CMS derives slug from it |
slug | lowercase, dash-separated | Yes | Derived from title | The last segment of the URL |
summary | string | Yes (may be "") | Page settings | Meta description, card and search-result blurb |
keywords | string array | When set | Page settings | Keyword meta tags |
authors | string array of user ids | When set | Page settings | Bylines, via the user lookup helper |
labels | string array of label ids | When set | Page settings | Label pages, filtering, RBAC overrides |
featureImage | asset reference object | When set | Page settings | Hero image and social card fallback |
featureVideo | asset reference object | When set | Page settings | Hero video |
ogCard | {title, description, image} | When set | Page settings | Open Graph and Twitter card tags |
formId | string | When set | Page settings | The form the form partial renders |
wordCount | number | When set | Computed on save | Reading-time estimates |
publishedAt | ISO-8601 UTC | On every published page | Publish action or the schedule | Collection sort, sitemap, all three feeds, JSON-LD |
modifiedAt | ISO-8601 UTC | Yes | Every save | Atom <updated>, JSON Feed date_modified, JSON-LD, OG modified time |
plannedDate | ISO-8601 UTC | When set | Plan calendar | Editorial scheduling and planning order |
previewOnly | boolean | Yes | Leed | Excludes the page from production builds |
eleventyExcludeFromCollections | boolean | Yes | Page settings | Removes the page from every collection |
disableAutolink | boolean | Yes | Page settings | Skips auto-linking on this page |
disableCta | boolean | Yes | Page settings | Suppresses the CTA shortcode on this page |
aliases | string array of paths | Yes ([] when empty) | Path history | Generates redirects to this page |
| passthrough keys | anything | When present | Import paths | Re-emitted verbatim at the top level |
That last row is extraFrontmatter: a passthrough channel Leed uses to carry keys it does not model, such as the flattened OpenAPI blob on generated API pages and menuId. It is written by import paths, not by you, and it is emitted first, so a modeled key always wins on a collision — nothing carried through can overwrite title or slug.
A CMS-written page with every optional key populated
{
"authors": ["usvc", "b93t"],
"disableCta": false,
"disableAutolink": false,
"labels": ["pcyk31"],
"keywords": ["Content Management", "Personalization"],
"featureImage": {
"assetId": "zrqm1y5l",
"src": "/cdn-cgi/imagedelivery/CkSmQAGqpZ-mcWDDI6mu0w/e4feea80-1c0d/original",
"width": 2100,
"height": 1500,
"alt": "A large orange number six in a modern office setting.",
"svg": false
},
"featureVideo": { "assetId": "b21kd0aa", "videoId": "9f2c…" },
"ogCard": {
"title": "6 Top Reasons to Switch",
"description": "Why teams move to a generative CMS.",
"image": "/static/images/og/switch.png"
},
"pageId": "ca9c8ebb-89cd-4426-b748-dc4cda9fa8f8",
"pageTypeId": "100111",
"title": "6 Top Reasons to Switch",
"slug": "6-top-reasons-to-switch",
"summary": "Businesses striving to stay ahead of the competition …",
"wordCount": 916,
"formId": "resource-download",
"publishedAt": "2024-12-18T21:15:00.000Z",
"plannedDate": "2024-12-15T00:00:00.000Z",
"modifiedAt": "2024-12-18T21:05:37.359Z",
"previewOnly": false,
"eleventyExcludeFromCollections": false,
"aliases": ["/blog/six-reasons-to-switch/"]
}These keys land in a file only when a page is published. Nothing about a draft appears in your repository — see How Publishing Works.
Keys your own pages must supply
Six: title, pageTypeId, pageId, publishedAt, modifiedAt, summary.
That is not a guess or a recommendation. It is what all 361 pages of a shipped Leed documentation site carry, and what the seed fixture’s pages carry. Everything else is optional and does something specific when present.
---
{
"title": "Compare Leed and Everything Else",
"pageTypeId": "110011",
"pageId": "6f0f9d5e-2a71-4a24-8d2f-4e6d1b4a9c07",
"publishedAt": "2026-09-01T12:00:00.000Z",
"modifiedAt": "2026-09-01T12:00:00.000Z",
"summary": "A feature-by-feature comparison of Leed against the alternatives."
}
---| Key | Required? | Example | What breaks without it |
|---|---|---|---|
title | Yes | "Compare Leed and Everything Else" | Empty <title> and empty listing entries |
pageTypeId | Yes | "110011" or "###ROOT###" | The build throws — no layout can be resolved |
pageId | Yes | a UUID v4 | pageid: links to the page do not resolve; no analytics attribution |
publishedAt | In practice | "2026-09-01T12:00:00.000Z" | Arbitrary sort order, empty <lastmod> and <pubDate>, invalid JSON-LD |
modifiedAt | In practice | "2026-09-01T12:00:00.000Z" | Empty Atom <updated> and article:modified_time |
summary | For documentation page types, yes | one sentence | No meta description; publish is refused for a documentation page |
layout | No | "blog.hbs" or null | Falls back to the page type’s layout |
permalink | No | "404.html" | Eleventy’s default URL from the file path is used |
keywords | No | ["comparison"] | Nothing; keyword meta tags are simply absent |
There is no category key and no order key in Leed front matter. Neither exists in any schema. Nesting comes from where the file sits under src/; ordering in a documentation set comes from the menu. If you have seen a “five keys: title, summary, keywords, category, order” description of Leed front matter, it is wrong.
Why the two dates are effectively required
Neither date is enforced. publishedAt is nullable in the database and the build will not stop without it. But an undated page is a page that behaves badly everywhere it is aggregated: it sorts arbitrarily against every other undated page, its sitemap <lastmod> is empty, its feed entries have empty dates, and its JSON-LD datePublished is invalid — which is a structured-data error a search engine will report back to you.
The convention worth adopting: set publishedAt to a clean round hour on the day the page goes live, keep modifiedAt greater than or equal to it, and bump modifiedAt when you substantively change the page. modifiedAt is non-null in the revision table and is always present on a CMS-written file, so a hand-authored page without one is the odd file out.
summary is optional in the schema and required at publish
For a documentation page type, requiredFields.summary defaults to true, and the publish action enforces it: a page without a summary is refused with a 400 carrying "Missing required fields" and the list of what is missing. posts page types default to requiring summary, featureImage and keywords; api page types require none of them.
requiredFields is a per-page-type setting, not a global rule, and it is editable — see Configuring a Page Type. Either way, treat summary as required for real reasons rather than validation ones: it is the <meta name="description">, the card blurb and the search-result blurb, so an empty one is a visible loss, not just a failed check.
There is no path or URL key
Nothing in front matter sets a page’s address. Not path, not url, not category. A page’s URL comes entirely from where its file sits under src/, and that mapping is a hard invariant rather than a convention. It has its own page: Page Paths and Folder Structure.
permalink is the one exception, and it is Eleventy’s key rather than Leed’s: "permalink": "404.html" is how the scaffold’s 404 page lands at a fixed filename. Use it for genuine special cases, not for routing ordinary pages.
Every pageId in the build must be unique
The build assembles a map from pageId to rendered path, and every pageid: link in the site is resolved through it. A map has one entry per key, so two pages sharing a pageId means every link to either of them lands on whichever rendered last — silently, with no warning and no build failure. It is a bug you find by clicking, weeks later.
Generate a real UUID v4 for each hand-authored page. Never copy one from another file, and never leave a placeholder in. (Leed’s own scaffold uses the literal "404" for the 404 page, which is unique and never linked to — that is the exception that proves the rule, not a pattern to copy.)
A pageid: link whose target does not exist in the map at all is handled differently and more visibly: the anchor is replaced by a <span class="page-removed"> carrying the link text. If you see one of those in a built page, the target was never published.
The ###ROOT### sentinel
Root-level pages — the ones that live directly under src/ rather than in a page-type folder — belong to a page type whose slug is empty. Its id differs per site, so hard-coding it makes a template unportable. Write the sentinel instead:
{ "pageTypeId": "###ROOT###" }At build time that resolves to whichever page type has an empty slug, or the legacy slug root. Leed’s own portable templates use it for exactly this reason.
If no such page type exists, the sentinel does not resolve, the lookup misses, and the build fails with PageType ###ROOT### has no layout defined! — which is a confusing message for the real problem, namely that the site has no root page type at all.
uniquePageId is computed for you
Paginated listing pages render many URLs from one source file, so they need an identifier per rendered page rather than per source file. uniquePageId is that identifier. You supply pageId; the build derives the rest.
| Page kind | Format | Example |
|---|---|---|
| Not paginated | the pageId itself | 6f0f9d5e-2a71-… |
Single-layer paginated (:paged) | list-<pageId> on the final page, list-<pageId>-<n> while a next page exists | list-6f0f9d5e-…-1 |
Double-layer paginated (:labels, :authors) | list-<pageId>-<listName>, with -<n> on non-final pages | list-6f0f9d5e-…-tutorials-2 |
Collections and the pagination data that produces these are on Collections and Pagination Data.
Your own keys
Anything else you put in the block is available in the template. This — arbitrary front-matter keys — is what “custom fields” means in Leed. There is no customFields schema field to configure and nothing to register first; you write a key, you read it in the template.
---
{
"title": "Pricing",
"pageTypeId": "###ROOT###",
"pageId": "0d8b0f1a-59f0-4a4a-9f0e-2f6e4a2b1c33",
"publishedAt": "2026-09-01T12:00:00.000Z",
"modifiedAt": "2026-09-01T12:00:00.000Z",
"summary": "Plans and pricing.",
"heading": "Simple pricing",
"subtitle": "Start free. Upgrade when you outgrow it.",
"items": [
{ "name": "Free", "price": "$0" },
{ "name": "Starter", "price": "$250" }
]
}
---
<h1>{{ heading }}</h1>
<p>{{ subtitle }}</p>
<ul>
{{#each items}}
<li>{{ this.name }} — {{ this.price }}</li>
{{/each}}
</ul>Front matter is the highest-precedence layer of the Eleventy data cascade, so a key you set here also overrides the same key set for the page type or the whole site. What sits below it is on Global Site Data and the Data Cascade.
Choosing a layout from front matter
Layout resolution has three possible outcomes, one of which is a hard build failure, and readers hit that failure without knowing which branch produced it:
flowchart TD
A["Render a page"] --> B{"layout set in front matter?"}
B -- "an explicit value" --> L1(["Use that layout"])
B -- "explicitly null" --> L2(["Render with no layout wrapper"])
B -- "not set" --> C["Resolve pageTypeId,<br/>mapping the root sentinel"]
C --> D{"Page type found?"}
D -- no --> T(["THROW: PageType has no layout defined!"])
D -- yes --> E{"Page type is noRender?"}
E -- yes --> L3(["No layout — the page type<br/>opts out of rendering"])
E -- no --> F{"Page type has a layout?"}
F -- yes --> L4(["Use the page type's layout"])
F -- no --> T
In prose: an explicit layout wins. "layout": null disables layout wrapping entirely, so the page renders its own complete document — that is what the scaffold’s index.hbs does. With no layout key at all, the page type’s configured layout is used. A page type marked noRender deliberately returns nothing here, because a template elsewhere decides when and where its pages are rendered. And a page type with no layout configured is a build failure, not a fallback.
Which layout a page type is bound to is set when you configure it — see Layouts and Page Types.
Computed keys you never write
Three more values are computed during the build and then behave as if they had been in your front matter all along.
permalink decides whether a page is rendered at all. It returns false — meaning do not render — when previewOnly is set and the build is not a preview build, and when the page type is noRender without an explicit forceRender. Otherwise it passes your own permalink through. This is the mechanism behind previewOnly: the page is not hidden after rendering, it is never rendered.
sitemapPriority comes from the page type rather than the page: the page type’s configured priority, defaulting to 0.99, or its label-page priority, defaulting to 0.79, for a label-paginated set. A page whose pageTypeId does not match any known page type fails here with Fix your pageType in the file referenced by: and the filename — usually the fastest way to find a typo’d page type id.
The date rollup on list pages. A paginated listing page has no publish date of its own, so when its publishedAt or modifiedAt matches the pagination alias, the build substitutes the date of the last item in the paginated set. That is what stops a blog index from appearing in the sitemap with an empty <lastmod>.