Build navigation and menus
Render menus from the site tree instead of hardcoding them, nest pages, hide what should not appear, and reorder without touching Liquid.
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.
1. Read the tree, do not rebuild it
sitemap.<nav>.children is the navigation, already resolved, already locale-aware:
<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:
{%- 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.
state: inactive makes it 404 everywhere, locally included. Hiding is visible: false.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.activeis the whole trail — the current page and its ancestors.item.lastActiveis 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.
<a href="{{ item.url }}"
class="nav-link{% if item.active %} is-active{% endif %}"
{% if item.lastActive %}aria-current="page"{% endif %}>{{ item.navTitle }}</a>
aria-current from active. 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 active, the assistive one takes lastActive.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")
{% 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")
UniqueUrl error on page.slug, because the target already owns that slug. Pass a distinct one — it is never served, so footer-tarifs is a perfectly good answer.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.
navTitle; the shortcut will still render the default-locale one. Each entry needs its own update-page with translations. A four-link footer on a bilingual site is four writes, not zero.Related
Last updated September 6, 2026.