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

Build a blog

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.

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.

Live example: Lisière. Every snippet comes from Lisière, the demo site of an independent long-form magazine, whose articles collection joins to auteurs by value. The front-end filter in section 7 is borrowed from Cairn, a hiking-guide site, which is where that pattern runs today.

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.

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.
  • 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.
Create the collection before anything that references it. A query, a save target or a route param pointing at a collection that does not exist fails at write time, not at render time.

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:

{
  "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.

An entry is live as soon as it is saved. 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.

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:

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.

{% 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:

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
Without the leading slash the path glues to the page URL. path: '{{article.slug}}' concatenates into /articlele-printemps, which matches nothing. Write /{{article.slug}}.

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:

  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:

{% 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:

{%- 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:

<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:

(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.

Do not filter with {% if %} inside the loop. Hiding entries at render time gives a wrong count, breaks the "all" option, and fetched everything anyway.

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:

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:

<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:

        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:

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

The search is scoped to the current locale, for free. Measured on Lisière: the French page returns nothing for an English term, and the English page returns nothing for a French one. search 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.

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:

{% 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:

{% 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.

        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.

            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:

            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:

{%- 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 %}
{%- 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.

A token on the preview link would be stronger, and we do not have one. 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.

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 is about where.

So the page-type carries one field:

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.

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:

{% 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.

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

Two properties of that object decide whether your template works.

The four values are not the same type. totalPages is a number; totalItems, currentPage and perPage are strings. A comparison on currentPage without | times: 1 is therefore alphabetical, and '10' sorts before '9' — the same trap as reading request.query.page raw. Coerce all three on the way in.

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:

{% 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.
{% 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.

{% if currentPage > 1 %}{% assign robotsOverride = 'noindex, follow' %}{% endif %}
Verified on Lisière (/archives, 8 published articles, 3 per page): page 1 at the bare path with Prev disabled, ?page=3 with Next disabled, ?page=99 showing the empty state with a working Prev, ?rubrique=idees dropping the total to 3 and hiding the nav entirely, and every link locale-prefixed in both languages.

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.

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.

An import is a publishing act. Entries have no draft state — forty created entries are forty live articles, on every page whose query matches them. Either add the published field from section 9 before importing and leave the boxes unticked, or import knowing the site is about to grow by forty.

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.

Related


Last updated September 7, 2026.