---
title: Build a blog
description: Model articles as a collection, list them, give each one its own URL, let visitors filter and search, and know what goes live the moment you save.
updated: 2026-08-19
---

A blog is the reference case for a Quilium collection: entries that grow over time, a listing, a page per entry, and a few ways to narrow the list. Everything here also applies to a knowledge base, a product catalogue or a press room.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Lisière.</b> Every snippet comes from <b>Lisière</b>, the demo site of an independent long-form magazine, whose <code>articles</code> collection joins to <code>auteurs</code> by value. The front-end filter in section 7 is borrowed from <b>Cairn</b>, a hiking-guide site, which is where that pattern runs today.</div></div>

## The pieces, and the order

| Piece | Why |
|---|---|
| A collection type | the articles themselves |
| A `query` on a content-type | the listing block |
| A detail page-type, a hidden page, a route | one URL per article |
| A `preview` on the collection | editors jump from an entry to its page |
| Data attributes plus a little JavaScript | the visitor narrows the list |

Build them in that order. A route created before its target page, or a `preview` before its route, fails at write time.

## 1. Model the articles

Fields live inside `tabsets`, never at the type root.

```yaml
articles:
  name: Articles
  objectName: Article
  icon: file
  tabsets:
    main:
      name: Article
      fields:
        title:
          type: text
          label: Title
          translatable: true
          searchable: true
        slug:
          type: slug
          label: Slug
          config:
            linkedField: title
        excerpt:
          type: textarea
          label: Standfirst
          translatable: true
        body:
          type: wysiwyg
          label: Body
          translatable: true
        category:
          type: select
          label: Section
          config:
            options:
              vivant: Vivant
              terrains: Terrains
              idees: Idées
              entretiens: Entretiens
        author:
          type: text
          label: Contributor
          info: Enter the slug of a contributor (join by value)
          translatable: false
        date:
          type: date
          label: Date de publicationRhythm
        image:
          type: image
          label: Cover image
          config:
            versions:
              card:
                label: Carte
                width: 800
                height: 600
              cover:
                label: Couverture
                width: 1600
                height: 900
        featured:
          type: checkbox
          label: Featured
          config:
            checkedValue: oui
            uncheckedValue: non
  displays:
    - type: datatable
      config:
        cols: [image, title, category, date]
        order:
          column: date
          sort: desc
  filters:
    - category
    - featured
```

Four decisions worth making deliberately:

- **The `slug` is the address.** `linkedField` seeds it from the title as the editor types. In a multilingual site, translate it with the title — a French reader gets a French address, and the engine remaps the menu, the feeds and the alternates around it. The one slug that must **not** be translated is a join key, like `auteurs.slug` below. See [Run a multilingual site](/en/docs/multilingual-site).
- **`author` is a plain text field holding another entry's slug.** That is a join by value, resolved by a query later. No relation field is involved.
- **`displays` and `filters` are the backoffice.** They shape the admin table. Neither has any effect on the public page.
- **A `select` stores keys, shows labels.** `vivant` is stored, "Vivant" is displayed, and the key is what your filters compare.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>Create the collection before anything that references it.</b> A query, a <code>save</code> target or a route param pointing at a collection that does not exist fails at write time, not at render time.</div></div>

## 2. Is it really a collection?

Three shapes look alike from a distance, and picking wrong is the most common modelling mistake.

| Shape | Use |
|---|---|
| A list an editor types inside one block | a single `wysiwyg` field |
| A short fixed set of typed mini-records, shown in one place | a `repeat` field on the content-type |
| An open-ended set, reused across pages, filterable, growing | an **collection** |

The test is reuse and growth. Four tiles that exist only on the home page are a `repeat`. Articles referenced from three pages and added weekly are a collection.

## 3. Add entries

In the backoffice the collection gets its own section, driven by `displays`. Over MCP, an item's fields go under a `data` wrapper, and images are arrays of objects:

