ConfigurationEntity routes

Entity routes

entityRoutes turns entities in the registry into pages. Each rule generates one page per entity matching its type — a specs index that materialises every {% spec %} as its own URL, a page per team member, a page per recipe.

It's a site-config field, so you don't need to write any code:

{
  "site": {
    "contentDir": "./content",
    "entityRoutes": [
      { "type": "spec", "url": "/specs/{id}/", "title": "{title}", "render": "{% expand $item.id /%}" },
      { "type": "work", "filter": "status:ready", "url": "/work/{id}/", "render": "{% expand $item.id /%}" },
      { "type": "decision", "url": "/decisions/{id}/", "render-template": "templates:decision-page.md" }
    ]
  }
}

Rule fields

FieldTypeDescription
typestringEntity type(s) the rule matches. Comma-separated for multiple.
urlstringTemplated route, site-root-relative — the site's base path is applied for you.
filterstringNarrows the match using the same field:value grammar as {% collection %}.
titlestringTemplated page title. Falls back to the rendered content's first heading.
renderstringInline Markdoc body for each generated page.
render-templatestringA Markdoc partial to use as the body instead of render.
frontmatterobjectFrontmatter for the generated page.

render and render-template are mutually exclusive — set one or the other.

Placeholders

url, title, and frontmatter interpolate {name} placeholders from the matched entity's fields:

{ "type": "recipe", "url": "/recipes/{id}/", "title": "{title} — {cuisine}" }

{id} is always available; everything else comes from the entity's own data. Substituted values are URL-encoded per path segment, so a value containing a slash produces nested path segments rather than an escaped one.

Page bodies

render is ordinary Markdoc, transformed once per entity with $item bound to that entity — the same contract as a collection per-item template:

{ "type": "spec", "url": "/specs/{id}/", "render": "{% expand $item.id /%}" }

So {% expand $item.id /%} inlines the entity's own content, and the shared formatter functions work here too:

{ "render": "Published {% date($item.data.published) %} by {% $item.data.author %}" }

For anything longer than a line or two, point render-template at a partial instead. It resolves through fileRoots, so "templates:decision-page.md" works from any generated page:

{ "type": "decision", "url": "/decisions/{id}/", "render-template": "templates:decision-page.md" }
note

{% expand %} only works on entities that are embeddable — the plugin that registered them has to provide the content. Entities registered without it (plain pages, headings) produce a clear build error rather than an empty page. See Entities.

Filtering

filter uses the field:value grammar documented on collection — the same parser, so a filter string that works in a rune attribute works here unchanged:

{ "type": "work", "filter": "status:ready", "url": "/work/{id}/" }
{ "type": "post", "filter": "tags:release", "url": "/releases/{id}/" }

Each matched entity's sourceUrl is back-filled with the route the rule generated. That means {% ref %} and {% xref %} to that entity start preferring the on-site page over wherever the entity was originally defined — cross-references light up across the site as soon as the rule exists, with no per-link edits.

When you need a hook instead

entityRoutes covers the "one page per entity" shape. Reach for a contributePages pipeline hook when you need something it can't express — synthesising pages from an external API, one page per group of entities, or routes whose shape depends on cross-page aggregation.