Build a template (Liquid)
Write the Liquid files that turn a page-type and its content blocks into rendered HTML, from the root layout down to a single element.
You have a page-type and a few content-types declared in YAML. This guide writes the templates that render them.
articles collection.quilium init) and quilium run to preview. Templates are plain .liquid files at the site root. The engine is LiquidJS, so every standard tag and filter you know works, plus the Quilium additions listed in Liquid filters.1. Know the four kinds of file
Every template you write is one of four roles. Nothing else exists.
| Kind | Example path | Renders |
|---|---|---|
| Root layout | layout.liquid |
the document: <head>, chrome, {% block content %} |
| Page-type layout | layouts/accueil.liquid |
one page-type; fills the blocks the root layout declares |
| Element | elements/aLaUne.liquid |
one content-type, that is, a single content block |
| Partial | _header.liquid, _articleCard.liquid |
a shared fragment, included by name |
A page-type's template key points at a page-type layout; a content-type's template key points at an element. Use the full path in both ('layouts/accueil.liquid', 'elements/aLaUne.liquid').
2. Write the root layout
layout.liquid holds everything shared by every page and declares the blocks children fill.
<!doctype html>
<html lang="{{ locale.slug }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{% assign brand = globals.brandName %}
{% assign effectiveTitle = pageTitle | default: page.metas.title | default: page.title | default: brand %}
{% block title %}<title>{{ effectiveTitle }}{% unless effectiveTitle contains brand %} — {{ brand }}{% endunless %}</title>{% endblock %}
<meta name="description" content="{{ page.metas.description | default: globals.baseline }}" />
{% block head %}{% endblock %}
</head>
<body>
{% include '_header' %}
<main id="main">{% block content %}{% endblock %}</main>
{% include '_footer' %}
</body>
</html>
Declare as few blocks as you need. Three (title, head, content) cover most sites.
3. Write the page-type layout
A page-type layout inherits the root layout and fills its blocks. Most of them are three lines, because the page's real content is the blocks the editor dropped into a section.
{% layout "layout.liquid" %}
{% block content %}
{% include '_contents', section: page.contents['main'] %}
{% endblock %}
page.contents['<section>'] is the array of blocks in that drop zone, in editor order. _contents is the dispatcher partial that renders each one; see Reuse blocks (partials) for its five lines.
To override something in the parent layout, assign outside any block. Assigns at that level bubble up to the parent, which is how a detail page sets its own <title> from route-aware data:
{% layout "layout.liquid" %}
{% if page.custom.article and page.custom.article.size > 0 %}
{% assign pageTitle = page.custom.article[0].title %}
{% endif %}
{% block content %}
{% include '_contents', section: page.contents['main'] %}
{% endblock %}
4. Write an element
One element per content-type. It renders a single block and reads that block's own fields from content.custom. Start every element by aliasing it:
{% assign c = content.custom %}
<section>
{% if c.title != blank %}<h2>{{ c.title }}</h2>{% endif %}
{% if c.excerpt != blank %}<div class="prose">{{ c.excerpt }}</div>{% endif %}
</section>
A wysiwyg field renders as HTML, no filter needed. A text or textarea field renders as text. An image field is an array of objects, so guard it and pick a named version:
{% if c.image and c.image.size > 0 %}
<img src="{{ c.image[0].versions.card.url }}"
alt="{% if c.image[0].name != blank %}{{ c.image[0].name }}{% else %}{{ c.title }}{% endif %}"
width="800" height="600" loading="lazy" />
{% endif %}
5. Render a list that comes from a query
A query field returns an array, whether it is declared on the page-type (route-aware, read as page.custom.<key>) or on the content-type itself (read as content.custom.<key>). Filtering, sorting and capping happen in the query config, never in the template. The template loops and handles the empty case:
{% assign c = content.custom %}
{% assign source = page.custom.latest %}
{% if source and source.size > 0 %}
<div class="grid">
{% for art in source %}
{% include '_articleCard', art: art %}
{% endfor %}
</div>
{% else %}
<p>{% if c.emptyMessage != blank %}{{ c.emptyMessage }}{% else %}{{ 'No article yet.' | _ }}{% endif %}</p>
{% endif %}
To link an item to its detail page, resolve the route rather than building the path by hand:
<a href="{{ 'article' | url: article: art }}">{{ art.title }}</a>
'article' is the route name, article: its param key, art the item. The result is already locale-aware. See Build a blog.
6. Read site-wide values
Global fields are flat on globals, without the tabset key in the path:
<p>{{ globals.copyright }} {{ now | date: 'YYYY' }}</p>
<a href="mailto:{{ globals.email }}">{{ globals.email }}</a>
Navigation comes from sitemap.<nav>.children, and each item.url is already locale-aware:
{% for item in sitemap.main.children %}
<a href="{{ item.url }}"{% if item.active %} aria-current="page"{% endif %}>{{ item.navTitle }}</a>
{% endfor %}
The full list of readable objects is in Variables and context.
7. Preview
quilium run
The local server renders your local templates against your site's content. Edit a .liquid file, reload the page, and you see the change.
Pitfalls
page.custom.article[0] on an empty result renders nothing useful and hides the real problem. Always test {% if x and x.size > 0 %} first.route.*does not exist in a template. It is resolved on the page-type, inside a query config. Templates read the result onpage.custom.<queryKey>.- Point
template:at the real file. A content-type with no explicittemplatefalls back to<key>.html, which is usually a dangling reference. Write the full path. - Cap and filter in the query, not in the loop. A
limitin the query is one fetch; aforloopbreak is a full fetch you then throw away. - Do not reference a YAML anchor you have not defined. It breaks the push, not the render.
Related
Last updated September 6, 2026.