Template language reference
StaticOwl templates are Liquid — the same dialect Jekyll, Shopify, Hugo, and 11ty use — with a small set of CMS-specific filters and tags layered on top. Anyone who has written a Jekyll layout can write a StaticOwl template; the additions are about reading from your content at build time and slotting in Page Builder zones.
The renderer that compiles your templates is the same one behind the preview endpoint and the in-page editor, so what you see in preview is what ships.
Output is HTML-escaped by default
Every {{ x }} in your template is HTML-escaped. If title is "<script>", the page renders <script> — not a live tag. This is the right security default: it closes the most common stored-XSS hole, where an editor pastes attacker-controlled text into a field that reaches a template unfiltered. Most templates never need to think about it.
You opt out only when the value is already HTML you want to render — a Markdown-rendered body, a JSON embed, a trusted HTML field, a navigation tree.
| You want | Use | Example |
|---|---|---|
| Plain text (the default) | {{ field }} |
<h1>{{ title }}</h1> — XSS-safe |
| Markdown → HTML | {{ field | md }} |
<article>{{ body | md }}</article> |
| Already-trusted HTML | {{ field | raw }} |
{{ html_content | raw }} |
Synonym for raw (reads clearer) |
{{ field | safe }} or {{ field | safe_html }} |
{{ rendered_block | safe }} |
JSON for an inline <script> |
{{ field | json }} |
<script>const quiz = {{ quiz | json }};</script> |
| Force-escape a value | {{ field | escape }} |
Rarely needed under autoescape; single-pass — does not double-encode |
md, raw, safe, safe_html, and json are all trusted-output filters: their output passes through autoescape unchanged. escape is the explicit single-pass form — autoescape does not stack on top of it.
The symptom of a missing opt-out is visible, not fatal: a page that shows literal <p> instead of paragraphs means an HTML field is being rendered with bare {{ }} — add | md (for Markdown) or | raw (for trusted HTML).
At a glance
{# Output expression (Liquid `{{ }}`): #}
<h1>{{ title }}</h1>
{# Filter: render a markdown field as HTML #}
<div class="body">{{ body | md }}</div>
{# Control flow #}
{% if featured %}<span class="badge">Featured</span>{% endif %}
{% for tag in tags %}
<a href="/tag/{{ tag.slug }}/">{{ tag.name }}</a>
{% endfor %}
{# Page Builder zone (drop target the editor recognizes) #}
{% zone "main" %}
{# Iterate content at build time #}
{% list type:"blog_post" where:"status:published" order:"-publishAt" limit:10 as post %}
<article>
<h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
<p>{{ post.excerpt }}</p>
</article>
{% else %}
<p>No posts yet.</p>
{% endlist %}
Liquid basics
A template is HTML (or any text) with two kinds of Liquid expressions woven in:
| Form | Purpose |
|---|---|
{{ value }} |
Output expression — interpolates a value. |
{% tag %}…{% endtag %} |
Control tag — branching, iteration, custom logic. |
{# … #} |
Comment — not emitted. |
Output expressions can be piped through filters:
{{ updatedAt | date: "%b %-d, %Y" }} {# → Jun 14, 2026 #}
{{ title | downcase }}
{{ items | size }} {# → 4 #}
{{ author.name | default: "Anonymous" }}
Every standard Liquid filter is available (date, default, truncate, escape, slice, size, join, sort, uniq, where, map, downcase, upcase, …). For the full inventory see LiquidJS — Filters.
Control flow:
{% if status == "published" %}…{% elsif status == "draft" %}…{% else %}…{% endif %}
{% unless author %}Anonymous post{% endunless %}
{% case role %}
{% when "admin" %}…
{% when "editor" %}…
{% else %}…
{% endcase %}
{% for item in items limit: 5 offset: 0 reversed %}
{{ forloop.index }} / {{ forloop.length }}: {{ item.title }}
{% endfor %}
{% assign total = items | size %}
{% capture greeting %}Hello, {{ author.name }}{% endcapture %}
LiquidJS docs — tags, syntax — apply verbatim.
Use {% elsif %}, not {% elif %} (LiquidJS uses the Ruby spelling). The renderer auto-corrects {% elif %} as a convenience, but write elsif.
Lenient by design
The renderer runs lenient:
- Undefined fields render as empty strings instead of throwing. A template survives schema evolution — a content type can gain or lose a field on its own clock without the live page breaking.
- Unknown filters degrade gracefully rather than erroring.
Render context
When a content item is rendered, the template sees:
| Variable | Source |
|---|---|
{{ <field> }} |
Every field on the item, flat. title, body, slug, publishAt, … |
{{ title }}, {{ description }}, {{ body }}, {{ updatedAt }} |
Normalized aliases (name → title, excerpt → description, body rendered to HTML once). Use these in shared layouts so a partial works against any content type. |
{{ canonicalUrl }} |
This page's absolute canonical URL (e.g. https://yoursite.com/features/x/) when the site has a public URL configured, else empty. Drop straight into <link rel="canonical"> and Open Graph / Twitter tags — no hand-reconstruction. |
{{ url }} |
The canonical URL when it's known, otherwise the root-relative path — so it's never empty when a path exists. The friendly "current page URL". |
{{ path }} |
This page's clean root-relative path (e.g. /features/x/). Always available. |
{{ permalink }} |
Alias for {{ url }}. |
{{ site.publicUrl }} |
The site's absolute base URL (e.g. https://yoursite.com), for building absolute links. Empty if the site has no public URL configured. |
{{ site.siteName }} |
Site display name. |
{{ site.siteTheme.primary }} / .accent / .font |
The site's core theme colors and font. |
{{ site.siteTheme.tokens.* }} |
The full design-token set — palette, typography, spacing, radius, motion. See Theme tokens below. |
{{ site.nav.primary }} |
Array of { label, href } — the primary nav. Iterate with {% for link in site.nav.primary %}. Absent if the site hasn't defined nav. |
{{ site.nav.cta }} |
Single { label, href } — the headline call to action. |
{{ site.nav.footer }} |
Array of { heading, links: [{label, href}] }. |
Inside a {% list %}, {% query %}, or any graph-iteration block, the iteration variable (default item, or whatever you named with as foo) carries the full row.
There is no request, now, page, or collections object in the context — reach for the fields and site.* values above. (To mark the current nav link, compare against {{ path }}, not request.path.)
Theme tokens
Every site exposes a full design-token set at site.siteTheme.tokens, so a template can drive its whole look from one source. Sites that predate tokens get sensible system defaults, so you can always interpolate without a fallback expression.
| Field | Type | Default |
|---|---|---|
tokens.palette.primary / .accent |
hex | matches theme.primary / .accent |
tokens.palette.neutral / .surface / .surface-2 / .ink / .ink-dim |
hex | system palette |
tokens.typography.display / .body / .mono |
CSS font-family string |
system-ui, system-ui, ui-monospace |
tokens.typography.scale |
numeric ratio as string | "1.250" |
tokens.typography.weights.display / .body / .bold |
number | 700 / 400 / 600 |
tokens.spacing.unit |
CSS length | "0.25rem" |
tokens.spacing.density |
compact | comfortable | airy |
comfortable |
tokens.radius.small / .medium / .large |
CSS length | 4px / 8px / 16px |
tokens.motion.duration |
CSS time | "180ms" |
tokens.motion.easing |
CSS easing | cubic-bezier(0.4, 0, 0.2, 1) |
tokens.motion.intensity |
none | subtle | playful |
subtle |
<style>
:root {
--primary: {{ site.siteTheme.primary }};
--accent: {{ site.siteTheme.accent }};
--so-font-display: {{ site.siteTheme.tokens.typography.display }};
--so-font-body: {{ site.siteTheme.tokens.typography.body }};
--so-radius-medium: {{ site.siteTheme.tokens.radius.medium }};
--so-motion-duration: {{ site.siteTheme.tokens.motion.duration }};
}
</style>
The default page layout (the one a site renders with before you write a template) already wires every token into :root CSS variables, so a new site gets a coherent look immediately.
Filters added by StaticOwl
| md — render Markdown to HTML
<div class="body">{{ body | md }}</div>
Renders Markdown to HTML. null / undefined → empty string.
Auto-Markdown for string fields. The renderer also detects Markdown shape automatically: a field whose value looks like Markdown (headings, bullet or numbered lists, fenced code, bold, paragraph breaks) renders as HTML even without | md. A field that starts with <…> is treated as existing HTML and passes through unchanged. This is why a freshly imported body of ## Welcome\n\nHello… just works in {{ body }}. If you ever need the raw Markdown source, use the explicit | escape.
| safe / | safe_html — render trusted HTML
{{ rendered_block | safe }}
Passes a value through as-is, without escaping. Use it for a field that already holds trusted HTML. safe and safe_html are identical; pick whichever reads better in context. | raw (LiquidJS's built-in) does the same thing.
| json — safe JSON for an inline script
<script>const data = {{ record | json }};</script>
JSON-encodes a value and makes it safe to embed inside a <script> block (a </script sequence inside the data is neutralized, so the data can't break out of the tag). Encoding errors render as null rather than breaking the page.
| img — a complete responsive <img> tag
{{ image | img: alt: title, width: 1600, height: 900 }}
Takes an image URL and emits a full <img> tag, so you don't hand-write attributes. null / empty → nothing.
| Arg | Effect |
|---|---|
alt |
Alt text (a bare first argument is treated as alt; title is also accepted). |
width / height |
Sets the attributes — helps layout stability. |
loading |
lazy (the default) or eager for an above-the-fold hero. |
class / style |
Extra attributes on the tag. |
Tags added by StaticOwl
{% zone "name" %} — Page Builder drop zone
<main>
{% zone "main" %}
</main>
<aside>
{% zone "sidebar" %}
</aside>
A zone is a drop target the visual editor recognizes. Anything dragged into it (widgets, page kits, custom blocks) renders here at build time. An empty zone emits nothing — wrap it with the surrounding HTML you want present regardless.
The argument can be a quoted string ("main", 'sidebar') or a bare identifier (main). Pick stable names: renaming a zone orphans whatever was dropped into it. The older output-style form {{ zone "name" }} still works and is rewritten to the tag form before parsing.
{% partial 'name' %} / {% include 'name' %} — shared markup
Pull another of the site's templates in by name, so a shared header, footer, or nav lives in one place instead of being copy-pasted into every page template.
<header>{% partial 'site-nav' %}</header>
<main>{{ body }}</main>
<footer>{% include 'site-footer' %}</footer>
{% include 'name' %}is an alias for{% partial 'name' %}— use whichever reads better. (Standard Liquid's built-inincludeloads from a filesystem root the build never populates, so both forms resolve against the site's template catalog instead.)- The
nameis another template's name on this site. The included template renders against the current context, so it can read the same{{ title }},{{ item }}, theme tokens, and so on. - A missing name renders an HTML comment (
<!-- partial: "x" not found -->) rather than breaking the build, so a typo is visible in the output. - Nesting is capped at 8 levels.
- The
{% include 'x' with obj %}/{% include 'x', k: v %}argument forms are not supported — pass data through the shared context, not include arguments.
{% list %} — iterate content at build time
{% list type:"blog_post"
where:"status:published, featured:true"
order:"-publishAt"
limit:10
as post %}
<article>
<h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
<time>{{ post.publishAt | date: "%b %-d, %Y" }}</time>
<p>{{ post.excerpt }}</p>
</article>
{% else %}
<p>No posts yet — check back soon.</p>
{% endlist %}
Arguments:
| Name | Type | Required | Default | Meaning |
|---|---|---|---|---|
type |
string | yes | — | Content type name (e.g. blog_post). |
where |
string | no | (no filter) | Comma-separated field:value predicates. Implicit AND between predicates. See "Where syntax" below. |
order |
string | no | engine default | Field name; prefix with - for descending. Comma-separated for multi-key ("category, -publishAt"). |
limit |
number | no | engine default | Max rows (max 9999). |
as |
identifier | no | item |
Name the iteration binding. |
{% else %} runs when zero rows match (same semantics as Liquid's built-in for). The block ends with {% endlist %}.
Where syntax — exact rules
The where argument is a literal string parsed by the build, not a Liquid expression. A few rules follow from that:
- Predicates are comma-separated, not joined by
AND.where:"status:published, featured:true"works;where:"status:published AND featured:true"does not filter the way you expect — the parser splits each predicate on its first:, so the second token becomes the literal value"published AND featured". - The split is on the first colon only. A value may itself contain a colon —
where:"parentId:course:cs101"keepscourse:cs101as the value. - Values are auto-coerced: digits → number,
true/false→ boolean, everything else → string. - List fields match on membership. For
tagsand any field declared as a list type,where:"tags:fruity"matches rows whose array containsfruity, not rows whose whole array equals the string. Scalar fields keep exact-equality. field:a|b|cis an IN-list — matches any of the values. The list is bound as a query parameter, never string-joined, so it's injection-safe.
Interpolating page context into where: — and the escaping caveat
where:, order:, and type: are run through Liquid against the current page before they are parsed, so you can splice page values straight in:
{# list items whose parent is THIS page #}
{% list type:"lesson" where:"parentId:{{ id }}" order:"order_index" as lesson %}
<a href="{{ lesson.url }}">{{ lesson.title }}</a>
{% else %}
<p>No lessons yet.</p>
{% endlist %}
{{ id }} resolves to the current page's id (e.g. course:cs101), producing where:"parentId:course:cs101".
Escaping caveat — the one that catches people. Because the
where:string is itself run through the autoescaping renderer, an interpolated value that contains&,",',<, or>gets HTML-escaped inside the predicate — so the filter sees&/'and matches zero rows. If your filter value can contain those characters, pipe it through| safe:{% list type:"post" where:"author:{{ author_name | safe }}" as p %}…{% endlist %}A plain id or slug (
{{ id }},{{ slug }}) is already safe. Reach for| safewhen the value is free text.
A couple more:
- Missing variables render as empty string.
where:"parentId:{{ missing }}"becomeswhere:"parentId:", which matches zero rows and fires your{% else %}— no crash. - Filter chains work.
where:"category:{{ category | downcase }}". - Children need a flat field to filter on.
{% list %}filters on direct properties, so to list "items whose parent is this page," store aparentIdon each child at import time and filter on it, as above.
Incremental rebuild
Every row a {% list %} returns is recorded as a dependency of the page. The next time one of those items changes, the page is automatically queued for re-render — you don't have to think about it.
Graph tags — {% similar %}, {% traverse %}, {% search %}, {% count %}, {% recommend %}, {% trending %}
StaticOwl stores content as a graph, which makes a few operations available that a flat CMS can't do — and keeps them to a one-liner. They share the same interpolation rules as {% list %}.
After a bulk import, materialize the index.
{% similar %},{% search %},{% recommend %}, and{% trending %}need a search index (embeddings or graph ranking) to return results. A single-record create indexes automatically; a bulkPOST /api/import/jsondoes not. RunPOST /api/maintenance/embeddings/materialize?type=<typeName>after a bulk import (it's idempotent — safe to re-run). If you forget, the build response includes a cleartransformErrortelling you so.
{% similar %} — related items by meaning
The shortest form takes no arguments — it finds items of the same content type most similar to the current page:
{% similar as item %}
<a href="{{ item.url }}">{{ item.title }}</a>
<span class="match">{{ item.score | times: 100 | round }}%</span>
{% else %}
<p>No similar items.</p>
{% endsimilar %}
| Arg | Default | Purpose |
|---|---|---|
to: |
current page's {{ id }} |
source item id |
of: (alias label:) |
type derived from to: |
restrict results to a content type |
limit: |
5 |
max results (1–50) |
where: |
— | scope the candidate set by a predicate (same language as {% list %}) |
same: |
— | scope to the source item's value for a field ("related within this X") |
pin: |
— | hand-pick specific items by id; the rest fill in by similarity |
asOf: |
— | rank among content as it existed at a past instant (auditable) |
receipt: |
false |
emit a provenance receipt after the block (see below) |
as: |
required | binding name for each result |
Each result carries id, title, url, and score (0–1, where 1 is identical). Similarity is by meaning, not tag overlap — two posts that share no tags but cover the same ground still cluster. It works the same for your own content types as for the built-in ones.
Provenance — receipt:true. Adds a machine-readable record of why these items were surfaced, right after the block: a <script type="application/json" data-retrieval-receipt> holding the source id, the scoping (where / same / via / asOf), the ranking mode, and the returned ids with their scores, plus a content hash of the whole derivation. It's invisible to readers but lets you (or an auditor, or your own script) see exactly what a related-content block was built from. The rendered items are unchanged.
{% similar where:"topic:{{ page.topic }}" receipt:true as item %}
<a href="{{ item.url }}">{{ item.title }}</a>
{% endsimilar %}
Hand-picked related — pin:. Name the exact items you want and they render first, in your order; if limit leaves room, similar items fill the rest. To show only your picks, set limit to the number you pinned.
{# exactly these three, in this order — no algorithm #}
{% similar pin:"lesson:intro, lesson:setup, lesson:first-build" limit:3 as l %}
<a href="{{ l.url }}">{{ l.title }}</a>
{% endsimilar %}
A pinned id that doesn't exist (typo, wrong type, deleted) is skipped — it never renders a broken card. where: / same: / asOf: constrain the fill, not your pins. Pins can come from a content field (pin:"{{ page.related_ids }}"), so an editor can manage the picks.
Scoped related — where: and same:. Both restrict the candidate set while keeping the ranking by similarity, so you still get a full limit of results from within the scope:
{# related lessons within THIS lesson's course #}
{% similar same:"course_slug" as l %}
<a href="{{ l.url }}">{{ l.title }}</a>
{% endsimilar %}
same:"course_slug" means "same value as the source item" — multiple fields with same:"course_slug,level". where: uses the {% list %} predicate language, including the field:a|b|c IN-list. A scoped query needs an embedding on the source item; without one it renders the {% else %} block.
Point-in-time — asOf:. asOf:"<ISO timestamp>" ranks among content as it existed at that instant, drawn from the content's own history — reproducible for audit. "What would this page have surfaced as related on 2026-01-01?"
{% similar asOf:"2026-01-01" limit:5 as l %}
<a href="{{ l.url }}">{{ l.title }}</a>
{% endsimilar %}
The timestamp is ISO 8601 ("2026-01-01" or "2026-01-01T00:00:00Z") and interpolates like any arg. It composes with where: / same:. If the page didn't exist (or wasn't indexed) at that instant, the {% else %} block fires.
Next / previous is sequential, not similarity — the item before and after this one in an ordered series. If your import denormalizes the neighbors onto each item (prev_url / prev_title / next_url / next_title), it's a plain field render:
<nav class="pager">
{% if prev_url %}<a class="prev" href="{{ prev_url }}">← {{ prev_title }}</a>{% endif %}
{% if next_url %}<a class="next" href="{{ next_url }}">{{ next_title }} →</a>{% endif %}
</nav>
{% traverse %} — multi-hop relationships
{# lessons of THIS course, ordered, with a property filter #}
{% traverse from:"{{ id }}" via:"-[:HAS_LESSON]->" type:"lesson"
where:"status:published" order:"order_index" as lesson %}
<a href="{{ lesson.url }}">{{ lesson.title }}</a>
{% endtraverse %}
{# two-hop: "authors of posts in this category" #}
{% traverse from:"{{ id }}" via:"-[:HAS_POST]->-[:WRITTEN_BY]->"
type:"author" as a %}
{{ a.name }}
{% endtraverse %}
via: is a relationship-path fragment. It accepts A-Z 0-9 _ : [ ] - < > ( ) | * and whitespace; anything else is rejected and the tag yields zero rows — there's no injection surface. The tag walks from the from: item along the path and returns the items at the far end, with the same where: / order: / limit: options as {% list %}. Use type:"X" to constrain the result type.
{% search %} — build-time search
{% search "how to deploy" in:"doc" limit:5 as result %}
<li><a href="{{ result.url }}">{{ result.title }}</a></li>
{% else %}
<li>No matches.</li>
{% endsearch %}
Search fuses keyword and meaning-based ranking, so both an exact-title hit and a topically related page surface, correctly ordered. (If the semantic tier isn't available it falls back to a keyword scan, so the tag always returns.) The first positional argument is the quoted query (interpolated against context — {% search "{{ category }} tutorials" %} works), in:"<type>" scopes to a content type, and field: overrides which text field is matched. Use it for in-site search widgets, autocomplete data, or "see also" panels.
{% count %} — a number, not a list
{% count type:"lesson" where:"parentId:{{ id }}" as n %}
<p>{{ n }} lessons in this course</p>
Single-shot, no block: the as binding becomes a number in the surrounding scope. Use it for badges, nav counts, and "X items remaining". If the count can't run (e.g. an isolated preview), n is 0 and the template still renders.
{% recommend %} — algorithm-driven "what next"
{% recommend after:"{{ id }}" type:"lesson" algo:"jaccard" limit:3 as next %}
<a href="{{ next.url }}">{{ next.title }}</a>
<span class="why">{{ next.reason }}</span>
{% else %}
<p>Nothing else to recommend yet.</p>
{% endrecommend %}
Returns rows with { id, title, url, score, reason }, where reason is a short human-readable explanation. Arguments: after: (required — "after this page, what next?"), type: (restrict candidates), algo: (jaccard (default), adamicAdar, commonNeighbors, or preferentialAttachment; an unknown value falls back to the default), excludeExisting: (default true — skip already-linked items), and limit: (1–50).
{% trending %} — popularity over a time window
{% trending type:"post" window:"7d" limit:5 as t %}
<li><a href="{{ t.url }}">{{ t.title }}</a> <span class="rank">#{{ forloop.index }}</span></li>
{% else %}
<li>No trending posts.</li>
{% endtrending %}
Ranks items over a recency-weighted window. window: accepts Nm / Nh / Nd / Nw (e.g. 7d, 24h); omit it to rank across all history. type: restricts the result type. If ranking isn't available, it falls back to a recency-ordered list, so the template still renders something useful.
{% query "name" %} — saved-query escape hatch
When {% list %} and the graph tags can't express a shape — multi-hop joins across several content types, aggregations (count / sum / avg grouped by a field), or anything you'd otherwise write by hand — run a saved query by name:
{% query "trending-this-week" as result %}
<li>{{ result.title }} — {{ result.author }}</li>
{% else %}
<p>No results.</p>
{% endquery %}
The name is a literal lookup against your saved queries — it is not interpolated, so {% query "posts-by-{{ author }}" %} looks for a query literally named that and finds nothing. For per-page parameters, use {% list %} with where: interpolation instead. The default binding is item if you omit as.
Standard tags — {% if %}, {% for %}, {% assign %}, {% capture %}
Standard Liquid — see the LiquidJS docs linked above.
A complete page template
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} — {{ site.siteName }}</title>
<meta name="description" content="{{ description }}">
<link rel="canonical" href="{{ canonicalUrl }}">
<style>
:root { --primary: {{ site.siteTheme.primary }}; --accent: {{ site.siteTheme.accent }}; }
</style>
</head>
<body>
<header>
<nav>
{% for link in site.nav.primary %}
<a href="{{ link.href }}" class="{% if link.href == path %}active{% endif %}">{{ link.label }}</a>
{% endfor %}
{% if site.nav.cta %}
<a class="cta" href="{{ site.nav.cta.href }}">{{ site.nav.cta.label }}</a>
{% endif %}
</nav>
</header>
<main>
<article>
<h1>{{ title }}</h1>
{% if author %}<p class="byline">by {{ author }}</p>{% endif %}
<div class="body">{{ body | md }}</div>
</article>
<section class="related">
<h2>Related posts</h2>
{% similar limit:3 as related %}
<article><h3><a href="{{ related.url }}">{{ related.title }}</a></h3></article>
{% endsimilar %}
</section>
{% zone "sidebar" %}
</main>
<footer>
{% for col in site.nav.footer %}
<div>
<h4>{{ col.heading }}</h4>
<ul>{% for link in col.links %}<li><a href="{{ link.href }}">{{ link.label }}</a></li>{% endfor %}</ul>
</div>
{% endfor %}
</footer>
</body>
</html>
Common patterns
Mark the current nav link
{% for link in site.nav.primary %}
<a href="{{ link.href }}" class="{% if link.href == path %}active{% endif %}">{{ link.label }}</a>
{% endfor %}
Truncated excerpt with fallback
{{ excerpt | default: body | strip_html | truncate: 160 }}
Date formatting
{{ publishAt | date: "%B %-d, %Y" }} {# June 14, 2026 #}
{{ publishAt | date: "%Y-%m-%d" }} {# 2026-06-14 #}
Show "Updated" only if it differs from "Published"
<time>Published {{ publishAt | date: "%b %-d, %Y" }}</time>
{% if updatedAt and updatedAt != publishAt %}
<time>Updated {{ updatedAt | date: "%b %-d, %Y" }}</time>
{% endif %}
Limits and what's out of scope
- Output size. A single rendered page is capped (a couple of megabytes); a runaway template fails the build rather than emitting a giant file.
- Static loops are bounded. A literal range over 10,000 iterations is refused before render.
- Templates are read-only. There are no mutations from a template — no writing back to content, no side effects. Content changes go through the API, the dashboard, or MCP.
- Cross-type joins inside
{% list %}. The filter form is single-type; reach for{% query %}for a join. - Filters are synchronous. The async work (the graph calls behind
{% list %}/{% similar %}/{% query %}) is handled by the tags, not by filters.
The in-page edit bar
Pages rendered for a signed-in editor get a small edit bar injected automatically (two <meta> tags and a deferred script before </head>). You don't add anything to your template — the renderer slots it in. It's a no-op for anonymous visitors, so it's safe in production output. Turn it off at the site level with site.inPageEditor = false if you ever need to.
See also
- Content modeling & field types — define the fields these templates render.
- Routing —
routePattern— control the URL shape per content type. - HTTP API — manage templates from code.
- Zero to deployed — the quickstart that builds a template end to end.