---
title: Add a second language
description: Turn a finished English site into a bilingual one — the dictionary, the translated content, the locale-aware URLs, and a language switcher the agent designs from the design system.
updated: 2026-08-16
prev: ai-site-instructions
---

The last pass, and the one that best shows what the model buys you. The site is complete, in English, and nobody planned for a second language. Adding one touches the dictionary, the content and the URLs — and almost nothing else.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Lisière.</b> The prototype is monolingual on purpose, and there is no language switcher anywhere in it. The agent designs one from the brand material, which is the point of having shipped that material in the first place.</div></div>

## What you will have at the end

The same site in English and in French — `/en/article/the-watershed-line` and `/fr/article/la-ligne-de-partage-des-eaux` — every address translated, correct `hreflang` mapping each page to its counterpart, a switcher that belongs to the design, and a form that fails politely in the reader's language.

## Before you start

[The site's AI instructions](/en/docs/ai-site-instructions) done: the site is complete and its conventions are written down.

## 1. Why this comes last

Retrofitting a language onto a finished site is the situation you actually meet. Doing it in this order also proves something the earlier tutorials only claimed: the model does not have to be redone.

The honest tally for Lisière: **zero schema changes**. No new type, no new field, no translatable flag anywhere. What the pass did touch, it touched outside the schema — a handful of template edits (the static strings, the `lang` attribute, one date partial), one settings write, and a lot of content translation. Everything the site is made of already knew how to exist twice.

## 2. Turn i18n on

The locales live in `miscellaneous` — the settings file that also holds the wysiwyg palette and the 404 registration. It only takes full replacement (`update-setting`, not a partial patch), so the write starts from a read and sends the whole file back, everything you have no opinion about included:

```prompt
Make the site bilingual: English stays the default, French second.
```

```answer
⏺ get-site-settings(miscellaneous)
  ⎿ the wysiwyg palette — anything not sent back gets erased

⏺ update-setting(miscellaneous)
  ⎿ the palette preserved, plus:
     i18n:
       enabled: true
       default:      { name: English,  slug: en, country: gb }
       translations: [{ name: Français, slug: fr, country: fr }]
```

The URLs change on the spot: every page now lives under its locale prefix — `/en/the-review`, `/fr/the-review` — and the bare `/` answers 301 to `/en`.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Enabling i18n breaks every published URL.</b> Only the root redirects. The old <code>/the-review</code> now answers <b>404</b> — it does not forward to <code>/en/the-review</code>. On a demo this is a curiosity; on a live site it is every bookmark, backlink and indexed page going dark, and the redirections feature earns its keep the same day. Do the language pass before the site has an audience, or budget the redirect map with it.</div></div>

## 3. The dictionary

Every static string in the templates goes through the `t` filter. This is the mechanical half of the work, and the half an agent does well:

```prompt
Pipe every static UI string through the dictionary and give me the
French. Content fields stay out of it — they translate CMS-side.
```

```answer
⏺ Edit × 10 files
  ⎿ ~50 strings wrapped: {{ "Subscribe" | t }}, the aria-labels,
    the form messages, the not-found branches. CMS fields
    (c.title, item.name…) deliberately untouched.

⏺ Bash(quilium push)
  ⎿ the extractor harvests the literals: 44 msgids appear in the
    dictionary, untranslated — the site still renders, in English

⏺ update-dictionary-translations(locale: fr)
  ⎿ one call, all 44 entries — one call per locale is the rule
```

The wrapping is not always mechanical, and the three exceptions are the actual lesson:

- **A hardcoded plural dies.** `{{ count }} piece{% if count != 1 %}s{% endif %}` assumed English morphology; it became two msgids, `piece` and `pieces` — « texte » / « textes ».
- **An interpolated sentence dies.** "The whole *{{ s.name }}* section" cannot survive French word order. The label was restructured to stand alone: `{{ "The whole section" | t }}` — « Toute la rubrique ».
- **A workaround unwinds.** The `_date` partial existed to force English month names past the install's French MomentJS. The French branch now uses the engine's `date` filter directly; the English branch keeps the lookup. The second locale did not add a workaround — it retired half of one.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Hardcoded internal links break in the second locale.</b> Anything pointing at a path rather than through a named route sends a French reader to the English page. Lisière had none to fix — every link already went through <code>| url</code> or the sitemap, and both turned locale-aware by themselves. That free ride was bought in the routing tutorial.</div></div>

## 4. Translate the content

Pages, blocks and collection entries all carry their translations — **slugs included**. A French reader gets French addresses: the slug is a field like any other, sent in the same translation call 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 does not linger: once `la-ligne-de-partage-des-eaux` exists, `/fr/article/the-watershed-line` is the not-found branch, not an alias.

(Until a slug's translation lands, the untranslated field simply falls back to the default locale — which is also why, *while the batch runs*, `/fr` serves a mix of French and English. Fallback, not a bug: reload when the batch is done before judging it.)

The write contract is the one place where the API differs by resource, and it is worth stating before the batch starts:

| Resource | Root + translation in one call? | Locales per call |
|---|---|---|
| `update-page`, `update-content` | **required** — translations alone is a 422 | 1 |
| `update-itemset-item` | **forbidden** — separate translation-only calls | 1 |

```prompt
Translate the site into French: pages, blocks, collection entries —
slugs included, a French reader gets French addresses. Literary
editorial register, "section" becomes « rubrique ». Verify every
page renders in both locales.
```

```answer
⏺ update-page × 8
  ⎿ title, navTitle, meta description — root + translations.fr
    combined, per the contract

⏺ update-content × 28
  ⎿ every block on every page, the content kit's ten included —
    the kit is the reference in both languages

⏺ update-itemset-item × 26
  ⎿ 7 articles (title, body, slug — accent spans preserved),
    4 sections — Le Vivant, Terrain, Idées, Entretiens, at
    /fr/section/le-vivant… — 4 contributors (role and bio;
    names are names). Translation-only calls: ItemSet never
    combines root and translations.

⏺ curl both locales
  ⎿ /fr/article/la-ligne-de-partage-des-eaux 200, the menu and
    the feeds regenerated onto the French slugs, /en unchanged —
    and /fr/article/the-watershed-line now answers the not-found
    branch: the translated slug replaced it
```

The remapping is not magic, and it has one condition worth knowing because its failure mode is silent. The engine computes a page's alternate URLs by re-resolving the route parameter through the detail page-type's query item — so **the route variable must bear the same name as the query field**: the route says `/{{article.slug}}` and the page-type's query field is `article` (`page.custom.article`). Lisière matched them from the start, which is why everything above came free. Name them differently and every `locales[*].url` on a detail page has no item to read the alternate slug from — the exact `[object Object]` failure the next section guards against, except on every page instead of only the not-found branch.

Two details from the pass that a reviewer should know. Hardcoded links *inside* French wysiwyg bodies were rewritten `/fr/`-prefixed — body links are content, so the locale is written into them, unlike template links which resolve per request. And the form closed the loop in French: an invalid POST on `/fr/newsletter` answers « Cette adresse email ne semble pas valide. », a valid one redirects to `/fr/thank-you` — same block, same redirect, the locale carried through.

## 5. The switcher, and hreflang

Neither exists in the prototype. The agent has the design system, the tokens and the existing components — that is enough to design a switcher that looks like it was always there:

```prompt
Design the language switcher from branding/ — it never existed in
the prototype. And give every page its hreflang.
```

```answer
⏺ Write(_localeSwitcher.liquid)
  ⎿ EN / FR in the mono stamp voice — the issue-number register,
    not a flag dropdown. Included twice: header, mobile panel.

⏺ Edit(layout.liquid)
  ⎿ the hreflang block in the head, and lang="{{ locale.slug }}"
    on <html> — the hardcoded "en" would now lie on every /fr page

⏺ Bash(quilium push) → curl
  ⎿ en, fr and x-default alternates on every page, each pointing
    at ITS page in the other language
```

The engine gives the switcher everything: `locales` lists the active locales for the current page, `l.url` is *this page* in that locale — translated slug resolved, `/en/section/the-living` offering `/fr/section/le-vivant` — and `l.current` flags the active one. Two rules keep it honest:

```liquid
{% for l in locales %}
  {% if l.current %}<span aria-current="true">{{ l.slug }}</span>
  {% else %}<a href="{{ l.url }}" lang="{{ l.slug }}">{{ l.slug }}</a>{% endif %}
{% endfor %}
```

- **`{% include %}`, never `{% render %}`** — render isolates scope, and the snippet would lose `locales` and render an empty nav.
- **`l.slug`, never `l.code`** — `code` does not exist on a locale object and resolves to an empty string, silently.

One engine caveat, found by QA and worth a guard: on a detail page whose slug resolves to nothing, `l.url` has no query item to read the alternate slug from and stringifies to `[object Object]` — the same mechanism that powers the remapping, with nothing to resolve. The layout skips those alternates and the switcher renders the label unlinked: a not-found page has no alternates to declare.

## 6. Check both locales

```prompt
QA the whole surface, both languages: every page, no untranslated
string, no link that crosses locales, hreflang everywhere.
```

```answer
⏺ curl × 20 — every page, /en and /fr
  ⎿ 20 × 200, zero Liquid leaks, zero [object Object]

⏺ grep every /fr page for href="/en (and the reverse)
  ⎿ only the switcher and the hreflang alternates cross — no
    content link leaves its locale

⏺ get-dictionary(missing_locale: fr)
  ⎿ 0 entries — the dictionary is complete

⏺ update-ai-instructions
  ⎿ the site's own instructions learn the new rules: never
    translate a slug, « rubrique », the write contract, the
    one-call-per-locale limit
```

That last call is the previous tutorial paying for itself: the next session — yours or anyone's — inherits the language rules without being told.

<div class="q-ph"><i class="fa-regular fa-image"></i><span><b>Placeholder:</b> screenshot to produce. The review page side by side: the English version and the French version, same layout, same addresses, the switcher active on each side.</span></div>

## What you learned

- A well-modelled site takes a second language without being redone — Lisière's tally was zero schema changes, and the biggest line item was the content itself.
- Static UI goes through the dictionary; content carries its own translations, slugs included — a French reader reads French addresses, and the engine remaps every link around them.
- The remapping's one condition: the route variable and the detail page-type's query field share a name. Named apart, every alternate URL fails — silently, as `[object Object]`.
- The write contract differs by resource: pages and blocks combine root + one translation per call; collection items never combine. One locale per call, everywhere.
- Enabling i18n re-homes every URL under its locale prefix — last thing before an audience, or first thing after a redirect map.
- A design system is what lets an agent invent a component that did not exist, instead of guessing.

## Where to go next

<div class="q-cards">
  <a class="q-card reco" href="/en/docs/prompting"><span class="k">Recommended</span><span class="t">Prompting well for Quilium</span><span class="d">Everything you just did, generalised into habits.</span></a>
  <a class="q-card" href="/en/docs/multilingual-site"><span class="t">Run a multilingual site</span><span class="d">The same ground as a reference procedure.</span></a>
  <a class="q-card" href="/en/docs/publish-and-go-live"><span class="t">Publish and go live</span><span class="d">Put what you built in front of visitors.</span></a>
</div>
