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

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.

Live example: Lisière. Every snippet on this page comes from Lisière, the demo site of an independent long-form magazine, running French (default) and English off the same templates.

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.

Enabling i18n re-homes every URL. Every page moves under its locale prefix, only the bare / 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, the hreflang alternates. 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 slug is translatable: false because 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.
Translated entry slugs have one condition. The engine rebuilds a detail page's alternate URLs by re-resolving the route parameter through the detail page-type's query — so the route variable must bear the same name as the query field: a route path {{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" } }
}
One locale per write. A write call carries at most one locale in 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.

Only string literals are extracted. {{ '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 select value rather than a collection, Quilium cannot rebuild the alternate URL and loc.url comes 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 parse after any template copy change.

Related


Last updated September 6, 2026.