```json
{
  "data": {
    "title": "Le printemps des lisières",
    "slug": "le-printemps-des-lisieres",
    "category": "vivant",
    "author": "claire-vasseur",
    "image": [{ "id": "…" }]
  }
}
```

`custom` is for content blocks, `data` is for collection items. Mixing them is a 422.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>An entry is live as soon as it is saved.</b> Collection items have no draft state, unlike pages. The moment an item exists, every query that matches it will return it. If you need staged entries, see section 9.</div></div>

## 4. List the articles

Declare a `query` field on the content-type that displays them. Putting the query on the **content-type** keeps the block self-contained, so an editor can drop it on any page and it works:

```yaml
fluxArticles:
  name: Flux d'articles
  template: 'elements/fluxArticles.liquid'
  tabsets:
    main:
      name: Feed
      fields:
        latest:
          type: query
          label: Articles
          config:
            itemSet: articles
            limit: 6
            sort:
              - date: desc
        emptyMessage:
          type: textarea
          label: Empty state
```

Put the query on the **page-type** instead when it depends on the route, which is the case for detail pages and category pages.

```liquid
{% assign c = content.custom %}
{% assign articles = c.latest %}

{% if articles and articles.size > 0 %}
  <div class="grid">
    {% for art in articles %}
      <article>
        {% if art.image and art.image.size > 0 %}
          <img src="{{ art.image[0].versions.card.url }}" alt="{{ art.title }}"
               width="800" height="600" loading="lazy">
        {% endif %}
        <h3><a href="{{ 'article' | url: article: art }}">{{ art.title }}</a></h3>
        {% if art.excerpt != blank %}<p>{{ art.excerpt }}</p>{% endif %}
      </article>
    {% endfor %}
  </div>
{% else %}
  <p>{% if c.emptyMessage != blank %}{{ c.emptyMessage }}{% else %}{{ 'No article yet.' | t }}{% endif %}</p>
{% endif %}
```

Two habits that save afternoons: guard the array before looping, and give the empty state a real message, because a collection nobody has filled yet is the normal state on day one.

## 5. Give each article a URL

**The detail page-type** carries the query that resolves the current entry. `{{route.<param>.slug}}` is valid **only** inside a query config:

```yaml
article:
  name: Article (detail)
  template: 'layouts/article.liquid'
  icon: file
  sections:
    main:
  tabsets:
    config:
      name: Article data
      fields:
        article:
          type: query
          label: Current article
          config:
            itemSet: articles
            limit: 1
            where:
              '&=':
                slug: '{{route.article.slug}}'
        author:
          type: query
          label: Author
          config:
            itemSet: auteurs
            limit: 1
            dependsOn: [article]
            where:
              '&=':
                slug: '{{article.author}}'
        related:
          type: query
          label: Related
          config:
            itemSet: articles
            limit: 3
            dependsOn: [article]
            where:
              and:
                - '&=':
                    category: '{{article.category}}'
                - '&!=':
                    slug: '{{route.article.slug}}'
            sort:
              - date: desc
```

`dependsOn: [article]` runs a query **after** the first one and lets it read the resolved entry as `{{article.<field>}}`. That is how the join by value works: the article stores an author slug, the second query turns it into the author record.

**The page**: create one page of that page-type, keep it out of the menu with `visible: false`, leave its state active, and give it a **real slug**. An empty slug is refused at publish (`422 UniqueUrl`): a child cannot answer on its parent's URL. Two shapes work:

- a child of the listing page with its own segment and the parent prefix unchecked (`isSlugPrefixed: false`): listing `articles` at `/articles`, child `article` at `/article`, route at `/article/<slug>`. With the prefix left on, the same child serves `/articles/article/<slug>`.
- a hidden page at the root of the tree, slug `article`, at `/article`. This is the shape of the AI path tutorials.

Re-parenting is a `reorder-navigation` call, `update-page` does not move a page in the tree.

**The route**: `path` holds only the variable part. Quilium appends it verbatim to the page URL, so it starts with a slash.

