Liquid Templates
Reference for Airogel's Liquid tags, filters and template variables, including query, paginate and field value shapes.
Overview
Sites on Airogel CMS are rendered with Liquid. You can use all of standard Liquid (if, for, assign, capture, the built-in filters and so on), and Airogel adds tags, filters and variables for working with your content. Templates are stored in your account. You can edit them in the dashboard, over the Templates API or MCP, or locally with Liquiditor.
How a page is rendered
- The URL is matched to a collection index page or an entry (see Routing).
- The content template is rendered. For an entry, that's the entry's own template or else the collection's template. For an index page, it's the collection's index template.
- The result is passed to the layout as
{{ content_for_layout }}. For an entry, the layout is the entry's layout, else the collection's layout, else the template with handletheme. Index pages use the collection's layout ortheme.
A minimal layout:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
{{ content_for_header }}
{{ 'site.css' | asset_url | stylesheet_tag }}
</head>
<body>
{% render 'site-header' %}
{{ content_for_layout }}
</body>
</html>
Always output {{ content_for_header }} in <head>. It includes the page's SEO and social meta tags, the canonical URL, feed discovery links, and the CSS and JavaScript that forms need.
Partials and frontmatter
{% render 'handle' %}and{% include 'handle' %}load another template in your account by its handle. As in standard Liquid,renderonly sees the variables you pass to it ({% render 'card', entry: post %}), whileincludeshares the current scope.- If a template starts with a
---…---frontmatter block, the block is removed before the template is parsed. You can use it for notes or metadata, and it never renders.
Template variables
| Variable | Available on | Contents |
|---|---|---|
<collection_handle> | Entry pages | The current entry, under its collection's handle, e.g. {{ posts.title }} |
<collection_handle> | Index pages | The collection: name, handle, schema_type, entries (published entries), total_count |
page | Index pages | The current page number (1, 2, …), taken from /page/N in the URL |
<global_handle> | Entry and index pages | Each global set, by its handle: title, handle and its fields, e.g. {{ site_settings.phone }} |
navigation.<handle> | Everywhere | The top-level items of a navigation. Each has title, url, its fields, and children when it has sub-items. |
events | Everywhere | Published events, soonest first (see Events) |
current_path | Entry and index pages | The request path, e.g. /blog/page/2 |
notice, alert | Entry and index pages | One-time messages, e.g. a form's success message (notice) or its errors (alert) |
content_for_layout | Layouts | The rendered content template |
content_for_header | Layouts | Meta tags, feed links, and the CMS form CSS and JS |
Some variables only exist inside a block tag: paginate inside {% paginate %}, query inside {% query %}, and form inside {% form_for %}.
Only the current collection is available as a variable. To list entries from any other collection, use {% query %} (below).
Entry properties
| Property | Description |
|---|---|
id | Entry ID (cnety_…) |
title | Entry title |
handle | Entry handle (slug) |
content_path | The entry's page path, e.g. /blog/hello-world |
published_at | Publish date and time |
<field_handle> | Any field from the entry's blueprint |
Entries have no .url property. Use .content_path to link to an entry:
{% for post in posts.entries %}
<a href="{{ post.content_path }}">{{ post.title }}</a>
<time>{{ post.published_at | date: "%B %-d, %Y" }}</time>
{% endfor %}
Field value shapes
| Field type | Value in Liquid | Example |
|---|---|---|
| Text, Number, Enumerate, Toggle | A plain string, number or boolean | {{ post.subtitle }} |
| Rich Text, Raw HTML | An HTML string | {{ post.body }} |
| Image | A path string to the file (not an object) | {{ post.hero | image_tag: post.title }} |
| Video | An object with url, video_id, thumbnail | {{ post.video.url | video_player }} |
| Gallery | An array of items with type, url, id, thumbnail, filename, width, height | {% for item in post.gallery %}…{% endfor %} |
| Entity | The referenced entry (or an array of entries if the field allows multiple), with the same properties as above | {{ post.author.title }}, {{ post.author.content_path }} |
| List, Dictionary | An array, or key/value pairs | {% for tag in post.tags %}…{% endfor %} |
For an image field, {{ post.hero.url }} is empty. Use {{ post.hero }} instead. See Field Types Reference for every field type.
Custom tags
| Tag | Purpose |
|---|---|
{% paginate %} | Split a list of entries into pages |
{% query %} | Fetch entries from any collection, with filters and sorting |
{% form_for %} | Render a form that saves submissions as entries (see Form For Tag) |
{% cms_scripts %} | Output the CMS form CSS and JS. Only needed if your layout doesn't output {{ content_for_header }}. Use css: "false" or js: "false" to output just one of them. |
{% locale %} | Outputs the current locale, en |
{% doc %} | Documents a template or partial. Renders nothing. |
{% paginate %}
{% paginate posts.entries by 10 %}
{% for post in paginate.items %}
<a href="{{ post.content_path }}">{{ post.title }}</a>
{% endfor %}
{% if paginate.total_pages > 1 %}
<nav>
{% if paginate.has_previous %}<a href="{{ paginate.previous_url }}">Newer</a>{% endif %}
{% for p in paginate.pages %}
{% if p.is_gap %}…
{% elsif p.is_current %}<strong>{{ p.number }}</strong>
{% else %}<a href="{{ p.url }}">{{ p.number }}</a>{% endif %}
{% endfor %}
{% if paginate.has_next %}<a href="{{ paginate.next_url }}">Older</a>{% endif %}
</nav>
{% endif %}
{% endpaginate %}
- The syntax is
{% paginate <list> by <number> %}, where the page size is a whole number written directly in the tag. The list is usually<collection_handle>.entrieson an index page, but any array works, such asquery.entries. - Available properties:
items,current_page,total_pages,total_count,per_page,has_previous,has_next,previous_page,next_page,previous_url,next_url, andpages(each item hasnumber,url,is_currentandis_gap; a gap stands for skipped page numbers). - Page URLs are paths, not query strings: page 1 is
/blog, and page 2 is/blog/page/2. The current page comes from the URL. - Pagination only works on index pages that aren't at the site root. An index page mounted at
/has no/page/NURLs.
Index pages are set up on the collection with index_routing (e.g. blog) and index_template_handle. See Routing.
{% query %}
Fetches entries from any collection, on any page:
{% query collection: 'events', order_by: 'starts_on', order_dir: 'asc', per_page: 5, filter: { fields: [{ path: 'featured', op: 'eq', value: true }] } %}
{% for item in query.entries %}
<a href="{{ item.content_path }}">{{ item.title }}</a>
{% else %}
<p>Nothing scheduled.</p>
{% endfor %}
<p>{{ query.total }} total</p>
{% endquery %}
| Option | Default | Description |
|---|---|---|
collection | (required) | Collection handle |
published | true | Setting false also returns unpublished entries, and they will appear on your public page |
order_by | published_at | published_at, created_at, updated_at, title, handle, position, or any field handle |
order_dir | desc | asc or desc |
per_page | 25 | Maximum 100 |
page | The page variable, else 1 | Page number |
filter | none | { fields: [{ path: 'field', op: 'eq', value: x }, …] }. All conditions must match. |
Filter operators: eq, ne, contains, not_contains, starts_with, ends_with, gt, gte, lt, lte, in, nin, exists, is_empty. Toggle fields support only eq and ne. Enumerate fields support eq, ne, in and nin.
A filter value can be a quoted string, a number, true or false, or a variable, such as value: posts.id to find entries that reference the current post. Each condition must be written in the order path, op, value.
If a condition's value is false, empty, or a variable that doesn't resolve, that condition is silently skipped, so the query returns more entries than you expect. To match toggles that are off, use op: 'ne', value: true.
Inside the block:
query.entries(also available asquery.results): each entry hastitle,handle,content_path,published,published_atand its field values.query.total: the total number of matching entries.query.pagination:current_page,total_pages,total_count,per_page,has_previous,has_next,previous_page,next_page.query.error: a message if the query failed, such as an unknown collection or an invalid filter. In that casequery.entriesis empty.
{% doc %}
Describes what a partial expects. The contents are never rendered or run, so examples can contain literal Liquid. The theme_validate and theme_generate_liquid_docs MCP tools read these comments.
{% doc %}
Renders a card for one entry.
@param {object} entry - The entry to show
@param {string} [cta] - Optional button label
@example
{% render 'entry-card', entry: post, cta: 'Read more' %}
{% enddoc %}
The tag takes no arguments. Mark a parameter as optional by putting its name in square brackets.
Custom filters
| Filter | Usage | Output |
|---|---|---|
asset_url | {{ 'logo.png' | asset_url }}, {{ 'images/logo.png' | asset_url }} | The URL of an uploaded asset, looked up by filename (and folder, if you include one). Outputs nothing if there's no match. |
image_tag | {{ url | image_tag: 'Alt text', 'css-class' }} | <img src alt class> |
video_player | {{ url | video_player: class: 'w-full', thumbnail: poster, video_id: id }} | A YouTube or Vimeo iframe for those URLs, otherwise the built-in player for uploaded videos |
script_tag | {{ url | script_tag }}, {{ url | script_tag: 'default' }} | <script type="module">. Pass 'default' for a classic script with no type. |
stylesheet_tag | {{ url | stylesheet_tag }}, {{ url | stylesheet_tag: 'print' }} | <link rel="stylesheet">, media="all" by default |
money | {{ 25 | money }}, {{ 25 | money: '€' }} | $25.00, €25.00 |
image_tag doesn't escape its arguments. If an alt text might contain quotes, pass it through escape first.
Tips
- Check a template before publishing it with the MCP tools
theme_validateandpreview_render_path.theme_generate_liquid_docslists the exact variable and field names for your account. - Only use CSS classes that exist in your site's stylesheet. Classes that appear only in content or templates stored in the CMS aren't guaranteed to be in a compiled Tailwind build.
- Every page is also available as Markdown by adding
.mdto its path, e.g./blog/hello-world.md.