# 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's written a Jekyll layout can write a StaticOwl template; the additions are about reading from your content graph at build time and slotting in Page Builder zones.

The same renderer (packages/server/src/build/render.ts) is used by the preview endpoint, the templates-preview route, the page-kit preview, and the site compiler. What you see in preview is what ships, byte-for-byte.


# 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 %}

{# Run a saved query when structured filters aren't expressive enough #}
{% query "trending-this-week" as item %}
  <li>{{ item.title }} — {{ item.author }}</li>
{% endquery %}

# 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 | escape }}
{{ 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.

# Strict mode

The renderer runs lenient:

This matters in a CMS: a content type gains or loses a field on its own clock; the live template shouldn't 500 because of it.


# 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.
{{ site.siteName }} Site display name.
{{ site.siteTheme.primary }} / .accent / .font Theme tokens.
{{ 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 CTA.
{{ site.nav.footer }} Array of { heading, links: [{label, href}] }.
{{ pageBlocks }} Internal — Page Builder zones. You won't reach for this directly; use {% zone %}.

Inside a {% list %} or {% query %} block the iteration variable (default item, or whatever you named with as foo) carries the full row.


# Filters added by StaticOwl

# | md — render markdown to HTML

<div class="body">{{ body | md }}</div>

null / undefined → empty string. Markdown is parsed with marked v18 in synchronous mode (no async extensions).

# Auto-markdown for string fields

The renderer wraps every string in your context with a "drop" that detects markdown shape and auto-converts. A field whose value looks like markdown (headings, bullet/numbered lists, fenced code, bold, paragraph breaks) renders as HTML without needing | md. A field that starts with <…> falls through unchanged so existing HTML snippets keep working.

This is why a freshly-imported body column with ## Welcome\n\nHello… just works in {{ body }}. If you need the raw markdown source, use {{ body | escape }} or the explicit | md filter on a known-markdown field.


# 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. If the zone is empty, nothing emits — wrap with surrounding HTML you want present regardless.

The argument can be a quoted string ("main", 'sidebar') or a bare identifier (main). The editor uses extractZones(tpl) to enumerate every zone in a template — pick stable names; renaming a zone orphans whatever was dropped into it.

Backward-compat: the legacy {{ zone "name" }} form (output-style braces) still works — a regex pre-pass rewrites it to {% zone "name" %} before Liquid parses.

# {% 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 against direct node properties. 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 pipeline, not a Liquid expression. Two consequences:

Values are auto-coerced: digits → number, true/false → boolean, anything else → string. A value like course:cs101 is kept as the string "course:cs101" (the predicate splits on the FIRST colon only).

# Incremental rebuild

Every row's id is recorded as a dependency. The next time any of those content items changes, this page is automatically queued for re-render. You don't have to think about it; just write the iteration.

# When the filter form runs out of road

If where + order + limit can't express what you need (multi-hop traversal, aggregation across types, joins, parent-relationship enumeration), reach for {% query %} — or precompute the shape at ingest time as a node property, then emit it directly. See Per-page iteration patterns for the trade-offs.

# Per-page iteration patterns

A common pattern is enumerate items whose parent is the current page — a course-index page listing its lessons, a quiz page listing its questions. {% list %} handles this natively via Liquid interpolation in where::

{# course template: list this course's lessons #}
<aside class="course-nav">
  <h3>Lessons</h3>
  {% 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 %}
</aside>

{{ id }} resolves against the rendering page's context (the course's id), producing where:"parentId:course:cs101" — which the engine pushes down as an indexed lookup. Three things to know:

# Site-wide listings — no per-page filter

If the listing is the same on every page (recent posts, featured items, top categories), drop the per-page predicate:

{% list type:"blog_post" order:"-publishAt" limit:5 as p %}
  <li><a href="{{ p.url }}">{{ p.title }}</a></li>
{% endlist %}

Used heavily on staticowl.com itself — homepage, blog index, customer-stories index, /features/, /docs/.

# When {% list %} isn't enough

For shapes the structured filter can't express — multi-hop traversal, cross-type joins, aggregations — reach for {% query "name" %}. For very large hierarchical sites where the structure is known at import time, you can also precompute the listing at ingest (stamp a nav_tree_html field on the parent at import, emit it directly with {{ nav_tree_html }} in the template). That trades engine calls for ingest complexity; pick what fits your team.

# Worked examples

Copy-paste-ready templates for common shapes live at docs/templates/examples/ — course index, quiz, blog index, related items, breadcrumbs.

# Graph-native primitives — {% similar %}, {% traverse %}, {% search %}, {% count %}

StaticOwl's substrate is a graph engine, not a flat key-value store. These four tags expose operations that flat-CMS shops can't do at all — and they keep the surface a one-liner. Same fast-path / Liquid-interpolation contract as {% list %}.

Bulk-imported sites: all four graph-primitive tags (especially {% similar %} and {% recommend %}) need embeddings or pageRank materialized to return non-empty results. POST /api/import/json does NOT auto-embed — run POST /api/maintenance/embeddings/materialize?type=<typeName> after the import (idempotent; safe to re-run). Single-record creates via POST /api/content/:type auto-embed by default. If you forget, the build response will include a transformError like {% similar %} failed: ... bulk import does NOT auto-embed. — the gap is now visible end-to-end.

{% similar to:"{{ id }}" limit:5 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 %}

Wraps db.similar(id, k) — cosine similarity over the WL-style 128-dim structural fingerprints embedded on every content node. The to: arg is the source node id (typically the current page's {{ id }}). The score is 0–1 (1 = identical, 0 = orthogonal). Optional label:"X" restricts results to a specific content type.

Why this is interesting: the embedding captures structure, not just text. A blog post about CSS Grid that shares 3 outbound MENTIONS edges with another post is "similar" to it even if the wording differs — you'd never get that from a tag match.

# {% traverse %} — multi-hop without writing Cypher

{# 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 Cypher path fragment. Characters allowed: A-Z 0-9 _ : [ ] - < > ( ) | * plus whitespace. Anything else is rejected and the tag yields zero rows — no Cypher injection surface. The tag splices the fragment into a MATCH (root {id: $from})<via>(n) against the engine, applies where:/order:/limit:, and returns the tail nodes.

Arguments mirror {% list %} (comma-separated where:, Liquid interpolation everywhere). Use type:"X" to constrain the tail label.

{% search "how to deploy" in:"doc" limit:5 as result %}
  <li>
    <a href="{{ result.url }}">{{ result.title }}</a>
    <span class="score">{{ result.score }}</span>
  </li>
{% else %}
  <li>No matches.</li>
{% endsearch %}

Wraps the engine's BM25 + vector rerank if configured; falls back to a simple CONTAINS scan when the procedure isn't reachable. First positional arg is the quoted query string (interpolated against ctx — {% search "{{ category }} tutorials" %} works). in:"<type>" scopes to a content type.

Use for "in our docs" search widgets, autocomplete data, or "see also" panels driven by topic similarity rather than tag overlap.

# {% count %} — cheap aggregate

{% count type:"lesson" where:"parentId:{{ id }}" as n %}
<p>{{ n }} lessons in this course</p>

Single-shot: no block, no iteration. The as binding becomes a number in the surrounding scope. One indexed MATCH ... RETURN count(n) on the engine — engine-pushdown when CSR is available. Use when you want a number, not a list (badges, counts in nav, "X items remaining").

If the hook isn't wired (preview before build), n is 0 — the template won't crash.

# {% 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>
  <span class="score">{{ next.score | times: 100 | round }}%</span>
{% else %}
  <p>Nothing else to recommend yet.</p>
{% endrecommend %}

Wraps db.linkPrediction.predict(node, k, {algorithm, candidateLabel, excludeExisting, explain}). Returns rows with {id, title, url, score, reason} where reason is the engine's explain string (e.g. "shared 3 neighbors with 2 candidates").

Arguments:

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

PageRank over the recency-weighted edge subgraph. window: accepts Nm/Nh/Nd/Nw (e.g. 7d, 30d, 24h). Omitting window: ranks across all historical edges. type: restricts the result type.

Fallback: if the engine's pageRank procedure is unavailable, the tag falls back to a recency-ordered list of the type — so the template still renders something useful instead of an empty block.

# Worked examples

Copy-paste-ready templates that combine these primitives with the per-page iteration patterns live at docs/templates/examples/course-index.liquid, related-items.liquid, related-via-similarity.liquid, course-with-counts.liquid, etc.


# {% query %} — saved-query escape hatch

{% query "trending-this-week" as result %}
  <li>{{ result.title }} — {{ result.author }} ({{ result.score }})</li>
{% else %}
  <p>No trending posts.</p>
{% endquery %}

Resolves a Query content node by name (its latest QueryVersion's stored Cypher), runs it on the engine, iterates the rows. Use for the shapes structured filters can't express:

You create saved queries with POST /api/queries — see api.md → Saved queries. The default binding is item if you omit as.

# {% if %} / {% for %} / {% capture %} / {% assign %}

Standard Liquid — see the LiquidJS docs linked above.


# A complete page template

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{{ title }} — {{ site.siteName }}</title>
  <meta name="description" content="{{ description }}">
  <link rel="stylesheet" href="https://cdn.example.com/site.css">
  <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 }}">{{ 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>
      {% list type:"blog_post"
              where:"tags:{{ tags | first }}"
              order:"-publishAt"
              limit:3 as related %}
        <article>
          <h3><a href="{{ related.url }}">{{ related.title }}</a></h3>
        </article>
      {% endlist %}
    </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

# Conditional class for the current page

{% for link in site.nav.primary %}
  <a href="{{ link.href }}"
     class="{% if link.href == request.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 different from "Created"

<time>Published {{ publishAt | date: "%b %-d, %Y" }}</time>
{% if updatedAt and updatedAt != publishAt %}
  <time>Updated {{ updatedAt | date: "%b %-d, %Y" }}</time>
{% endif %}

# Lookup table

{% assign labels = "draft,Draft|published,Published|archived,Archived" | split: "|" %}
{# Use forms-aware tooling for real lookups; this is the dirt-simple inline shape #}

# What's NOT in scope


# Edit-bar wiring (automatic)

Pages rendered for the admin preview / live in-page editor get two <meta> tags + a deferred <script src="https://app.staticowl.com/edit-bar.js"> injected automatically. You don't need to add anything to your template — the renderer slots them in right before </head>. The bar is a no-op for non-logged-in visitors, so it's safe in production output. Opt out at the site level via site.inPageEditor = false if you ever need to.


# See also