| Key | Value |
|---|---|
| `name` | `article`, the handle the query reads as `route.article.slug` |
| `path` | `/{{article.slug}}` |
| `page_id` | the hidden detail page |

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Without the leading slash the path glues to the page URL.</b> <code>path: '{{article.slug}}'</code> concatenates into <code>/articlele-printemps</code>, which matches nothing. Write <code>/{{article.slug}}</code>.</div></div>

**The preview link**, once the route exists, so editors jump from an entry to its page. The params key is the flat token from the pattern:

```yaml
  preview:
    - label: Voir sur le site
      route: article
      params:
        'article.slug': '{{slug}}'
```

## 6. Render the article page

Query results arrive on `page.custom.*` as arrays. Guard before indexing, and assign the page title **outside** any block so it reaches the root layout:

```liquid
{% layout "layout.liquid" %}

{% if page.custom.article and page.custom.article.size > 0 %}
  {% assign pageTitle = page.custom.article[0].title %}
{% endif %}

{% block content %}
  {% include '_contents', section: page.contents['main'] %}
{% endblock %}
```

The blocks dropped into `main` each render a slice of what the page-type resolved. They carry no fields of their own:

```liquid
{%- comment -%} elements/enteteArticle.liquid {%- endcomment -%}
{% if page.custom.article and page.custom.article.size > 0 %}
  {% assign a = page.custom.article[0] %}
  <header>
    <h1>{{ a.title }}</h1>
    {% if a.excerpt != blank %}<p>{{ a.excerpt }}</p>{% endif %}
    <time datetime="{{ a.date }}">{{ a.date | date: 'D MMMM YYYY' }}</time>
  </header>
{% endif %}
```

Splitting the detail page into placeable blocks rather than one hardcoded template is what lets an editor reorder the byline, the body and the related list without touching Liquid.

## 7. Let visitors filter the list

Three mechanisms carry the word "filter", and only one of them is what visitors see.

| Mechanism | Where it runs | Who it serves |
|---|---|---|
| `filters` on the collection type | backoffice | editors, in the admin table |
| `where` in a `query` | server, at render | the page: what gets fetched at all |
| Data attributes plus JavaScript | browser | visitors, narrowing what is on screen |

Pick server side when the criterion is fixed for the page, a category page or a featured list. Pick front end when the visitor chooses and the set is small enough to send at once. Beyond a few hundred entries, the answer is a route per category or pagination.

Stamp each criterion onto the card as a `data-` attribute, writing the **stored key**, never the translated label:

```liquid
<article class="art-card" data-rubrique="{{ art.category }}" data-date="{{ art.date }}">
```

Then one small script sorts, shows or hides, and updates the count. This is the Cairn implementation, unchanged apart from the class names:

```js
(function () {
  var grid = document.querySelector('.art-grid');
  var filter = document.querySelector('.art-filter');
  if (!grid || !filter) return;
  var count = document.querySelector('.art-count');
  var cards = Array.prototype.slice.call(grid.querySelectorAll('.art-card'));

  function render() {
    var value = filter.value;
    var visible = 0;
    cards.forEach(function (card) {
      var ok = (value === 'all') || (card.getAttribute('data-rubrique') === value);
      card.style.display = ok ? '' : 'none';
      if (ok) visible++;
    });
    if (count) count.textContent = visible;
  }

  filter.addEventListener('change', render);
  render();
})();
```

Render the count server side as well, so it is right before the script runs. And check the page with JavaScript disabled: every article is in the HTML already, which is what makes this safe for search engines.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Do not filter with <code>{% if %}</code> inside the loop.</b> Hiding entries at render time gives a wrong count, breaks the "all" option, and fetched everything anyway.</div></div>

## 8. Add a search

The articles are a collection, so the search is a **collection query** — the same `query` field as the listing in section 4, with one more clause. Nothing external is involved: no search service, no endpoint, no key, no JavaScript. The term arrives from the query string and goes straight into the `where`.

Give the results page its own page-type, since it carries its own query:

