# 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:
strictVariables: false— references to undefined fields render as empty strings instead of throwing. Templates survive schema evolution.strictFilters: false— unknown filters degrade gracefully.
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:
- Predicates are comma-separated, not joined by
AND.where:"status:published, featured:true"works;where:"status:published AND featured:true"silently fails to filter (the parser splits on:first, so the second token becomes the literal value"published AND featured"). - Liquid interpolation works in
where:,order:, andtype:. The arg strings are rendered against the current page's context before parsing, sowhere:"parentId:{{ id }}"enumerates "items whose parent is THIS page." Filter chains work too (where:"key:{{ title | downcase }}"). Missing context vars resolve to empty string, not an error.
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:
- Children need a flat
parentIdfield on import. The{% list %}filter pushes down to direct node properties, so children should be stored withparentId: "<parent-id>"at ingest time. Customers running/api/import/jsontypically stamp this in their record-shaping step. - The interpolated string is parsed as a comma-separated predicate list.
where:"parentId:{{ id }}, status:published"does both filters. The first colon in the rendered string separates field from value, so embedded colons in the id (course:cs101) are kept as the value. - Missing context vars render as empty string.
where:"parentId:{{ missing }}"becomeswhere:"parentId:", which matches zero rows — your{% else %}block fires. No crash.
# 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/jsondoes NOT auto-embed — runPOST /api/maintenance/embeddings/materialize?type=<typeName>after the import (idempotent; safe to re-run). Single-record creates viaPOST /api/content/:typeauto-embed by default. If you forget, the build response will include atransformErrorlike{% similar %} failed: ... bulk import does NOT auto-embed.— the gap is now visible end-to-end.
# {% similar %} — related items via embeddings
{% 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 %} — build-time semantic search
{% 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:
after:(required) — source node id; "after this page, what should they see?"type:— restrict candidates to a content typealgo:— one ofjaccard(default),adamicAdar,commonNeighbors,preferentialAttachment. Unknown values silently drop to the engine default.excludeExisting:— defaults totrue(skip directly-linked items)limit:— max rows (1–50)
# {% trending %} — pageRank with 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 %}
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:
- multi-hop traversal (
Post -[:WRITTEN_BY]-> Author -[:WORKS_AT]-> Org) - aggregations (
count,sum,avggrouped by a field) - joins across multiple content types
- anything you'd write as Cypher anyway
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
- Server-side includes / partials —
{% include "_header.liquid" %}is not currently exposed. To share markup, define a template kind in the schema and let templates reference it via the Page Kit system. Roadmap. - Cross-content-type joins inside
{% list %}— the filter form is single-type. Reach for{% query %}with a saved Cypher query. - Mutations from templates — templates are pure read-side. No
{% assign | save %}to the graph, no fire-and-forget side effects. Mutations go through/api/content, lifecycle hooks, or MCP. - Async filters —
markedruns synchronously; filters are sync-only. Async work lives in{% list %}/{% query %}(the engine call) and lifecycle hooks (the build-time pipeline).
# 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
- HTTP API —
/api/templates/*— CRUD on templates - Routing —
routePattern— control the URL shape per content type - Lifecycle hooks —
beforeRenderfor transforming data before it hits the template packages/server/src/build/render.ts— the authoritative implementation (when in doubt, the code wins)