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.
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
slugis the address.linkedFieldseeds 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, likeauteurs.slugbelow. See Run a multilingual site. authoris 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.displaysandfiltersare the backoffice. They shape the admin table. Neither has any effect on the public page.- A
selectstores keys, shows labels.vivantis stored, "Vivant" is displayed, and the key is what your filters compare.
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.
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): listingarticlesat/articles, childarticleat/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 |
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.
{% 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.
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.
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: 1is not optional. Without it, the bare URL — the one with no?pageat 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:oncategoryis dropped when?rubrique=is absent, which is exactly what makes the filter optional.'&='onpublishedfires 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.
/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.
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 hiddenpage=1so 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/orassociate 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 %}
/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.
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.