---
title: Build navigation and menus
description: Render menus from the site tree instead of hardcoding them, nest pages, hide what should not appear, and reorder without touching Liquid.
updated: 2026-08-19
---

A Quilium menu is not markup you maintain, it is the page tree rendered. Get that right once and adding a page to the site adds it to the menu, in every locale, with the correct URL.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Vesper.</b> The recursive partial below is Vesper's, a guesthouse site whose footer renders the full site tree. The header pattern comes from <b>Lisière</b>, the current-page flags in section 5 were measured on <b>Cairn</b>, whose menu has a nested branch, and the second navigation in section 6 is <b>Oscilla</b>'s.</div></div>

## 1. Read the tree, do not rebuild it

`sitemap.<nav>.children` is the navigation, already resolved, already locale-aware:

```liquid
<nav>
  {% for item in sitemap.main.children %}
    <a href="{{ item.url }}">{{ item.navTitle }}</a>
  {% endfor %}
</nav>
```

`navTitle` is the menu label, which is not always the page title. A page called "Our approach to responsible hosting" is "Approach" in the menu.

## 2. Go deeper with a recursive partial

A partial that includes itself renders any depth without knowing it in advance:

```liquid
{%- comment -%} _sitemap.liquid, arg: nav {%- endcomment -%}
<ul>
  {% for child in nav.children %}
    <li>
      <a href="{{ child.url }}">{{ child.navTitle }}</a>
      {% if child.children.size > 0 %}{% include '_sitemap', nav: child %}{% endif %}
    </li>
  {% endfor %}
</ul>
```

Call it with `{% include '_sitemap', nav: sitemap.main %}`. The same file serves a footer site map and a multi-level header.

## 3. Hide what should not appear

`visible: false` keeps a page out of menus while leaving it routable. That is what detail pages, thank-you pages and editor-facing pages use.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Never deactivate a page to hide it.</b> <code>state: inactive</code> makes it 404 everywhere, locally included. Hiding is <code>visible: false</code>.</div></div>

## 4. Reorder and nest

Order and parenting are data, changed with `reorder-navigation`, which takes a flat list of the pages that moved. `update-page` does not move a page in the tree, which surprises everyone once.

Nesting matters beyond the menu: a detail page mounted under the listing page is what makes its route serve `/article/<slug>` rather than `/<slug>`.

## 5. Mark the current page

Nothing to compute: every item already carries two flags, and they are not the same flag.

- **`item.active`** is the whole trail — the current page **and** its ancestors.
- **`item.lastActive`** is the current page alone, the leaf of that trail.

Measured on Cairn, whose detail page is nested under the "Itinéraires" listing:

| You are on | `Itinéraires.active` | `Itinéraires.lastActive` |
|---|---|---|
| `/fr/itineraires` — the listing itself | `true` | `true` |
| `/fr/itineraires/detail/arete-gerbier` — a detail page | `true` | `false` |

That second row is the whole point of having two flags. The parent lights up on a detail page, which is what you want for a menu; but the visitor is not on the listing, so nothing there should claim to be the current page.

```liquid
<a href="{{ item.url }}"
   class="nav-link{% if item.active %} is-active{% endif %}"
   {% if item.lastActive %}aria-current="page"{% endif %}>{{ item.navTitle }}</a>
```

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Do not drive <code>aria-current</code> from <code>active</code>.</b> On any detail page it lands on an ancestor, and a screen reader announces the listing as the page you are on. The visual state takes <code>active</code>, the assistive one takes <code>lastActive</code>.</div></div>

Never rebuild these by comparing `item.url` to the current path: `item.url` carries the locale prefix, so the comparison is false in every locale but the default — a bug that only shows up the day the site becomes bilingual.

## 6. More than one menu

A site usually has at least two, a header and a footer, and they are separate navigations rather than one tree filtered twice. Rendering the header's tree in the footer is the shortcut everyone takes on day one, and it is why so many footers list exactly the same five links as the header.

**The key you choose is the key you read.** `create-navigation` derives one from the name unless you pass it; pass it, because it becomes the path in every template.

```
create-navigation(name: "Pied de page", key: "footer")
```

```liquid
{% for item in sitemap.footer.children %}
  <li><a href="{{ item.url }}">{{ item.navTitle }}</a></li>
{% endfor %}
```

That is the whole template side. Note what is **not** there: no branch on the entry type, no second field for the URL. A second navigation renders exactly like the first.

**You populate it with shortcuts, not with copies.** A second menu points at pages that already exist, so its entries are of type `shortcut` and carry the target's id:

```
add-page-to-navigation(navigation_key: "footer", navTitle: "Tarifs",
                       nav_type: "shortcut", shortcut: "<page-uuid>",
                       type: "default", slug: "footer-tarifs")
```

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>A shortcut still occupies a slug, and slugs are unique site-wide.</b> Letting the tool derive one from the navTitle fails with a <code>UniqueUrl</code> error on <code>page.slug</code>, because the target already owns that slug. Pass a distinct one — it is never served, so <code>footer-tarifs</code> is a perfectly good answer.</div></div>

The reason the slug does not matter is worth stating plainly, because the API response makes it look as though it might. **In a template, `item.url` on a shortcut is already the target's resolved URL**, locale prefix included — `/fr/tarifs`, not `/footer-tarifs`. The `shortcut` object visible in the tool's JSON response is not exposed to Liquid at all; `item.shortcut.url` renders empty. Measured on Oscilla.

**Each entry carries its own label, which cuts both ways.** The footer entry above is titled "Demander une démo" while the header calls the same page "Contact" — that is the feature, and it is a large part of why a second navigation beats a filtered first one.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>On a multilingual site, a shortcut does not inherit its target's translations.</b> The page it points at may have a perfectly good English <code>navTitle</code>; the shortcut will still render the default-locale one. Each entry needs its own <code>update-page</code> with <code>translations</code>. A four-link footer on a bilingual site is four writes, not zero.</div></div>

## 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">How include-with-arguments works, and its scope trap.</span></a>
  <a class="q-card" href="/en/docs/template-variables"><span class="t">Variables and context</span><span class="d">The <code>sitemap</code> object, key by key.</span></a>
  <a class="q-card" href="/en/docs/routing-and-urls"><span class="t">Routing and URLs</span><span class="d">Why nesting decides the URL.</span></a>
</div>
