Multi-locale websites

A website with more than one configured locale (admin → website settings → Locales) becomes "multi-locale." That flips two behaviors on:

  1. URL shape — page URLs gain a mandatory /<locale>/ prefix.
  2. 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-us will 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) > 1 becomes 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/aanvraag and /en/request-a-quote are 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.

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 + an x-default row 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.