The multilingual model
Understand the three independent layers Quilium uses to serve a site in several languages, so you can reason about what belongs where before you translate a single string.
Why three layers
A multilingual Quilium site is not one big translation switch. It is three layers that each answer a different question, and they are deliberately kept apart:
| Layer | Answers | Lives in |
|---|---|---|
| Locale configuration | Which languages exist, which is default, how URLs are prefixed. | Site settings (miscellaneous) |
| UI dictionary | How interface strings written in templates read in each language. | Template strings via ` |
| CMS content | How the editable content of each page and item reads in each language. | Per-locale field values in the CMS |
They are separate because they have different owners, different lifecycles, and different sources of truth. The locale list is a site-wide decision set once. UI strings are written by whoever writes templates and translated once per string. Content is translated per page, per block, per item, by whoever edits content. Letting any one of these leak into another is the most common source of half-translated sites, so the rest of this page is about keeping them distinct.
For how these layers are produced step by step, see the how-to Run a multilingual site. This page explains the model; that one walks the workflow.
Layer 1 — Locale configuration
The first layer declares the languages themselves. It is a single block of site settings, under miscellaneous, that names a default locale and a list of translation locales.
Conceptually it holds, per locale, a display name, a slug (the URL prefix, like fr or en), and a country code used for things like flags. One locale is the default; the others are alternates. A locale can also be configured but not yet served, so you can stage a language before it goes live.
This layer decides three things for the whole site:
- What
localeis at render time. Quilium injects alocaleobject into every render. Templates readlocale.slugfor the active language, never a hardcoded one:
<html lang="{{ locale.slug }}">
-
How URLs are shaped. Every locale prefixes its URLs with its
slug, the default included:/en/the-review,/fr/the-review. Only the bare root/redirects to the default locale; an unprefixed inner path answers 404 rather than forwarding, which is why enabling i18n on a site with an audience comes with a redirect map. Routing and slug-per-locale resolution build on this. See Routing and URLs. -
What
localescontains. The same layer populates thelocalescollection a template iterates to build a language switcher, each entry already carrying that page's URL in its language.
Because this is one site-wide setting, it is the layer you touch least often. You set it up once, and the other two layers operate inside the frame it defines.
Layer 2 — The UI dictionary
The second layer covers the strings that live in templates rather than in content: button labels, empty states, filter names, aria-labels, breadcrumb words. These are part of the interface a developer writes, so they cannot be edited through the CMS content forms. They get their own translation channel: the UI dictionary.
You mark such a string in a template with the translate filter. | _ is the canonical form (it follows the gettext convention); | t is a registered alias with identical behaviour. Both resolve through the same gettext function at render time, so the choice is stylistic. Prefer | _ for consistency with the extractor keyword.
<button>{{ "S'abonner" | _ }}</button>
<p>{{ 'Aucun article pour le moment.' | _ }}</p>
<nav aria-label="{{ 'Navigation principale' | _ }}">
The string you write is the msgid, the key the dictionary looks up for the active locale. If a locale has no translation for that msgid, the source string renders unchanged, so the site stays functional while translation catches up.
What belongs here, and what does not, is the line that keeps this layer clean:
| Use | _ / | t | Do not |
|---|---|
| Button and link labels | content.custom.* block fields |
| Empty states, validation messages | collection entry fields |
| Filter and breadcrumb words | globals.* global fields |
| aria-label, title, placeholder | Navigation titles (navTitle) |
| Static fallback defaults | Slugs and URLs |
Everything on the right is editable content. It is translated through layer three, not the dictionary. Wrapping a content field in | _ is a modelling mistake: the field already carries its own per-locale value, and the dictionary would shadow it with a frozen string.
The dictionary workflow
The dictionary is filled in three moves, and the order matters:
- Author strings in templates with
{{ 'source string' | _ }}(or| t). The argument must be a literal string. A piped variable, like{{ label | _ }}, is not a fixed key, so the next step cannot see it. - Extract with
quilium dictionary parse. It scans the templates, collects every msgid, and registers the new ones as untranslated entries. - Translate each entry per locale through
update-dictionary-translations.
.liquid files were invisible to extraction. The extractor predates the Liquid engine: it only knew the old call form _('…') and never looked at .liquid files. Strings written as | _ / | t were skipped, so they never reached the dictionary even though they translated correctly at runtime once a msgid existed. It was patched in June 2026 to scan .liquid and recognise the filter form. If you are on an older CLI, upgrade, or the gap reappears.This pitfall is worth understanding rather than memorising, because it shows the seam in the model: runtime translation and extraction are two different mechanisms. Runtime always rendered | _ correctly. Extraction is the build-time scan that discovers which msgids exist. The bug was only in the scanner, which is why the symptom was "the string shows in French and never appears in the dictionary to translate" rather than an error.
.liquid extractor fix. (Flags are settled: quilium dictionary parse takes none, and the extraction glob is configured through the dictionary key of quilium.json.)Dynamic labels need a literal somewhere
Because extraction only sees literal strings, a label resolved from data, like an enum value rendered as a human word, is invisible to the scan. The pattern is to keep the dynamic lookup at the use-site and list the literal keys once in a small seed snippet that is never rendered but is scanned, so the extractor still finds them. Reach for this only for enum and choice labels and other data-driven UI words, never for content fields.
Layer 3 — Per-locale CMS content
The third layer is the editable content: page titles, content blocks, collection entries, global fields. Here translation is not a dictionary lookup. Each translatable field holds one value per active locale. A title is not a single string with a separate translation table; it is the field, resolved to the value for the locale being rendered.
A template never translates these. It reads the field, and Quilium has already resolved it to the active locale:
{% assign c = content.custom %}
<h2>{{ c.title }}</h2>
<div class="prose">{{ c.body }}</div>
The same holds for a collection entry read through a query, or a globals.<field>. There is no | _ anywhere near them. That filter is for the interface words around the content, not the content itself.
Slugs are content too. An article carries one slug per locale, so its URL differs by language, and the routing layer picks the right one. That is why translating an item means translating its slug as well, not only its visible text. See The content model for how fields and items are shaped.
Translation mirroring
The rule that ties this layer together is mirroring: every locale must carry the same keys and the same structure. A locale is not allowed to have a different set of blocks, a different field shape, or a missing slug. It is the same content, valued differently per language.
This matters because the renderer resolves a field to the active locale and expects it to be there. When a locale is missing a value, Quilium falls back to the default locale's content so the page still renders, but the page is now lying: <html lang> says one language while the words are in another. Mirroring is what prevents that drift. Two ways to handle an incomplete locale:
- Hide the not-yet-translated page for that locale, so a gap is an absence rather than a wrong-language page.
- Show a banner marking the page as not yet translated, accepting the fallback but signalling it.
Either way, the goal is the same: never silently serve default-locale content under another locale's flag.
hreflang ties the layers together
The three layers each hold one piece of what a search engine needs to understand a multilingual site, and hreflang is where they meet. In the document head, one hreflang link per locale tells crawlers that these URLs are the same page in different languages:
{% for loc in locales %}
<link rel="alternate" hreflang="{{ loc.slug }}" href="{{ loc.url }}" />
{% endfor %}
Read the layers off that one loop. The set of locales comes from layer one. Each loc.url is the current page in that locale, which only resolves correctly because layer three mirrored the content and its slug across locales. The word in the surrounding UI, if any, would come from layer two. When hreflang is wrong, the cause is almost always a gap in one specific layer, which is exactly why telling the layers apart is worth the effort.
Each layer is independent
The payoff of three separate layers is that they compose freely. You can run translated UI over content that is still single-language, or fully translated content under an interface you have not finished translating. A new locale added in layer one does not break the others; it simply renders source strings and fallback content until layers two and three catch up. Nothing is all-or-nothing, and the site stays live at every step.
Where to go next
- Run a multilingual site, the step-by-step how-to for setting up all three layers.
- The content model, how fields and items hold per-locale values.
- Routing and URLs, how locale slugs shape every URL.
- Templating philosophy, why interface strings and content are rendered differently.
Last updated September 6, 2026.