Multi-locale websites
A website with more than one configured locale (admin → website settings → Locales) becomes "multi-locale." That flips two behaviors on:
- URL shape — page URLs gain a mandatory
/<locale>/prefix. - Translations — a page stays one page. Each extra language is an overlay on it: a row carrying only that language's title, slug, meta and field values, laid over the canonical page at render time.
This document covers both halves — operators in the first section, theme authors in the second.
For operators
Single-locale → multi-locale: URL shape changes
Heads-up. Adding a second locale to an existing single-locale website changes every page URL in one shot. A site running at
/about-uswill redirect to/<defaultLocale>/about-us(302) the moment you save the second locale.
What this means in practice:
- Bookmarks and external links keep working via the 302 redirect, so no broken links.
- SEO impact is real but minor. Google generally honors 302s for a cycle; once the new prefixed URLs are crawled they become the canonical.
- One-shot, not gradual. There's no "soft launch" mode. The flip
happens the instant
count(locales) > 1becomes true. - You cannot half-roll-back. Removing the second locale flips URLs back to unprefixed — another change for crawlers to process. So if you're experimenting with a second locale, finish or revert decisively rather than toggling.
If the site has meaningful organic traffic and you want zero risk, schedule the flip for low-traffic hours and check Search Console for crawl errors over the next few days.
Working with translations in the admin
On a multi-locale site, a page edit screen shows one tab per locale. A translation is not a page you navigate to — it is a language tab on the page you already have open, and switching tabs costs no round trip.
Clicking + en starts an English overlay on this page. What that means
in practice:
- The structure is not translated. Template and field layout belong to the page, so every language stays in step automatically. (Nesting is not a page property at all: it lives on the Menus tab, see below.)
- You translate what you want to. A field you never touch in the EN tab keeps rendering the default language — the editor marks those as inherited so it stays obvious which text is genuinely translated. Blanking a field deliberately is still expressible: a field the translation mentions wins even when empty.
- Slug and title are per language.
/nl/aanvraagand/en/request-a-quoteare the same page. - Publishing is per language. A draft EN overlay changes nothing for
visitors; they keep getting the default language under
/en/.
The pages list shows one row per page, not one per language — a six-language site with 20 pages lists 20 rows, not 120.
An untranslated page is never a 404
This is the rule worth internalising: if a locale has no published overlay, the visitor gets the default language's content under that locale's URL. A half-finished Swedish site is readable, not broken.
The consequence is that URLs resolve for locales you haven't started.
/sv/aanvraag works on day one and serves Dutch. That is deliberate, and
it is why the language switcher and hreflang do not simply list every
configured locale — see below.
Menus are per-locale
Navigation menus are configured per locale. A menu item is keyed on
(website, locale, menu_code), so the header_menu an English visitor
sees is a different set of rows from the Dutch one. On the Menus tab,
pick the locale you're editing — building the nl header menu does not
populate the en header menu. Plan to set each menu up once per active
locale (the items can of course point at each locale's own translated
pages).
Menus are the exception to "structure isn't translated": both the label and the target are per locale, which makes them the right home for anything a theme would otherwise hardcode into its chrome.
What is stored where
Two rows back a translated page, and knowing which is which explains most of the behaviour above:
- The page row: the canonical content, in the website's default locale. It owns the template and the full field set. It does not own a place in the hierarchy; that comes from the menu items that link it.
- A page_translation row per other locale — title, slug, meta, SEO and field overrides only. Absent means inherit.
Things to know:
- At most one translation per locale per page, enforced by the database. You cannot end up with two English versions of one page.
- The default locale has no translation row. It is the page.
- Deleting a translation drops that language back to fallback; the page and the other languages are untouched.
- Changing the website's default locale is not a supported operation — the canonical row would have to change language. Treat the default locale as fixed once content exists.
During the changeover. Sites migrated from the older model (one page row per locale, joined by
translation_group_id) still have those sibling rows in the database. They stay resolvable on purpose, so a translation that failed to backfill degrades to the old behaviour instead of 404ing a live page. They disappear with the cleanup migration; until then a site can legitimately show more page rows than pages.
What about cart, login, checkout, faq, etc.?
Module routes carry the locale prefix too on a multi-locale website:
/nl/products/..., /nl/blog, /nl/faq, /nl/cart, /nl/checkout,
/nl/login. The set of route names that behave this way is an explicit
list in cms-core/src/Website/LocalizedRoutes.php, so what is and is not
prefixed is readable in one place rather than guessed from a pattern.
Deliberately not prefixed, and staying flat on every site:
/checkout/webhook/*: a payment gateway posts to a URL we handed it once; moving it under a locale would break every stored webhook config./logout: Symfony's firewall matches the configured logout path literally./media/*,/_b/*: files, not pages, no language.sitemap*.xml,/robots.txt: one document per site, with the locales handled inside it.
Themes never build these URLs by hand anyway: path('cms_faq_index') and
friends return the prefixed URL on a multi-locale site and the flat one
elsewhere. Hardcoding /faq in a template is what breaks.
Robots and sitemaps
/sitemap-pages.xml automatically does the right thing on multi-locale
sites:
- Emits one
<url>per (page, locale) at its prefixed URL. - Adds
<xhtml:link rel="alternate" hreflang="...">annotations per entry, listing all published translations + anx-defaultrow pointing at the default-locale variant.
sitemap-products.xml and sitemap-landing.xml follow the same rule for
module routes: one URL per locale with the shared slug, annotated with
alternates, since those really are one page in several languages.
sitemap-faq.xml is the exception, and on purpose. An FAQ category
belongs to one language, so each language advertises the categories it
actually has and there are no hreflang alternates between them: two
languages that happen to share a slug hold different questions, not
translations of each other. See
operators/modules/faq.md.
For theme authors
Page URLs from Twig
You almost never need to think about locale prefixes — the
page_path(page) and page_url(page) helpers automatically emit the
right URL shape:
- Single-locale website →
/<slug> - Multi-locale website →
/<locale>/<slug>
See twig-helpers.md → Linking for the
helpers themselves. The one rule: do not hardcode path('page', ...)
in templates. The Symfony route name is page on single-locale sites
and page_localized on multi-locale ones; hardcoded path('page', ...)
breaks on the latter. Always go through page_path().
To link to a page the theme knows by name rather than one nav_tree()
handed you — a "request a quote" CTA repeated across the site — use
cms_page(slug) to fetch it first:
<a href="{{ page_path(cms_page('aanvraag')) }}">Container aanvragen</a>
The slug is the page's default-locale slug in every locale, and the pair emits that locale's translated URL. Don't keep your own map of resolved paths in a page field: it duplicates routing the CMS owns and goes stale as soon as someone edits a slug in the admin.
Rendering a language switcher
In a layout, use cms_language_alternates(). It answers for whatever
URL is being rendered — page, category, product, cart, checkout, account
— and returns the same
[{ locale, url, path, isCurrent, title }, …] list as
page_translations(), so one partial covers the whole site:
{% set alternates = cms_language_alternates() %}
{% if alternates|length > 1 %}
{# … one entry per language … #}
{% endif %}
The helpers below are page-only. A switcher built on them vanishes on the
shop half of the site, because those routes carry no Page. See
twig-helpers.md for what each route reports.
The quickest route is language_switch(), which renders the whole widget
— a connected pill group, or a <select> once there are more than three
languages:
{{ language_switch(page) }}
{{ language_switch(page, color='white') }}
{{ language_switch(page, style='dropdown') }}
It returns an empty string on single-locale sites and on pages with
nothing to switch to, so it is safe to drop in unconditionally. Style it
via .lang-switch, .lang-switch__pill and .lang-switch__select.
Reach for page_translations(page) instead when you want to own the
markup:
{% set translations = page_translations(page) %}
{% if translations|length > 1 %}
<nav aria-label="Language" class="lang-switcher">
<ul>
{% for t in translations %}
<li>
{% if t.isCurrent %}
<span class="current" aria-current="true">{{ t.locale|upper }}</span>
{% else %}
<a href="{{ t.path }}" hreflang="{{ t.locale }}">{{ t.locale|upper }}</a>
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
{% endif %}
The |length > 1 guard keeps the switcher off single-locale sites and
off untranslated pages on multi-locale sites — both cases return one
entry (the page itself).
It lists the locales this page genuinely exists in, not the site's configured locales. Remember that an untranslated locale's URL still resolves — it serves the default language. Offering a pill for it would promise "this page, in that language" and then hand back the text the reader was already looking at, so those locales are left out. Draft translations are excluded for the same reason.
That is why the switcher can show three languages on one page and six on another. It reflects what has actually been translated.
hreflang in <head>
You probably don't need to do anything. The shared SEO partial emits
hreflang for you, derived from the page's published translations, and
sets a canonical carrying the locale prefix. Themes that don't override
_partials/seo.html.twig get both for free.
If you do own your own <head>, the call is:
{{ seo_hreflang(page)|raw }}
Same rule as the switcher: only genuinely translated locales are
annotated, plus an x-default row pointing at the default-locale
variant, and nothing at all when there is only the page itself. See
twig-helpers.md → SEO / metadata for the
exact output.
page.locale is the locale being rendered, not "the page's language"
{{ page.locale }} and {{ app.request.locale }} always agree inside a
page template. Prefer page.locale — clearer intent.
But note what it means: on a fallback render, page.locale is sv while
every string on the page is Dutch. It answers "which locale is this URL",
not "which language is this text". When you need the latter:
{% if not page.isTranslated() %}
{# this locale is falling back to the default language #}
{% endif %}
Most themes should ignore this — the fallback is meant to be invisible. It's there for the rare case where you want to mark untranslated content, or suppress something that only makes sense once translated.
Right-to-left locales
Nothing to do. The shared base emits dir on <html> alongside lang,
derived from the locale being rendered, so an ar / he / fa / ur
page arrives right-to-left in every theme:
<html lang="ar" dir="rtl">
What that buys you is worth knowing, because it decides how you write the
layout. dir sets the inline axis, so logical CSS mirrors for free
and physical CSS does not:
| Write this | Not this |
|---|---|
ms-4, me-4, ps-2, pe-2 |
ml-4, mr-4, pl-2, pr-2 |
text-start, text-end |
text-left, text-right |
start-0, end-0, border-s, border-e |
left-0, right-0, border-l |
A theme written that way needs no RTL stylesheet at all. Where a rule
genuinely has to differ (an icon that should flip, a background gradient
that shouldn't), use Tailwind's rtl: / ltr: variants. They match on
the dir attribute, which is why the attribute is emitted rather than a
direction: rtl rule in theme CSS. cms_dir() gives you the same value
in Twig; see
twig-helpers.md → cms_dir().
Do not add html[lang="ar"] { direction: rtl } to a theme. It looks
equivalent, but computed direction does not enable the rtl: variants
and does not fully substitute for the attribute when Latin and Arabic
text share a line, which is the normal case for a brand name in body
copy.
Theme assets are locale-agnostic
theme_asset('img/hero.jpg') works the same on single- and multi-locale
sites. There's no "per-locale theme" — one theme version serves all
locales. If you need locale-specific imagery, either:
- Pick the asset name in Twig:
theme_asset('img/hero-' ~ page.locale ~ '.jpg') - Or ship a single image with text in CSS/HTML so the locale handling stays in templates, not in image files.
Module URLs in themes (cart, login, faq, etc.)
Routes like /login, /cart, /checkout, /faq do carry the
locale prefix on a multi-locale site (see the operators section above).
Always generate them: {{ path('customer_login') }},
{{ path('cms_faq_index') }}. The generator returns the prefixed URL
where the site runs several languages and the flat one where it does not,
so the same template works on both. A hardcoded
<a href="/login">Log in</a> still resolves, but through a 302 that
re-negotiates the locale from scratch, so a visitor reading /nl/ can
land in another language; and a hand-built '/' ~ page.locale ~ '/faq'
breaks on the single-locale site that has no prefix at all.