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 &lt;script&gt; — 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 &lt;p&gt; 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:


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>

{% 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:

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 &amp; / &#39; 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 | safe when the value is free text.

A couple more:

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 bulk POST /api/import/json does not. Run POST /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 clear transformError telling 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


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

Related docs

Resolved at build time via {% similar %} — cosine similarity over embeddings, not tag overlap. Zero arguments.

Zero to deployed — the 5-minute quickstart
69% match
HTTP API
62% match
Routing — routePattern URL templates
60% match