---
title: A collection without a route
description: Build the contributors — a collection that deliberately has no page of its own, rendered in two places through one query and one hydration.
updated: 2026-08-15
prev: ai-collection-detail
next: ai-form-page
---

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

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Lisière.</b> 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 <code>proto/contributors.html</code>.</div></div>

## 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](/en/docs/ai-collection-detail) 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.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>No route means no mount point.</b> 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.</div></div>

## 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 <i>absent</i> here:

```yaml
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.](/thumbs/3d9b7680-aa3f-11f1-a288-09d69f607558.png)

## 3. Build it

```prompt
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.
```

```answer
⏺ 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:

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

```yaml
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.

<div class="q-callout q-callout-tip"><i class="fa-solid fa-lightbulb lead"></i>
  <div><b>The relation is real, the rendering is loose.</b> The join is an <code>items</code> 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.</div></div>

## 5. Look at it

```prompt
QA: the grid against the back-office drag order, the links inside
the cards, and what the upload did to the article pages.
```

```answer
⏺ /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.](/thumbs/3d9d9c30-aa3f-11f1-9d38-17e317b4ce84.png)

## 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](/en/docs/ai-form-page) — the collection that writes itself.
