Templating
ClickLess templates run a Handlebars-flavored engine with first-class support for variables, comparisons, conditionals, loops, partials, and collection entry templates. This page is the complete reference — variables, every field type, loop helpers, partials, and a few helpers (filterByReference, filterFuture, entryUrl) that make real sites possible.
Welcome #
Three kinds of files are deployable inside ClickLess: pages, partials, and collection entry templates. All three are plain HTML with embedded templating tags ({% … %}) and variable interpolations ({{ … }}). You can edit them in the dashboard, in the AI editor, or — with the CLI — in your own editor or LLM toolchain.
Everything in this guide works the same across the dashboard, the AI editor, and CLI-driven workflows. The same template engine renders every page.
How templates work #
When a page renders, the engine has access to:
website— site-level data (name, timezone, primary domain, hosting URLs).page— the current page's metadata (slug, title, status).collections.<name>— every collection on the site, ready to iterate.entry— only inside a collection entry template, the row being rendered.- Anything you set yourself with
{% set %}.
Output is plain HTML. The engine runs server-side on every preview build and every production publish — there's no client-side runtime to ship.
Deployable artifacts #
For LLMs and editors working through the CLI: only these three file patterns are deployable; everything else is read-only context.
| Path pattern | Type | Deployable? |
|---|---|---|
pages/*.html, pages/**/*.html | Page | Yes |
partials/*.html | Partial | Yes |
collections/*/template.html | Entry template | Yes |
*.meta.json | Sidecar | No (read-only) |
collections/*/collection.meta.json | Schema | No (LLM context) |
See the CLI folder layout reference for the full picture.
Variables #
Use {% set %} to declare and assign variables. Variables can hold strings, numbers, booleans, or whole objects pulled from the context.
{% set variableName = value %}
{% set count = 0 %}
{% set message = "Hello World" %}
{% set isActive = true %}
Variables can also be assigned values from the surrounding context:
{% set siteName = website.name %}
{% set productPrice = product.price %}
{% set userName = currentUser.firstName + " " + currentUser.lastName %}
Arithmetic & strings #
All standard arithmetic operators work on number variables:
{% set count = count + 1 %} <!-- Addition -->
{% set total = price * quantity %} <!-- Multiplication -->
{% set average = sum / count %} <!-- Division -->
{% set remainder = total % 10 %} <!-- Modulo -->
{% set diff = max - min %} <!-- Subtraction -->
The + operator doubles as string concatenation:
{% set firstName = "John" %}
{% set lastName = "Doe" %}
{% set fullName = firstName + " " + lastName %}
{{ fullName }} <!-- Output: John Doe -->
Comparisons #
Comparison operators return booleans you can store in variables or use inline in {% if %}:
{% set isAdult = age >= 18 %}
{% set canView = isPublic == true %}
{% set isInvalid = status != "active" %}
{% set inRange = value > min && value < max %}
Supported operators: ==, !=, >, <, >=, <=, &&, ||.
Note: Boolean negation (!) is not supported. Write value == false instead of !value.
Variable scope #
Variables you create inside a loop are scoped to that loop iteration; assignments to variables declared in a parent scope, however, write back to the parent. This is what makes running totals and cross-iteration counters work.
{% set outerCount = 0 %}
{% for category in categories %}
{% set innerCount = 0 %} <!-- Loop-scoped variable -->
{% for product in category.products %}
{% set innerCount = innerCount + 1 %}
{% set outerCount = outerCount + 1 %} <!-- Updates parent -->
{% endfor %}
Category {{ category.name }} has {{ innerCount }} products
{% endfor %}
Total products: {{ outerCount }}
Important notes
- Variables are evaluated when set, not when used.
- Undefined variables return
undefinedrather than raising an error. - Variable names must be valid JavaScript identifiers (letters, digits, underscores).
- Reserved names —
loop,context,website,page,entry— should not be reused.
Conditionals #
Conditionals use the familiar if / elsif / else structure, with closing {% endif %}:
{% if user.subscribed %}
<p>Thanks for subscribing!</p>
{% elsif user.trialEndsAt | isFuture %}
<p>You're on a trial.</p>
{% else %}
<p><a href="/pricing">Subscribe</a></p>
{% endif %}
Loops #
Two equivalent loop syntaxes are supported:
{% for item in collection %} … {% endfor %}
{% each collection as item %} … {% endeach %}
Inside any loop, the loop object exposes iteration metadata — index, first/last flags, total length, and so on.
Time fields #
Time fields store values in 24-hour HH:mm format and can be reformatted with the formatTime filter:
{{ eventTime }} <!-- 14:30 -->
{{ eventTime | formatTime "h:mm A" }} <!-- 2:30 PM -->
{{ eventTime | formatTime "hh:mm A" }} <!-- 02:30 PM -->
Available time format patterns
| Token | Meaning | Example |
|---|---|---|
h | Hours, 12-hour, no leading zero | 1–12 |
hh | Hours, 12-hour, with leading zero | 01–12 |
H | Hours, 24-hour, no leading zero | 0–23 |
HH | Hours, 24-hour, with leading zero | 00–23 |
m | Minutes, no leading zero | 0–59 |
mm | Minutes, with leading zero | 00–59 |
A | Uppercase AM/PM | AM, PM |
a | Lowercase am/pm | am, pm |
Select fields #
Select fields expose both the raw value and the human-readable display label:
{{ status }} <!-- active (raw value) -->
{{ status.label }} <!-- Active (display label) -->
{{ status.value }} <!-- active (explicit value) -->
Use the .value form in conditionals so you're never comparing against a localized label:
{% if status.value == "active" %}
<span class="badge badge-success">{{ status.label }}</span>
{% elsif status.value == "pending" %}
<span class="badge badge-warning">{{ status.label }}</span>
{% else %}
<span class="badge badge-default">{{ status.label }}</span>
{% endif %}
Date fields #
All dates default to your website's configured timezone. The formatDate filter handles every output shape; the formatDateInTimezone and formatDateInUserTimezone filters let you re-anchor.
{{ eventDate | formatDate "MMM DD, YYYY" }} <!-- Jan 15, 2026 -->
{{ eventDate | formatDate "MM/DD/YYYY" }} <!-- 01/15/2026 -->
{{ eventDate | formatDate "MMMM DD, YYYY" }} <!-- January 15, 2026 -->
{{ eventDate | formatDate "relative" }} <!-- 3 days ago -->
{{ eventDate | formatDate "datetime" }} <!-- Jan 15, 2026 at 2:30 PM EST -->
Timezone conversion
{{ eventDate | formatDateInUserTimezone "MMM DD, YYYY h:mm A" }}
{{ eventDate | formatDateInTimezone "America/Los_Angeles" "h:mm A" }}
{{ eventDate | formatDateInTimezone "Europe/London" "MMM DD, h:mm A" }}
Date comparison
{% if event.date | isPast %}
<span class="badge">Past event</span>
{% elsif event.date | isFuture %}
<span class="badge">Upcoming</span>
{% else %}
<span class="badge">Today</span>
{% endif %}
Filter future events
{% filterFuture collections.events "start" as upcomingEvents %}
{% for event in upcomingEvents %}
<div class="event">
<h3>{{ event.title }}</h3>
<p>{{ event.start | formatDate "MMM D, YYYY" }}</p>
</div>
{% endfor %}
Reference fields #
Reference fields are links between collections — a product points at its category, a staff member at their ministries, an event at its speaker. The engine loads the related entry's fields automatically when you access them in a template.
Single references and multi-references behave differently — see below for the breakdown.
Other field types #
Boolean fields
{% if product.featured %}
<span class="featured-badge">Featured</span>
{% endif %}
Number fields
<p>Price: ${{ product.price }}</p>
<p>Total: ${{ product.price * quantity }}</p>
Image fields
{% if product.image isNotEmpty %}
<img src="{{ product.image }}" alt="{{ product.name }}">
{% endif %}
URL fields
{% if company.website isNotEmpty %}
<a href="{{ company.website }}" target="_blank" rel="noopener">
Visit website
</a>
{% endif %}
Empty-value checks #
Two helpers — isEmpty and isNotEmpty — let you safely guard nested property access. Both support prefix and postfix syntax:
<!-- Prefix syntax (traditional) -->
{% if isNotEmpty product.description %}
<p>{{ product.description }}</p>
{% endif %}
<!-- Postfix syntax (more readable) -->
{% if product.description isNotEmpty %}
<p>{{ product.description }}</p>
{% endif %}
{% if product.tags isEmpty %}
<p>No tags available</p>
{% endif %}
Single vs. multiple references #
A reference field configured as single dereferences directly to the related entry. Accessing any field on it returns that field's value. Check for existence before drilling in to avoid rendering empty fragments.
{{ product.category.name }}
{{ product.category.description }}
{% if product.category isNotEmpty %}
<div class="category-info">
<h4>{{ product.category.name }}</h4>
<p>{{ product.category.description }}</p>
</div>
{% endif %}
A multi-reference field is an array of related entries — iterate with {% for %}:
{% for tag in post.tags %}
<span class="tag">{{ tag.name }}</span>
{% endfor %}
<!-- With comma separation -->
{% for tag in post.tags %}
{{ tag.name }}{% if loop.last == false %}, {% endif %}
{% endfor %}
<p>This post has {{ post.tags | count }} tags.</p>
Optional ordering #
Reference fields can carry an optional order value for each selected reference. When enabled in the CMS interface, you can drag-and-drop or hand-set per-reference order; the engine then sorts results by that value when filtering. Useful for prioritizing featured products, staff seniority within a ministry, speaker billing order, or display order of related items.
Behavior
- Entries with an
ordervalue are sorted numerically (ascending; lower comes first). - Entries with an
orderalways appear before entries without one. - Entries without an order keep their original sequence (stable sort).
filterByReference #
Use filterByReference to find every entry in one collection that references a specific entry from another. The result is auto-sorted by the optional order value described above.
<!-- Filter staff by ministry, with custom ordering -->
{% filterByReference collections.staff "ministries" currentMinistry.id as filteredStaff %}
{% for member in filteredStaff %}
<div class="staff-member">
<h3>{{ member.name }}</h3>
<p>{{ member.title }}</p>
</div>
{% endfor %}
You can also inline it without naming the result:
{% for member in (filterByReference collections.staff "ministries" "ministry-id-123") %}
<div class="staff-member">
<h3>{{ member.name }}</h3>
<p>{{ member.role }}</p>
</div>
{% endfor %}
A common pattern is to render every ministry, then nest each one's filtered staff list inside:
{% for ministry in collections.ministries %}
<section class="ministry-section">
<h2>{{ ministry.name }}</h2>
<div class="ministry-staff">
{% filterByReference collections.staff "ministries" ministry.id as ministryStaff %}
{% for member in ministryStaff %}
<div class="team-member">
<img src="{{ member.photo }}" alt="{{ member.name }}">
<h4>{{ member.name }}</h4>
<p>{{ member.title }}</p>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
Loop basics #
Any {% for %} or {% each %} block exposes a special loop object with iteration metadata:
| Property | Type | Meaning |
|---|---|---|
loop.index | number | Current iteration index (0-based) |
loop.index1 | number | Current iteration index (1-based) |
loop.first | boolean | true for the first iteration |
loop.last | boolean | true for the last iteration |
loop.length | number | Total number of items |
Loop state in practice #
Numbering items
{% for item in collections.news %}
<span>{{ loop.index1 }}</span>
<h3>{{ item.title }}</h3>
{% endfor %}
Comma-separated lists
{% for category in post.categories %}
{{ category.name }}{% if loop.last == false %}, {% endif %}
{% endfor %}
First / last styling
{% for slide in collections.slides %}
<div class="slide {% if loop.first %}active{% endif %}">
{{ slide.content }}
</div>
{% endfor %}
Limiting items across nested loops
{% set messageCount = 0 %}
{% for series in collections.sermons %}
<h3>{{ series.title }}</h3>
{% for message in series.messages %}
{% if messageCount < 3 %}
<div>{{ message.title }} – {{ message.date }}</div>
{% set messageCount = messageCount + 1 %}
{% endif %}
{% endfor %}
{% endfor %}
<p>Showing {{ messageCount }} messages.</p>
Partials overview #
Partials are reusable HTML fragments — headers, footers, navigation menus, card components, anything you'd want to render in more than one place. They're versioned and deployable like pages, and live in the dashboard's "Partials" section or — under the CLI — in partials/*.html.
Each partial has a name (used to reference it) and a displayName (for the dashboard list). Inside a page or another partial, include one by name:
Using partials #
{% include 'header' %}
{% include 'footer' %}
{% include 'product-card' %}
The included partial is rendered with the current page's context — variables, current entry (inside an entry template), and any {% set %} values you've already assigned are all visible.
Passing data to partials #
To pass a specific value into a partial, use the where clause to set a named context variable:
{% include 'product-card' where 'product' currentProduct %}
{% include 'cta-block' where 'headline' "Get started today" %}
Inside partials/product-card.html, you'd then reference {{ product.name }}, {{ product.price }}, etc. Common usage is iterating a collection and including a partial per row:
{% for item in collections.products %}
{% include 'product-card' where 'product' item %}
{% endfor %}
Collection schemas #
Every collection on a ClickLess site has a schema — the list of fields each entry can hold, with their types, labels, and required flags. The schema is what powers the dashboard's entry-edit forms; in your templates, it determines which {{ entry.fieldName }} references are valid.
When you pull a project with the CLI, every collection's schema is mirrored to collections/<slug>/collection.meta.json — read-only context for LLM agents writing templates. Example:
// collections/staff/collection.meta.json
{
"$readOnly": true,
"collectionId": "col_xyz",
"name": "Staff",
"slug": "staff",
"description": "Team members",
"pageType": "staff-bio",
"fieldDefinitions": {
"name": { "name": "Name", "type": "text", "required": true, "orderIndex": 0 },
"title": { "name": "Title", "type": "text", "required": false, "orderIndex": 1 },
"bio": { "name": "Bio", "type": "textarea", "required": false, "orderIndex": 2 },
"photo": { "name": "Photo", "type": "file", "required": false, "orderIndex": 3 }
}
}
LLM agents: read collection.meta.json before writing any {{ entry.fieldName }} markup. The fieldDefinitions map is the source of truth — don't invent field names.
Entry templates #
Every collection has one entry template — the HTML used to render an individual entry's page (e.g. a blog post, a staff bio, an event detail). The template has access to a special entry variable that holds every field on the entry.
<!-- collections/staff/template.html -->
<article class="staff-member">
{% if entry.photo isNotEmpty %}
<img src="{{ entry.photo }}" alt="{{ entry.name }}">
{% endif %}
<h1>{{ entry.name }}</h1>
<p class="title">{{ entry.title }}</p>
<div class="bio">{{ entry.bio }}</div>
</article>
Entry URLs #
Use the entryUrl filter to generate a URL to a collection entry's detail page. It handles duplicate titles correctly, respects selective-build settings (returning null for entries without a public URL), uses the existing _slug field when present, and falls back to ID-based URLs otherwise.
<a href="{{ event | entryUrl 'events' }}">{{ event.title }}</a>
{% for post in collections.blog %}
<a href="{{ post | entryUrl 'blog' }}">{{ post.title }}</a>
{% endfor %}
The legacy slugify filter is still available for ad-hoc slug generation but entryUrl is preferred for entry detail links:
{{ "My Blog Post Title" | slugify }} <!-- "my-blog-post-title" -->
Syntax reference #
Variables
{{ variable }}— output a variable.{{ variable | filter }}— apply a filter.{{ variable | filter "argument" }}— filter with an argument.
Conditionals
{% if condition %} … {% endif %}{% if condition %} … {% else %} … {% endif %}{% if condition %} … {% elsif condition %} … {% else %} … {% endif %}
Operators
==equal to,!=not equal to>greater than,<less than,>=/<=for inclusive comparison&&logical AND,||logical OR- Note:
!negation is not supported. Usevalue == false.
Loops
{% for item in collection %} … {% endfor %}{% each collection as item %} … {% endeach %}
Includes
{% include 'partial-name' %}{% include 'partial-name' where 'var' value %}
Best practices #
-
Always check for existence before drilling into nested properties:
{% if product.category isNotEmpty %} {{ product.category.name }} {% endif %} -
Provide fallbacks for optional fields:
{{ product.description || "No description available" }} -
Handle empty collections explicitly:
{% if collections.events | count > 0 %} {% for event in collections.events %}…{% endfor %} {% else %} <p>No events scheduled.</p> {% endif %} - Format consistently across pages — pick one date format and stick to it.
CLI handoff #
If you're editing templates from your own editor or pairing with an LLM in the terminal, the ClickLess CLI brings the same pages, partials, and entry templates onto disk as plain HTML — with versioned writes, drift detection, and typed-domain confirmations on every deploy.
Every CLI deploy creates new version documents in Firestore. Old versions are never deleted — you can roll back any artifact via clickless versions + clickless restore.