---
title: The multilingual model
description: 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.
updated: 2026-06-22
---

## 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 `| _` + the dictionary |
| **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.

<div class="q-ph"><i class="fa-regular fa-image"></i><span><b>Placeholder:</b> "three layers" diagram, three stacked bands (locale config · UI dictionary · per-locale CMS content) feeding one rendered page. To produce from the fil-rouge demo site.</span></div>

For how these layers are produced step by step, see the how-to [Run a multilingual site](/en/docs/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 `locale` is at render time.** Quilium injects a `locale` object into every render. Templates read `locale.slug` for the active language, never a hardcoded one:

```liquid
<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](/en/docs/set-up-redirections). Routing and slug-per-locale resolution build on this. See [Routing and URLs](/en/docs/routing-and-urls).

- **What `locales` contains.** The same layer populates the `locales` collection 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.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>One source of truth for "which languages".</b> Nothing else in the system maintains its own list of languages. The dictionary and the CMS both translate into the locales this layer declares, never into a locale it does not.</div>
</div>

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

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

1. **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.
2. **Extract** with `quilium dictionary parse`. It scans the templates, collects every msgid, and registers the new ones as untranslated entries.
3. **Translate** each entry per locale through `update-dictionary-translations`.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Recently fixed: <code>.liquid</code> files were invisible to extraction.</b> The extractor predates the Liquid engine: it only knew the old call form <code>_('…')</code> and never looked at <code>.liquid</code> files. Strings written as <code>| _</code> / <code>| t</code> 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 <code>.liquid</code> and recognise the filter form. If you are on an older CLI, upgrade, or the gap reappears.</div>
</div>

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.

<div class="q-ph"><i class="fa-solid fa-terminal"></i><span><b>Placeholder:</b> confirm the minimum CLI version carrying the <code>.liquid</code> extractor fix. (Flags are settled: <code>quilium dictionary parse</code> takes none, and the extraction glob is configured through the <code>dictionary</code> key of <code>quilium.json</code>.)</span></div>

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

```liquid
{% 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](/en/docs/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.

<div class="q-callout q-callout-tip"><i class="fa-solid fa-lightbulb lead"></i>
  <div><b>Mirroring is structural, not just textual.</b> It is not enough to translate the words. The same blocks, the same fields, and a per-locale slug must exist for every active locale, or routing and fallback behave in surprising ways.</div>
</div>

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

```liquid
{% 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](/en/docs/multilingual-site), the step-by-step how-to for setting up all three layers.
- [The content model](/en/docs/content-model), how fields and items hold per-locale values.
- [Routing and URLs](/en/docs/routing-and-urls), how locale slugs shape every URL.
- [Templating philosophy](/en/docs/templating-philosophy), why interface strings and content are rendered differently.