```yaml
recherche:
  name: Recherche
  template: 'layouts/recherche.liquid'
  sections:
    main:
  tabsets:
    config:
      name: Results
      fields:
        results:
          type: query
          label: Search results
          config:
            itemSet: articles
            limit: 20
            where:
              and:
                - search: '{{query.q}}'
                - '&=':
                    published: oui
            sort:
              - date: desc
```

Then create one hidden page on that type — `state: active` so it is routable, `visible: false` so it stays out of the menu — and give the template a plain `GET` form whose input is named `q`:

```liquid
<form action="{% include '_localeUrl', path: '/recherche' %}" method="get" role="search">
  <label for="q">{{ 'Chercher un article' | t }}</label>
  <input type="search" id="q" name="q" value="{{ request.query.q | escape }}" />
  <button type="submit">{{ 'Chercher' | t }}</button>
</form>

{% for art in page.custom.results %}
  {% include '_articleCard', art: art %}
{% endfor %}
```

Note the two spellings of the same value: **`{{query.q}}` in the YAML, `request.query.q` in the template.** The query config and the template read the request through different namespaces, and neither accepts the other's form. And `| escape` is not optional — Liquid never escapes anything on its own, and this value comes from the visitor.

### What `search` actually matches

`search` covers the fields marked `searchable: true` on the collection type — and only those. Section 1 set it on `title`; for a search worth the name, set it on the body fields too:

```yaml
        excerpt:
          type: textarea
          searchable: true
        body:
          type: wysiwyg
          searchable: true
```

Measured on Lisière, nine articles, once `title`, `excerpt` and `body` were all searchable:

| Query | Result | What it tells you |
|---|---|---|
| `?q=forêt` | 5 articles | matches across all three fields, accents included |
| `?q=FORÊT` | 5 articles | case-insensitive |
| `?q=forê` | **0 articles** | whole words only — this is not a substring match |
| `?q=forêt entretien` | 1 article | several words are combined with **AND**, not OR |
| `?q=` absent | 8 articles | the clause is dropped, see below |

The third row is the one that surprises people. `search` is word-based: a partial word finds nothing, so it cannot drive an autocomplete or a type-as-you-go field. When you want substring behaviour, that is `ilike` on a named field, with `%` as the wildcard:

```yaml
              - ilike:
                  title: '%{{query.q}}%'
```

Measured against the same corpus, `?q=forê` returns 1 with `ilike` where `search` returned 0 — and only 1 rather than 5, because `ilike` reads the single field you name while `search` reads all the searchable ones. Pick per need: `search` for a real search box, `ilike` for a narrow lookup on one column.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>The search is scoped to the current locale, for free.</b> Measured on Lisière: the French page returns nothing for an English term, and the English page returns nothing for a French one. <code>search</code> reads the translated values of the locale being rendered, so a bilingual site gets two independent search indexes without declaring anything. Nothing to configure, and nothing to work around either — a visitor cannot search the other language from this page.</div></div>

### Three states, and one of them is a trap

An empty term is not an error, and it is not zero results. A clause whose variable resolves to empty is **dropped**, so arriving on the results page with no `?q=` runs a query with only the `published` filter left — and lists the entire corpus. Say so in the interface rather than letting it read as a search result:

```liquid
{% if terme == blank %}
  {{ 'Tous les articles, faute de terme de recherche.' | t }}
{% elsif results.size > 0 %}
  {{ results.size }} {{ 'résultat(s) pour' | t }} <b>{{ terme | escape }}</b>
{% else %}
  {{ 'Aucun résultat pour' | t }} <b>{{ terme | escape }}</b>
{% endif %}
```

If you would rather show nothing until someone types, make the clause strict with the `&` prefix — `'&search': '{{query.q}}'` — and an empty term yields zero rows instead of all of them.

Note that `published` stays strict either way. A filter that must always apply is exactly what strict mode is for: were it written `'='`, an unset flag anywhere would drop it and quietly surface unpublished articles.

### Keep the results page out of the index

Every distinct term is a distinct URL, and their content is already indexed on the articles themselves. Declare the page `noindex` from its own template, the same mechanism section 9 uses for staged entries:

