Skip to content
Quilium Docs
Back to site
Tutorials

A collection without a route

Build the contributors — a collection that deliberately has no page of its own, rendered in two places through one query and one hydration.

The previous tutorial gave a collection its own URLs. This one deliberately does not — and that decision is worth as much as the other.

Live example: Lisière. Its contributors appear in a grid on one page and in the author box under every article, and never have a page of their own. The reference is proto/contributors.html.

What you will have at the end

The /contributors page live: a grid of bylines in the exact order the editors drag them, cards that link nowhere on purpose — and, as a side effect of one media upload, a portrait appearing in the author box of all seven articles.

Before you start

A collection with its own pages done. And once more, the architecture pass was ahead of you: the contributors collection exists and its four rows are full — the author box has been reading them since the detail step. The grid block exists too, query included. What is missing is one portrait in the media library, one element template, and the page itself.

1. Decide against a detail page

A collection needs a route when its entries are destinations — something to link to, to share, to index. Contributors here are context: you read a name under an article, not a page about the person. So: no route, no detail page-type, no hidden page. The whole chain from the previous tutorial, deliberately not built.

No route means no mount point. The collection exists, it is queried, it renders — it simply has no address. You can add the route later without touching anything that already reads the collection.

2. The query that was already waiting

The grid block carries its own query — and it is worth reading next to the detail-page queries, because everything hard about those is absent here:

people:
  type: query
  label: The contributors
  config:
    itemSet: contributor
    limit: 60
    sort:
      # Manual order first — editors drag the rows in the collection.
      # `created_at` is the fallback for rows nobody has dragged yet.
      - '_order': asc
      - created_at: asc
    where:
      and:
        - state: Actif

No route variable — there is no URL to resolve. No dependsOn — nothing to wait for. A block query is the simple case: the page is the mount, the block carries the list, and the filter is one state clause.

The one deliberate choice is the sort. The collection is declared sortable: true, which gives the editors drag handles in the back-office — and '_order': asc is what makes the front page obey them. The created_at fallback matters more than it looks: without it, rows nobody has dragged yet have no stable order, and the grid can shuffle between two renders.

The Contributors collection in the back-office: four rows with their portraits, roles and Active state.

3. Build it

The contributors page now: proto/contributors.html.
The collection has no route and that stays true — the cards link
nowhere. A byline is context, not a destination.
The grid follows the order the editors drag in the back-office.
⏺ get-skills() → get-skill × 3
  ⎿ the architecture matrix, the media chain, the block conventions

⏺ Bash(quilium media upload contributor-portrait.png)
  ⎿ ✓ one upload, one uuid — the alt written once, on the media entry

⏺ update-itemset-item × 4
  ⎿ the same portrait referenced by the four rows

⏺ Write elements/contributorGrid.liquid
  ⎿ portrait, name, role, bio — and not a single <a> in the card

⏺ add-content-to-page × 4
  ⎿ pageHeader, contributorGrid, reviewBand, newsletterBand — three
    of the four blocks existed before this page did

⏺ Bash(quilium push)
  ⎿ ✓

The reuse ratio keeps improving: one new element file, zero new YAML. The header is the same pageHeader as The review's, the two bands are the same blocks as the home page's — a page is an assembly, and by the third page the parts are mostly on the shelf.

Inside the new element, two small habits worth keeping:

{% if p.portrait[0] %}
  <img src="{{ p.portrait[0].versions.card.url }}"
       alt="{% if p.portrait[0].alt != blank %}{{ p.portrait[0].alt }}{% else %}{{ p.name }}{% endif %}" … />
{% endif %}

The guard, because a contributor without a portrait should cost a card its image, not the page its render. And the alt fallback chain — the media entry's alt first, the person's name as the last resort — so no portrait ever ships unnamed.

4. The second rendering costs nothing

The stub of this idea was already in the previous tutorial, easy to miss: the article resolver hydrates its relations —

article:
  config:
    itemSet: article
    limit: 1
    with:
      - author
      - section

— and the author box under every article reads article.author[0] as a full contributor object. That is the collection's second rendering, and it required no query at all. The grid reads the collection through a query; the author box reaches it through a relation. Same rows, two shapes.

This is also where the single media upload pays out: the moment the portrait landed on the four rows, every author box on the site gained an avatar — seven article pages changed, and no template was touched, because the box had carried its {% if author.portrait[0] %} branch since the day it was written.

The relation is real, the rendering is loose. The join is an items field, not a copied string — but every read of it is guarded. An article without an author renders no box, a contributor without a portrait renders no image, and nothing 500s. On a public site, degrading quietly is a feature you design, not an accident.

5. Look at it

QA: the grid against the back-office drag order, the links inside
the cards, and what the upload did to the article pages.
⏺ /contributors
  ⎿ 200 — one h1, four cards, in the drag order, zero Liquid leaks

⏺ grep the grid for <a>
  ⎿ 0 — the collection has no destination and the template keeps
    that promise

⏺ the 7 article pages
  ⎿ every author box now carries the portrait — one upload, seven
    pages changed, no template touched

⏺ grep everything for src=""
  ⎿ 0 — no version key typo'd, no upload forgotten

The <a> count is the check specific to this page. On a routed collection the QA asks "does every card leave the listing"; here it asks the exact opposite — and both are one grep.

And the visual pass, as always: the page next to proto/contributors.html on your two ports, and a drag in the back-office collection followed by a reload — the grid must follow.

The contributors page rendered: the bylines stamp, the Contributors title, the editorial intro, and the grid of portrait cards below.

What you learned

  • Not every collection deserves a route. Choosing is a modelling decision, and "no" leaves the door open — the route can come later without touching what already reads the collection.
  • A block query is the simple case of the query field: no route variable, no dependsOn — those belong to detail pages.
  • sortable: true on the collection plus '_order': asc in the query is the whole of "the editors decide the order".
  • The same rows can render through a query in one place and through a hydrated relation in another — the second rendering is free.
  • One media upload can change every page that reads the collection. That is the point of referencing, never copying.

Next: A form that persists — the collection that writes itself.


Last updated September 7, 2026.