Layouts and Page Types

A layout is a complete web page: the document shell, the head, the header, the footer and a hole where the content goes. A page type names one. The naming happens in the CMS at Settings → Page Types; the file itself lives in your repository at src/_layouts/. That split is the thing to hold on to — the CMS owns the binding, your clone owns the markup, and neither half knows anything about the other beyond a filename.

Layouts and page types are free on every plan. Only the CMS editor for layout files is gated, and that is covered on Layouts Workspace.

How a page finds its layout

Layout resolution is computed per page, in eleventyComputed, and it has four outcomes.

flowchart TD
    A["Page renders"] --> B{"Front matter sets layout?"}
    B -->|"Yes — a filename"| C["Use it"]
    B -->|"Yes — explicit null"| D["Render with no layout"]
    B -->|"No"| E["Resolve pageTypeId<br/>###ROOT### maps to the empty-slug page type"]
    E --> F{"Page type has noRender?"}
    F -->|"Yes"| G["No layout — nothing is rendered"]
    F -->|"No"| H{"Page type has a layout?"}
    H -->|"Yes"| I["Use pageType.layout"]
    H -->|"No"| J["Build fails:<br/>PageType &lt;id&gt; has no layout defined!"]

In prose, in the order the code takes them:

  1. The page’s own layout front-matter key wins. A filename is used as-is. An explicit null is honored as an instruction: render this page with no layout at all. That is how a standalone src/*.hbs file that already contains its own <html> document opts out.
  2. Otherwise the page type decides. pageTypeId is resolved first, which is where the ###ROOT### sentinel is translated: it means “the page type whose slug is the empty string” (or the legacy slug root).
  3. A noRender page type returns no layout, so the page cannot render an empty shell. See Page-type fields that change what a template must do.
  4. A page type with no layout at all stops the build.

The other computed keys you will meet

layout is one of eight keys computed for every page. The rest mostly stay out of your way, but three of them explain behavior that is otherwise baffling.

uniquePageId gives a paginated set a stable, per-output-page identity — list-<pageId> for the first output page and list-<pageId>-<n> for the rest, with the label or author name spliced in for a two-layer set. It is the id the build-results page map is keyed on, which is why a paginated list reports as several pages rather than one.

publishedAt and modifiedAt have a special case that is easy to trip over: when the front-matter value is the same string as the pagination alias — "publishedAt": "list" on a page paginating with alias: "list" — the computed key resolves it to the last item in the chunk, so a list page carries a real date instead of the literal word. The shipped index scaffold relies on this. Both keys are ordinary front-matter fields on a hand-authored page; see the Front Matter Reference.

permalink returns false — meaning do not write a file — in two cases: a noRender page type without forceRender, and a previewOnly page outside a preview build. previewOnly is a build-inclusion mechanism with no control in the CMS; it defaults to false and is not something you set in normal work.

All eight computed keys
KeyWhat it computesWhen it matters to you
layoutThe layout file for this page, by the four-step resolution aboveAlways — it is the reason a page renders as anything
pageIdThe page’s id, reaching into item.pageId for OpenAPI-generated pagesRarely; it is what pageid: links resolve against
uniquePageIdlist-<pageId> plus a name and page number for paginated setsWhen a paginated set reports as several pages in build results
publishedAtThe page’s publish date, or the last item’s date on an aliased list pageSort order, sitemap <lastmod>, feed dates, JSON-LD, card datelines
modifiedAtSame resolution as publishedAtSame consumers
pageTypeIdThe page type id, with ###ROOT### mapped to the empty-slug typeAny template that looks a page type up by id
sitemapPrioritylabelSiteMapPriority or 0.79 for a label-pagination page; pageType.sitemapPriority or 0.99 otherwise; 1 with no page typeOnly when tuning a sitemap
permalinkfalse for noRender-without-forceRender and for previewOnly outside preview; otherwise the page’s own permalinkWhen a page mysteriously produces no file

The one layout Leed ships

Leed ships exactly one layout file, _layouts/leed-documentation.hbs, and every documentation and API page type is bound to it. Below is the whole of it, minus the Handlebars comment that shouts about indentation:

{{#if (docsLayoutName) }}
{{> (docsLayoutTemplate) }}
<!-- Generated by Leed AI - '{{ docsLayoutName }}' layout using '{{ docsColorTheme }}' theme -->

{{else}}
    <h1>documentationConfiguration is missing!</h1>
{{/if}}

Three things follow from those lines.

Third: the scaffolder will not write a layout file for a page type bound to leed-documentation.hbs. It is a system file, it is injected on every build and deleted afterwards, and a copy of it in your _layouts/ would be overwritten anyway. Which of the three documentation layouts it then delegates to is chosen by layoutName — see Documentation Layouts.

Writing your own layout

A layout is any .hbs file in src/_layouts/. There is nothing to register: put the file there and its filename becomes selectable as the page type’s Layout.

The minimum useful layout is one line, and that is very nearly what a real one looks like. main.hbs on a shipped site — the layout bound to its root page type — is this line plus a comment saying “empty layout”:

{{{ process content }}}

An empty layout like that is the right choice when each src/*.hbs page supplies its own wrapper — a landing page, a pricing page, anything whose markup is a one-off. The page brings the document; the layout gets out of the way.

The master-template pattern

Leed sites do not chain layouts with Eleventy’s layout: key. They use a Handlebars block partial, and once you have seen it you will recognize it everywhere in a real repository.

The master template is a partial — one complete HTML document with a hole in it:

<!DOCTYPE html>
<html lang="en" class="scroll-smooth code-theme-leed font-noto-sans">

<head>
    <title>{{ title }} | {{ siteTitle }}</title>
    <meta name="viewport" content="width=device-width">
    {{> leed/head }}
</head>

<body class="flex flex-col h-screen antialiased">
    {{> header }}

    <div class="grow mt-(--height-header)">
{{> @partial-block}}
    </div>

    {{> footer }}
</body>

</html>

Every layout then invokes that partial with a body:

{{#>site-template scrollBarId="legal-copy" }}

<main class="relative default-x-margins default-y-margins">
    <section id="template-content">
        <article id="legal-copy" class="templated-content">
            <h1>{{{ title }}}</h1>
            {{{ process content }}}
        </article>
    </section>
</main>

{{/site-template}}

The mechanics are two sentences. {{#> name }} … {{/name}} invokes a partial with a block body, and the partial renders that body wherever it writes {{> @partial-block }}.

The reason to prefer it over Eleventy layout chaining is that parameters pass through the invocation. scrollBarId="legal-copy" above is read by the master template and forwarded to the scroll indicator; wide, showDocsSearch, fixedHeight and anything else you invent work the same way. Layout chaining can only pass data through the cascade, which means inventing a front-matter key and setting it on every page. The same mechanism, in more depth and with the other invocation forms, is at Writing Your Own Partials.

Indentation is load-bearing

Write {{> @partial-block}} at column 0 in your master template. This is the same rule as leed-documentation.hbs, for the same reason, and it bites everyone once: indent the call and Handlebars indents every line the block produces, code blocks included. In the example above the surrounding <div> is indented and the partial-block call is not — that asymmetry is deliberate, not a typo.

Page-type fields that change what a template must do

Three fields on a page type change what your templates are responsible for.

noRender. The site builder emits nothing at all for pages of this type — permalink computes to false for every one of them. A template of yours must then generate those pages and decide their paths, by paginating the type’s :unpaged collection with forceRender: true and computing a permalink from each item’s slug. That is exactly what the paginated scaffolding trio does; see Page-Type Scaffolding Templates.

redirectIndex. Takes first, last, a relative path starting with /, or an absolute URL. The build resolves it to a destination and emits two 302 rules into _redirects — one for /<slug>/ and one for /<slug>/index.html. first and last resolve against the type’s :unpaged collection, so they follow your content rather than a hard-coded URL. The redirect model as a whole is at Aliases and Redirects.

slug. A page type’s slug may contain /, which is how a versioned API root like docs/api/3.7.0 works. The consequence for your repository is the directory data file: it is named after the last slug segment, so that type’s settings live at src/docs/api/3.7.0/3.7.0.11tydata.json. Naming it after the whole slug would bury it in a phantom subtree that Eleventy binds to an empty directory, and the set’s pages would inherit nothing.

What this looks like on a shipped site

The page-type-to-layout map for leed.ai, straight out of _data/pageTypeList.json:

Page type idSlugLayout
root(empty)main.hbs
100111blogblog.hbs
110011comparecomparison-page.hbs
ev9wohresourcescontent-with-form.hbs
n1atdpproductproduct.hbs
pt_uvedkdlegallegal.hbs
u05nxrdocsleed-documentation.hbs

Read it as an illustration rather than a template to copy: the ids are that site’s, and the only row that is the same everywhere is the last one — a documentation page type is always bound to leed-documentation.hbs.

The CMS page-type editor with a non-documentation page type expanded, showing its name, slug and the Layout field populated with a layout filename

The field in that screenshot is the whole of the CMS half of this page. What you set there, and everything else on that screen, is at Configuring a Page Type.

ESC