```liquid
{% assign robotsOverride = 'noindex, follow' %}
```

`follow` rather than `nofollow`: there is no reason to stop a crawler walking from a result to the article it points at.

## 9. Staging entries, and pagination

Two things a growing blog asks for, neither of which comes for free.

**An entry has no draft.** Pages do, collection items do not: saving an item publishes it. To stage articles, add your own field and filter on it.

```yaml
        published:
          type: checkbox
          label: Published
          info: Unticked, the article drops out of every list but stays reachable by its URL — and its page turns noindex
          translatable: false
          config:
            checkedValue: oui
            uncheckedValue: non
```

Then add the condition to every **listing** query, and leave the **detail** query alone.

```yaml
            where:
              '&=':
                published: oui
```

Four things that decide whether this works, in the order they bite.

**Backfill every existing entry, explicitly.** The moment the filter exists, an entry with no value for `published` is invisible — an equality clause never matches a missing field. A `default:` does not save you: defaults fire when a row is created, so the rows that already exist stay empty and disappear from the site the second you save the query. On Lisière that was nine writes before anything rendered again. Verify by filtering on each value and checking the totals add up:

```
publié: 8 + non publié: 1 = 9 = total de la collection  ✓
```

**Find every listing query, not the obvious one.** Lisière has six pointing at `articles`: the featured item and the latest feed on the home page-type, the category page-type, the related-articles query on the detail page-type, the reusable feed content-type, and the corpus behind the hand-picked selector. A query you miss is a page where staged entries still show, and it is the *related articles* block — buried on the detail page — that gets forgotten.

Where a `where` already exists, adding a second condition means moving to the list form. One clause per item:

```yaml
            where:
              and:
                - '&=':
                    category: '{{route.category.slug}}'
                - '&=':
                    published: oui
```

**A hand-picked reference bypasses the filter entirely.** An `items` field, where an editor chooses entries by id, carries no `where` — nothing filters it. That is defensible, since picking an entry by hand is an editorial decision, but it means "unpublished" is a promise the listing queries keep and the manual selector does not.

**The flag is not per locale.** One publication state for the whole entry, every language. Staging a translation while the source is live would need its own flag and is not a pattern we have run.

### The detail page: reachable, but never indexed

Leaving the detail query unfiltered is deliberate — it is what makes the collection's `preview` link work, so an editor can look at an article before anyone else can find it. The cost is that the URL is guessable, and a guessable URL that returns 200 is a URL a crawler will eventually reach.

So the detail query stays open **and the page declares itself unindexable**. The metas of the hidden detail page cannot carry it, because that single page serves every entry. It comes from the page-type template instead, through the same escape hatch as the title — an `{% assign %}` outside any block reaches the parent layout:

```liquid
{%- comment -%} layouts/article.liquid {%- endcomment -%}
{% if page.custom.article and page.custom.article.size > 0 and page.custom.article[0].published != 'oui' %}
  {% assign robotsOverride = 'noindex, nofollow' %}
{% endif %}
```

```liquid
{%- comment -%} layout.liquid {%- endcomment -%}
{% assign effectiveRobots = robotsOverride | default: page.metas.robots %}
{% if effectiveRobots != blank %}<meta name="robots" content="{{ effectiveRobots }}" />{% endif %}
```

Measured on Lisière: the staged article answers 200 with `noindex, nofollow` in both locales, the published one carries no robots tag at all, and the category page that used to list two articles now lists one.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>A token on the preview link would be stronger, and we do not have one.</b> Nothing in the platform signs a preview URL, so "unlisted plus noindex" is the whole of the protection. It is the right pattern for an embargoed publication date; it is not the right pattern for something confidential.</div></div>

**Pagination.** A `query` field paginates natively, and it really is as small as it sounds: give it a `page:` and a `limit:`, and it returns one page instead of the whole collection. One field, two extra lines.

