---
title: Turn the work list into a collection
description: Take the project list you already have on the quickstart onepager and promote it into a real collection, with a page per project.
updated: 2026-07-30
prev: quickstart
next: pages-and-publish
---

You finished the [Quickstart](/en/docs/quickstart) with a onepager running locally. Its work section lists eight projects, and they live in a **repeat field**: fine for a fixed list, useless the day you want a page per project, a filter, or a feed. In this tutorial you move them into a collection, without the page changing at all.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Slate.</b> You keep working on the exact project you checked out in the quickstart, the demo site of a small design studio. Nothing new to download. Every YAML block, template diff and URL below is taken from that site after the migration.</div></div>

## What you will have at the end

A `project` collection holding the eight projects, the home page rendering it through a query, a `/work/view/<slug>` URL for each project, and a detail page. The home page looks exactly as it does now.

## 1. Look at what you already have

Open `elements/work.liquid` and the `work` content-type. The projects are a `repeat` field: a list of groups (title, client, summary, image) stored **inside one content block**.

```yaml
work:
  name: Work
  template: elements/work.liquid
  tabsets:
    main:
      name: Selected work
      fields:
        eyebrow:
          type: text
          label: Eyebrow
        title:
          type: text
          label: Title
        projects:
          type: repeat
          label: Projects
          info: One card per project. The first project is featured (coral rule).
          config:
            fields:
              title:
                type: text
                label: Project title
              client:
                type: text
                label: Client
              summary:
                type: textarea
                label: Summary (one line)
              image:
                type: image
                label: Visual
                config:
                  versions:
                    standard:
                      label: '4:3 — 1200x900'
                      width: 1200
                      height: 900
```

The template loops that field directly:

```liquid
{% if c.projects and c.projects.size > 0 %}
<div class="mt-14 grid md:grid-cols-3 gap-6">
  {% for project in c.projects %}
  {% assign pImg = project.image[0].versions.standard.url %}
  ...
  {% endfor %}
</div>
{% endif %}
```

That is the thing to understand before changing it. A repeat field is content *of a block*. A collection is content *of the site*, addressable from anywhere.

## 2. Create the `project` collection

Declare a collection type with the same fields the repeat had, plus a `slug`, which is what gives each project an address.

```yaml
project:
  name: Projects
  objectName: Project
  icon: folder-open
  sortable: true
  tabsets:
    main:
      name: Project
      fields:
        title:
          type: text
          label: Project title
          searchable: true
        slug:
          type: slug
          label: Slug
          info: The address of the project page, under /work/view/.
          config:
            linkedField: title
        client:
          type: text
          label: Client
          searchable: true
        summary:
          type: textarea
          label: Summary (one line)
        image:
          type: image
          label: Visual
          config:
            versions:
              standard:
                label: '4:3 — 1200x900'
                width: 1200
                height: 900
  displays:
    - type: datatable
      config:
        cols:
          - image
          - title
          - client
        order:
          column: _order
          sort: asc
```

Three things are new compared to the repeat field:

| Key | What it does |
|-----|--------------|
| `slug` | The address of the item. `config.linkedField: title` fills it from the title, so "Harbor — booking platform" becomes `harbor-booking-platform`. |
| `sortable: true` | Turns on drag-and-drop ordering in the backoffice. The position is stored in the system `_order` field — do not model an `order` number field by hand. |
| `displays` | The backoffice table: which columns editors see, and the default sort. |

The key is singular — `project`, not `projects` — because the type describes one item. The plural lives in `name: Projects`, which is what editors read in the backoffice.

![The Projects section in the backoffice, listing the eight projects in a datatable with a visual, the project title and the client.](/thumbs/5430bd80-aa3f-11f1-a33f-d9258ad11d95.png)

## 3. Move the eight projects in

Copy each project from the repeat field into an item of the collection. Eight items, same values, same order. The slug fills itself from the title as you type it.

<div class="q-callout q-callout-tip"><i class="fa-solid fa-lightbulb lead"></i>
  <div><b>Do not delete the repeat field yet.</b> Keep it until the query version renders correctly. Comparing the two is how you know you did not lose anything.</div></div>

![The edit form of one project, with the title, the slug filled in as harbor-booking-platform, the client, the summary and the visual.](/thumbs/5432e080-aa3f-11f1-8e03-ad85f3925bf4.png)

## 4. Point the block at the collection

Replace the repeat field with a **query**: the collection to read, the sort, the limit.

```yaml
projectList:
  type: query
  label: Projects
  info: Reads the Projects collection. The first project is featured (coral rule).
  config:
    itemSet: project
    limit: 8
    sort:
      - _order: asc
```

`sort: - _order: asc` is what makes the drag-and-drop order of step 2 the order on the site. The editor decides which project comes first, and therefore which one is featured, without touching a template.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b><code>_order</code> is empty until someone reorders.</b> Items created without ever being dragged have no position, and sorting on an empty position gives an arbitrary order that can change between renders. Open the Projects list and drag the items into the order you want once — that is what fills <code>_order</code>.</div></div>

The template change is two lines — the field the loop reads, and nothing else:

