↑ ↓ to navigate
↵ to select
esc to close
Guides Intermediate

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

  1. The URL is matched to a collection index page or an entry (see Routing).
  2. 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.
  3. 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 handle theme. Index pages use the collection's layout or theme.

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, render only sees the variables you pass to it ({% render 'card', entry: post %}), while include shares 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

VariableAvailable onContents
<collection_handle>Entry pagesThe current entry, under its collection's handle, e.g. {{ posts.title }}
<collection_handle>Index pagesThe collection: name, handle, schema_type, entries (published entries), total_count
pageIndex pagesThe current page number (1, 2, …), taken from /page/N in the URL
<global_handle>Entry and index pagesEach global set, by its handle: title, handle and its fields, e.g. {{ site_settings.phone }}
navigation.<handle>EverywhereThe top-level items of a navigation. Each has title, url, its fields, and children when it has sub-items.
eventsEverywherePublished events, soonest first (see Events)
current_pathEntry and index pagesThe request path, e.g. /blog/page/2
notice, alertEntry and index pagesOne-time messages, e.g. a form's success message (notice) or its errors (alert)
content_for_layoutLayoutsThe rendered content template
content_for_headerLayoutsMeta 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

PropertyDescription
idEntry ID (cnety_…)
titleEntry title
handleEntry handle (slug)
content_pathThe entry's page path, e.g. /blog/hello-world
published_atPublish 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 typeValue in LiquidExample
Text, Number, Enumerate, ToggleA plain string, number or boolean{{ post.subtitle }}
Rich Text, Raw HTMLAn HTML string{{ post.body }}
ImageA path string to the file (not an object){{ post.hero | image_tag: post.title }}
VideoAn object with url, video_id, thumbnail{{ post.video.url | video_player }}
GalleryAn array of items with type, url, id, thumbnail, filename, width, height{% for item in post.gallery %}…{% endfor %}
EntityThe 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, DictionaryAn 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

TagPurpose
{% 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>.entries on an index page, but any array works, such as query.entries.
  • Available properties: items, current_page, total_pages, total_count, per_page, has_previous, has_next, previous_page, next_page, previous_url, next_url, and pages (each item has number, url, is_current and is_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/N URLs.

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 %}
OptionDefaultDescription
collection(required)Collection handle
publishedtrueSetting false also returns unpublished entries, and they will appear on your public page
order_bypublished_atpublished_at, created_at, updated_at, title, handle, position, or any field handle
order_dirdescasc or desc
per_page25Maximum 100
pageThe page variable, else 1Page number
filternone{ 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 as query.results): each entry has title, handle, content_path, published, published_at and 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 case query.entries is 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

FilterUsageOutput
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_validate and preview_render_path. theme_generate_liquid_docs lists 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 .md to its path, e.g. /blog/hello-world.md.