Add a second language
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.
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.
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 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:
Make the site bilingual: English stays the default, French second.
⏺ 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.
/the-review now answers 404 — it does not forward to /en/the-review. 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.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:
Pipe every static UI string through the dictionary and give me the
French. Content fields stay out of it — they translate CMS-side.
⏺ 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,pieceandpieces— « 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
_datepartial existed to force English month names past the install's French MomentJS. The French branch now uses the engine'sdatefilter directly; the English branch keeps the lookup. The second locale did not add a workaround — it retired half of one.
| url or the sitemap, and both turned locale-aware by themselves. That free ride was bought in the routing tutorial.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 |
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.
⏺ 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:
Design the language switcher from branding/ — it never existed in
the prototype. And give every page its hreflang.
⏺ 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:
{% 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 loselocalesand render an empty nav.l.slug, neverl.code—codedoes 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
QA the whole surface, both languages: every page, no untranslated
string, no link that crosses locales, hreflang everywhere.
⏺ 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.
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
Last updated September 7, 2026.