Skip to content
Quilium Docs
Back to site
How-to guides

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.

Live example: Vesper. The recursive partial below is Vesper's, a guesthouse site whose footer renders the full site tree. The header pattern comes from Lisière, the current-page flags in section 5 were measured on Cairn, whose menu has a nested branch, and the second navigation in section 6 is Oscilla's.

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.

Never deactivate a page to hide it. 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.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.

<a href="{{ item.url }}"
   class="nav-link{% if item.active %} is-active{% endif %}"
   {% if item.lastActive %}aria-current="page"{% endif %}>{{ item.navTitle }}</a>
Do not drive 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")
A shortcut still occupies a slug, and slugs are unique site-wide. Letting the tool derive one from the navTitle fails with a 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.

On a multilingual site, a shortcut does not inherit its target's translations. The page it points at may have a perfectly good English 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.