The instinct at this point is to add a second query to count the corpus, because the paginated one hands back a bare array with no `.total` on it. Resist it — the total is published elsewhere, and [the next section](#the-total-and-where-it-actually-lives) is about where.

So the page-type carries **one** field:

```yaml
archive:
  name: Archives
  template: 'layouts/archive.liquid'
  sections:
    main:
  tabsets:
    config:
      name: Paginated feed
      fields:
        articles:
          type: query
          config:
            itemSet: articles
            limit: 3
            page: '{{query.page}}'
            defaults:
              page: 1
            where:
              and:
                - '&=':
                    published: oui
                - in:
                    category: '{{query.rubrique}}'
            sort:
              - date: desc
```

That is the whole of it. Two details in that YAML earn their place:

- **`defaults: page: 1` is not optional.** Without it, the bare URL — the one with no `?page` at all, which is to say the one everybody lands on — can render an empty list.
- **The filter is permissive, the visibility rule is strict.** `in:` on `category` is dropped when `?rubrique=` is absent, which is exactly what makes the filter optional. `'&='` on `published` fires no matter what. Getting these backwards either leaks staged entries or returns nothing on the unfiltered page. See [Custom fields](/en/docs/custom-fields#where-clause-operators).

### The total, and where it actually lives

The paginated query returns **only the current page**, as a bare array: `.total`, `.last_page` and `.current_page` are not on it. That much is true, and it is where most implementations go wrong — they add a second query whose only job is to be counted.

They do not need to. **The moment a query carries `page:`, the engine publishes a sister variable next to it, named after the field with `Meta` appended.** For a query field called `articles`, that is `articlesMeta`:

```liquid
{% assign articles = page.custom.articles %}
{% assign pagination = page.custom.articlesMeta.pagination %}

{% assign total = pagination.totalItems | times: 1 %}
{% assign lastPage = pagination.totalPages | times: 1 %}
{% assign currentPage = pagination.currentPage | times: 1 %}
```

No count query, no ceiling division. `totalPages` arrives computed.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Measured on Lisière</b>, <code>/archives</code>, 8 published articles, 3 per page. <code>articlesMeta.pagination</code> returns <code>{ totalItems, totalPages, currentPage, perPage }</code>: <code>"8" / 3 / "1" / "3"</code> on the bare URL, <code>"2"</code> as current page on <code>?page=2</code>, and — the decisive one — <code>"3" / 1</code> on <code>?rubrique=idees</code>. <b>The meta honours the <code>where</code></b>, URL filter included, which is what makes counting by hand pointless. The sister variable appears <i>only</i> for a query carrying <code>page:</code>: a plain query field has no <code>…Meta</code> beside it.</div></div>

Two properties of that object decide whether your template works.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>The four values are not the same type.</b> <code>totalPages</code> is a <b>number</b>; <code>totalItems</code>, <code>currentPage</code> and <code>perPage</code> are <b>strings</b>. A comparison on <code>currentPage</code> without <code>| times: 1</code> is therefore alphabetical, and <code>'10'</code> sorts before <code>'9'</code> — the same trap as reading <code>request.query.page</code> raw. Coerce all three on the way in.</div></div>

**`currentPage` is not clamped.** On a three-page archive, `?page=99` comes back as `"99"`, not `3` — the meta reports what was asked for, not what exists. Detecting the overflow is still the template's job, and it is what drives the "past the last page" state below.

One naming trap survives all of this: **`{{query.page}}` is YAML-side, `request.query.page` is template-side.** Same value, two names, and using the wrong one raises no error at all — just a page that is permanently page 1.

### The links

The nav has to render **outside** the "are there results" branch. Someone will land on `?page=99`, and an empty list with no navigation is a dead end:

```liquid
{% if lastPage > 1 or currentPage > 1 %}
  {% include '_pagination', currentPage: currentPage, lastPage: lastPage,
     basePath: basePath, qsFilters: qsFilters, page1Href: page1Href %}
{% endif %}
```

Inside the partial, the rules that matter are about URLs rather than markup:

- **Page 1 is the bare path — never `?page=1`.** Two URLs for one page is one canonical competing with itself.
- **Filters survive pagination; the page number does not survive a filter change.** Page 1 keeps `?rubrique=idees`, and the filter form posts a hidden `page=1` so that changing the filter from page 3 doesn't strand the visitor on a page 3 that no longer exists.
- **Precompute compound conditions.** Liquid has no parentheses and `and`/`or` associate to the right, so the "is this page number inside the visible window" test goes into its own variable rather than inline.
- **`(1..lastPage)` is inclusive at both ends**, unlike the exclusive-end ranges of other template engines.

```liquid
{% for p in (1..lastPage) %}
  {% assign inWindow = false %}
  {% if p == 1 or p == lastPage %}{% assign inWindow = true %}{% endif %}
  {% if p >= winLow and p <= winHigh %}{% assign inWindow = true %}{% endif %}
  {% if inWindow %}
    {% if p == currentPage %}
      <span aria-current="page">{{ p }}</span>
    {% else %}
      {% if p == 1 %}{% assign pHref = page1Href %}{% else %}{% capture pHref %}{{ basePath }}?page={{ p }}{{ qsFilters }}{% endcapture %}{% endif %}
      <a href="{{ pHref }}">{{ p }}</a>
    {% endif %}
  {% elsif p == ellipsisLeft or p == ellipsisRight %}
    <span aria-hidden="true">…</span>
  {% endif %}
{% endfor %}
```

Finally, the SEO half: keep page 1 indexable and drop the rest. The archive's content already lives on the articles themselves, and deep pages only add near-duplicate URLs.

```liquid
{% if currentPage > 1 %}{% assign robotsOverride = 'noindex, follow' %}{% endif %}
```

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Verified on Lisière</b> (<code>/archives</code>, 8 published articles, 3 per page): page 1 at the bare path with Prev disabled, <code>?page=3</code> with Next disabled, <code>?page=99</code> showing the empty state <i>with</i> a working Prev, <code>?rubrique=idees</code> dropping the total to 3 and hiding the nav entirely, and every link locale-prefixed in both languages.</div></div>

## 10. Let an agent do the import

Modelling the collection is the part that needs you. Filling it from a spreadsheet, a legacy export or a folder of markdown files is the part that does not.

```prompt
Create the entries of the articles collection from the file I just gave you.
Map: title to title, standfirst to excerpt, body to body, section to category
(store the key, not the label), author slug to author, date to date. Leave image empty.
Show me the first entry as the exact JSON you will send, before you create the rest.
```

The mapping line is the useful half. `category` is a `select`, so it stores `vivant` and displays "Vivant" — an agent handed a spreadsheet of labels will cheerfully write the labels, and the filter in section 7 then matches nothing while the listing looks perfectly normal. Naming the stored key in the prompt is cheaper than finding that later.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>An import is a publishing act.</b> Entries have no draft state — forty created entries are forty live articles, on every page whose query matches them. Either add the <code>published</code> field from section 9 <b>before</b> importing and leave the boxes unticked, or import knowing the site is about to grow by forty.</div></div>

Images are the one thing not to fold into the same pass: they are uploaded to the media library first, and referenced by uuid afterwards. See [Manage media and images](/en/docs/media-and-images).

## Related

<div class="q-cards">
  <a class="q-card reco" href="/en/docs/rich-article"><span class="k">Recommended</span><span class="t">A rich, flexible article body</span><span class="d">Let editors compose the article body from typed blocks.</span></a>
  <a class="q-card" href="/en/docs/routing-and-urls"><span class="t">Routing and URLs</span><span class="d">How a request becomes a rendered entry.</span></a>
  <a class="q-card" href="/en/docs/itemset-types"><span class="t">Collection-type schema</span><span class="d">Every key, including <code>displays</code>, <code>filters</code> and <code>preview</code>.</span></a>
  <a class="q-card" href="/en/docs/multilingual-site"><span class="t">Run a multilingual site</span><span class="d">Translating entries and switching locales.</span></a>
</div>
