---
title: Build a template (Liquid)
description: 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.
updated: 2026-07-25
next: reuse-partials
---

You have a page-type and a few content-types declared in YAML. This guide writes the templates that render them.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Lisière.</b> Every snippet on this page comes from <b>Lisière</b>, the demo site of an independent long-form magazine, bilingual, whose home page is the index of its <code>articles</code> collection.</div></div>

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>Before you start.</b> A local checkout (<code>quilium init</code>) and <code>quilium run</code> to preview. Templates are plain <code>.liquid</code> files at the site root. The engine is LiquidJS, so every standard tag and filter you know works, plus the Quilium additions listed in <a href="/en/docs/liquid-filters">Liquid filters</a>.</div></div>

## 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.

```liquid
<!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.

```liquid
{% 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)](/en/docs/reuse-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:

```liquid
{% 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:

```liquid
{% 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:

```liquid
{% 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:

```liquid
{% 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:

```liquid
<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](/en/docs/blog).

## 6. Read site-wide values

Global fields are flat on `globals`, without the tabset key in the path:

```liquid
<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:

```liquid
{% 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](/en/docs/template-variables).

## 7. Preview

```bash
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

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Guard before you index.</b> Every query result is an array. <code>page.custom.article[0]</code> on an empty result renders nothing useful and hides the real problem. Always test <code>{% if x and x.size > 0 %}</code> first.</div></div>

- **`route.*` does not exist in a template.** It is resolved on the page-type, inside a query config. Templates read the result on `page.custom.<queryKey>`.
- **Point `template:` at the real file.** A content-type with no explicit `template` falls 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 `limit` in the query is one fetch; a `forloop` break 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

<div class="q-cards">
  <a class="q-card reco" href="/en/docs/reuse-partials"><span class="k">Recommended</span><span class="t">Reuse blocks (partials)</span><span class="d">The dispatcher, shared fragments and scope leaks.</span></a>
  <a class="q-card" href="/en/docs/template-variables"><span class="t">Variables and context</span><span class="d">Every object a template can read.</span></a>
  <a class="q-card" href="/en/docs/templating-philosophy"><span class="t">Templating philosophy</span><span class="d">Why the data shape lives in YAML, not in the template.</span></a>
</div>
