Run a multilingual site
Declare locales, translate content and interface strings, and make every link in your templates land on the right language.
A Quilium site serves one set of templates for every locale. What changes is the data behind them and the URL prefix in front of them.
1. Declare the locales
Patch the i18n key of the miscellaneous settings block. default is the primary locale, translations holds the others.
i18n:
enabled: true
default:
name: Français
slug: fr
country: fr
translations:
- name: English
slug: en
country: gb
slug becomes the URL segment (/fr/…, /en/…); country only selects the flag in the backoffice. miscellaneous has no partial-patch path: the write is update-setting, which replaces the whole file — read it first, merge i18n into it, and send everything back, the wysiwyg palette included. See Site settings.
/ redirects, and the old unprefixed URLs answer 404. On a site that already has an audience, plan the redirections in the same change.2. Mark the fields that vary
A field is translatable or it is not, and that decision lives in the schema:
title:
type: text
label: Titre
translatable: true
Slugs deserve the decision to be made per case, because three cases behave differently:
- An entry with its own detail URL: translate the slug. A reader in the second locale then gets an address in their language —
/en/article/the-watershed-line,/fr/article/la-ligne-de-partage-des-eaux. The slug is a field like any other, sent in the same translation as the title it derives from, and the engine remaps the whole link graph around it: the menu, the feeds, the switcher, thehreflangalternates. The old-locale slug is not kept as an alias; it becomes the not-found branch. - A slug used as a join key: never. In the example site, an author's
slugistranslatable: falsebecause articles reference authors by that value: a per-locale slug would break the join in one language. - Page slugs: identical across locales is the comfortable default. The locale prefix already tells the languages apart (
/fr/newsletter,/en/newsletter), and identical slugs keep every fixed link trivial.
{{article.slug}} with a query field named article. Named apart, every loc.url on detail pages stringifies to [object Object], silently.3. Translate the content
Content and collection items carry their translations alongside their default-locale data, in a translations wrapper keyed by locale slug:
{
"data": { "title": "Là où les mondes se touchent" },
"translations": { "en": { "title": "Where worlds meet" } }
}
translations. For three locales, that is one call for the default data and one per additional locale. Publication state is tracked per locale, so a translation can stay in draft while the source is live.The wrapper key depends on what you are writing: custom for a content block, data for a collection item. See Drive the site with MCP tools.
4. Translate the interface strings
Anything hardcoded in a template, button labels, empty states, aria-labels, goes through the translate filter instead of the content model.
<button>{{ "S'abonner" | _ }}</button>
<p>{{ 'Aucun article pour le moment.' | _ }}</p>
The msgid is the source string. An untranslated msgid renders the source, so a partial dictionary degrades to the default language rather than to blanks.
Then extract and translate:
quilium dictionary parse
This scans your templates, collects the msgids and uploads them. Fill each locale with update-dictionary-translations.
{{ 'Read more' | _ }} is collected; {{ label | _ }} is not, because there is nothing to collect at scan time. It still translates at runtime if the msgid exists, but nothing will have created it. For a value that varies, map it explicitly with a case.{%- case category -%}
{%- when 'vivant' -%}{{ 'Vivant' | _ }}
{%- when 'idees' -%}{{ 'Idées' | _ }}
{%- else -%}{{ category }}
{%- endcase -%}
| t is a registered alias of | _ with identical behaviour. Pick one per site and stay with it; the how-to guides use | _, the AI-path tutorials use | t.
5. Keep links inside the current locale
Three cases, three answers.
| Link | Write | Already locale-aware |
|---|---|---|
| Navigation entry | {{ item.url }} from sitemap.<nav>.children |
yes |
| Collection item detail | {{ 'article' | url: article: art }} |
yes |
| Fixed internal path | {% include '_localeUrl', path: '/newsletter' %} |
no, the helper adds the prefix |
Only the third needs help, and the helper is five lines you write once (see Reuse blocks (partials)). Never pass an already-locale-aware URL through it, or you get /fr/fr/….
6. Add the switcher and the hreflang tags
locales lists every locale with loc.slug and loc.url, the URL of the current page in that locale.
{% for loc in locales %}
<a href="{{ loc.url }}" hreflang="{{ loc.slug }}"
aria-current="{% if loc.slug == locale.slug %}true{% else %}false{% endif %}">{{ loc.slug | upcase }}</a>
{% endfor %}
In the root layout's <head>, the same loop produces the alternates:
{% for loc in locales %}
<link rel="alternate" hreflang="{{ loc.slug }}" href="{{ loc.url }}" />
{% endfor %}
locale.slug is the current locale, and it belongs on the root element: <html lang="{{ locale.slug }}">.
7. Let an agent do the mirroring
Everything above is a decision. Mirroring forty entries into a second locale is not — it is the same shape, forty times, and it is where a session quietly turns into an afternoon.
List the entries of the articles collection that have no English translation.
For each one, translate title, excerpt and body into English, keep the slug
as it is, and write them one entry at a time.
Show me the first entry translated, and the exact call you will send, before the others.
The costing is in Drive the site with MCP tools and it is worth knowing before you start: a collection entry takes root and each locale in separate calls, so twenty-six bilingual entries are fifty-two writes. Content blocks follow the opposite rule — root plus one locale in the same call. An agent that mixes the two gets a 422 on the first entry and on the fifty-second identically, which is exactly why you ask to see one call before the rest.
Keep the slug out of the translation unless you decided otherwise in section 2, and keep interface strings out of this entirely: those are dictionary entries, and quilium dictionary parse is what creates them.
Pitfalls
- A route param that is not a collection slug serialises badly. When a route is backed by a fixed
selectvalue rather than a collection, Quilium cannot rebuild the alternate URL andloc.urlcomes back containing[object Object]. Sanitise it in a helper partial by replacing that literal with the real value read off the page's data. - A hardcoded path breaks the day a slug is translated. Links that go through a route, the sitemap or the switcher follow translated slugs by themselves; a path written by hand does not. That is the real cost of per-locale addresses, and the reason the helper in section 5 only ever receives fixed, untranslated paths.
- A missing msgid is invisible in the default locale. It renders the source string, which is correct there and untranslated everywhere else. Run
quilium dictionary parseafter any template copy change.
Related
Last updated September 6, 2026.