```liquid
{% if c.projectList and c.projectList.size > 0 %}
  {% for project in c.projectList %}
```

Everything inside the loop is untouched. A collection item exposes the same flat field names the repeat groups did, so `project.title`, `project.client`, `project.summary` and `project.image[0].versions.standard.url` all keep working.

Note where the rules live: `limit` and `sort` are in the query, not in the template. They are configuration, so an editor can change them — whereas a `{% if forloop.index <= 8 %}` in Liquid would bury the same rule in code.

See [Build a blog](/en/docs/blog) for the same move written as a reference procedure.

## 5. Check that nothing changed

Reload the local page. Same eight projects, same order, same featured first project.

On Slate the rendered HTML is byte-for-byte identical to the version served by the repeat field — only the per-site image URLs differ:

```bash
diff before.html after.html
```

This is the payoff of the tutorial: the page is identical, and yet the data now lives somewhere it can be reused. Nothing about the visitor's experience changed, everything about what you can build next did.

Now, and only now, delete the `projects` repeat field from the `work` content-type. Two sources for the same list is how they drift apart.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Deleting a field deletes its content.</b> The values stored in the repeat go with it, and there is no undo. Do it once the query version renders correctly, not before — that is what the previous step was for.</div></div>

## 6. Give each project its own page

A collection with no detail page is a list. Three pieces turn items into pages.

**A page-type that resolves the current project from the URL.** The query filters the collection by the slug in the address:

```yaml
projectDetail:
  name: Project detail
  template: projectDetail.liquid
  tabsets:
    config:
      name: Project
      fields:
        project:
          type: query
          label: Project
          info: Resolves the project from the URL. Do not edit.
          config:
            itemSet: project
            where:
              '&=':
                slug: '{{route.project.slug}}'
            limit: 1
  sections:
    row1:
```

`route.project.slug` reads the URL. It resolves **only inside a query config**, never in a template. The `'&='` operator keeps the clause even when the slug is empty, so a missing slug returns zero rows instead of the whole collection.

**A page to hang the route on.** Two pages, because a URL is built from the page tree:

- a `Work` page with the slug `work`, using the `default` page-type. It is the section your projects live under. Leave it empty for now — you fill it in the [next tutorial](/en/docs/pages-and-publish).
- under it, a child page of type `projectDetail` with the slug `view`, set **not visible**. It is not a nav entry, it is the mount point of the detail URLs.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Do not give the child an empty slug to get <code>/work/&lt;slug&gt;</code>.</b> It looks like the prettier URL, but two pages then resolve to <code>/work</code> and publishing refuses it with <code>422 UniqueUrl</code>. Give the child a real segment — <code>view</code> — and the two stop competing.</div></div>

**A route.** Its `path` carries only the *variable* part of the URL. It is appended verbatim to the page URL, which is why it starts with `/`; the page tree supplies the `/work/view` prefix:

```yaml
name: projectDetail
path: /{{project.slug}}
```

which Quilium computes as:

```text
/work/view/{{project.slug}}
```

Do not repeat the prefix in the path: writing `/work/view/{{project.slug}}` there would produce `/work/view/work/view/<slug>`. The `path` is only what comes *after* the page.

In the detail template, `limit: 1` means the query still returns a list, so take the first row:

```liquid
{% assign p = page.custom.project[0] %}
```

And link the cards on the home page. The `url` filter takes a **named argument carrying the whole item**, never the slug:

```liquid
<a href="{{ 'projectDetail' | url: project: project }}">{{ project.title }}</a>
```

which renders as:

```text
/work/view/harbor-booking-platform
/work/view/cadence-analytics-dashboard
/work/view/field-a-shop-that-loads-fast
```

Because the link goes through the route, this is also what makes the URLs safe to change later: move the pages and every link follows, with no template edit.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>A wrong link here fails silently.</b> A typo in the route name, or a positional argument instead of a named one, still responds 200 — it just goes to the wrong place, and the positional form serialises every field of the item into the query string. Check that the link lands on a <i>different</i> page, not merely that it loads.</div></div>

**Finally, give editors the way back.** A collection with a detail route needs a `preview` button, or the backoffice list has no link to the live page:

```yaml
preview:
  - label: View on the site
    route: projectDetail
    params:
      project.slug: '{{slug}}'
```

The `params` key mirrors the route token literally: the path uses `{{project.slug}}`, so the key is the flat string `project.slug`, never a nested map.

The entry lands under the **Preview** menu at the top of the item form, the one already visible in the screenshot at step 3. An editor opens the project page from there, without knowing the URL.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Preview opens the hosted site, not your machine.</b> It renders with the templates stored on the server, so a detail template that only exists locally is not there yet and the page comes back broken. Run <code>quilium push</code> first — see <a href="/en/docs/pages-and-publish">Add pages and publish</a>.</div></div>

The full procedure, with its pitfalls, is in [Build a blog](/en/docs/blog).

## What you learned

- A repeat field belongs to one block; a collection belongs to the site.
- A query is how a block reads a collection — and it carries the sort and the caps, so an editor owns them.
- A route plus a detail page-type is what turns items into pages.

Next: [Add pages and publish](/en/docs/pages-and-publish), where the onepager stops being one page.
