---
title: Run a multilingual site
description: Declare locales, translate content and interface strings, and make every link in your templates land on the right language.
updated: 2026-08-19
prev: reuse-partials
next: blog
---

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.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Lisière.</b> Every snippet on this page comes from <b>Lisière</b>, the demo site of an independent long-form magazine, running French (default) and English off the same templates.</div></div>

## 1. Declare the locales

Patch the `i18n` key of the `miscellaneous` settings block. `default` is the primary locale, `translations` holds the others.

```yaml
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](/en/docs/site-settings).

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Enabling i18n re-homes every URL.</b> Every page moves under its locale prefix, only the bare <code>/</code> redirects, and the old unprefixed URLs answer 404. On a site that already has an audience, plan the <a href="/en/docs/set-up-redirections">redirections</a> in the same change.</div></div>

## 2. Mark the fields that vary

A field is translatable or it is not, and that decision lives in the schema:

```yaml
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.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Translated entry slugs have one condition.</b> The engine rebuilds a detail page's alternate URLs by re-resolving the route parameter through the detail page-type's query — so <b>the route variable must bear the same name as the query field</b>: a route path <code>{{article.slug}}</code> with a query field named <code>article</code>. Named apart, every <code>loc.url</code> on detail pages stringifies to <code>[object Object]</code>, silently.</div></div>

## 3. Translate the content

Content and collection items carry their translations alongside their default-locale data, in a `translations` wrapper keyed by locale slug:

```json
{
  "data": { "title": "Là où les mondes se touchent" },
  "translations": { "en": { "title": "Where worlds meet" } }
}
```

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>One locale per write.</b> A write call carries at most one locale in <code>translations</code>. 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.</div></div>

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](/en/docs/drive-site-with-mcp).

## 4. Translate the interface strings

Anything hardcoded in a template, button labels, empty states, `aria-label`s, goes through the translate filter instead of the content model.

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

```bash
quilium dictionary parse
```

This scans your templates, collects the msgids and uploads them. Fill each locale with `update-dictionary-translations`.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Only string literals are extracted.</b> <code>{{ 'Read more' | _ }}</code> is collected; <code>{{ label | _ }}</code> 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 <code>case</code>.</div></div>

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

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

```liquid
{% 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.

```prompt
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](/en/docs/drive-site-with-mcp) 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

<div class="q-cards">
  <a class="q-card reco" href="/en/docs/multilingual-model"><span class="k">Recommended</span><span class="t">The multilingual model</span><span class="d">Why translations sit beside the data instead of in parallel pages.</span></a>
  <a class="q-card" href="/en/docs/site-settings"><span class="t">Site settings</span><span class="d">The <code>i18n</code> key in full.</span></a>
  <a class="q-card" href="/en/docs/liquid-filters"><span class="t">Liquid filters</span><span class="d">The <code>_</code> and <code>url</code> filters.</span></a>
</div>
