Multi-Language Content
Accent CMS can serve the same content tree in multiple languages. Translations live alongside the original files using a language suffix in the filename, the default language keeps its existing plain URLs, and every page receives a list of its own translations so templates can render a language switcher.
Multi-language support is opt-in. A site that does not configure
site.languages (or lists only one language) behaves exactly as a
single-language site: no language detection, no URL prefixes, no translation
grouping. You can add i18n to an existing site without changing any current
URLs.
Enabling i18n
Multi-language mode activates only when site.languages lists two or more
language codes. Add the supported languages to config.yaml and make sure the
default (site.language) is one of them:
site: name: My Website language: en # default language -- owns the plain URLs languages: [en, de, fr] # all supported languages, in display order
Rules:
site.languageis the default language. Its pages are served at their normal URLs with no prefix.site.languagesmust includesite.language. Validation rejects a config where the default language is missing from the list.- The order of
site.languagesis the display order used by language switchers. - With fewer than two entries, i18n stays off and the site is single-language.
See the Configuration reference for the full
list of site keys.
Authoring a translation
Language is determined by a filename suffix, not by a frontmatter field.
This is the single source of truth and cannot drift out of sync. To translate a
page, add a sibling file whose stem ends in .<lang>:
content/01.home/
default.md # English (default language) -> /home
default.de.md # German translation -> /de/home
default.fr.md # French translation -> /fr/home
The same convention applies to file-style pages:
content/docs/
intro.md # English -> /docs/intro
intro.de.md # German -> /de/docs/intro
How suffixes are interpreted:
- A file with no recognized language suffix is treated as the default language.
- A suffix is only recognized when the code appears in
site.languages. A file namednotes.uk.mdon a site whoselanguageslist does not containukis treated as an ordinary page with the stemnotes.uk– never as a translation. This keeps dotted filenames safe and makes i18n purely additive.
Frontmatter: localize values, not keys
Frontmatter keys are a fixed schema, not content. Keep them in their
canonical form – the lower-case English names Accent ships (title, date,
author, location, …) – in every language, and translate only the
values:
# 02.events/sommerfest/default.de.md (German translation) --- title: Sommerfest 2026 # value translated date: 2026-08-15 location: Zürich # key stays `location`; the value is German author: Redaktion ---
A localized key is not recognized as the field it resembles. Write Ort:
instead of location: and Accent does not map it back – it lands in the
untyped custom map (page.custom.Ort), invisible to typed fields, feeds, any
template that reads the canonical name, and structured data. The filename suffix
already carries the language, so the keys never need to change.
Keys and rich results (structured data)
This matters most for structured data:
JSON-LD type detection keys on canonical field names. A page with a date
and a location is auto-detected as a schema.org Event; rename location
to Ort and the page silently falls back to a plain article – and the venue
would be empty even if the type were forced, because the Event builder also
reads location by its canonical name. Keeping the English key with a
translated value is what keeps the Event (and its Place) intact in every
language.
When you want the page type driven by section rather than by fields – an
events listing whose pages carry no location, or simply to be explicit – map
URL prefixes to schema types in config.yaml. Non-default languages are served
under their /{lang}/ prefix, so map each one:
structured_data: type_mappings: "/events/": Event # default-language events "/de/events/": Event # the German translations live here
For a single page, the most explicit and fully language-agnostic option is the per-page override, which bypasses both the heuristic and the URL prefixes:
--- title: Sommerfest 2026 schema: type: Event ---
The URL model
The default language owns the bare URLs; every other language is served under a
/{lang}/... prefix:
| File | Language | URL |
|---|---|---|
01.home/default.md | en (default) | /home |
01.home/default.de.md | de | /de/home |
01.home/default.fr.md | fr | /fr/home |
Because the default language keeps its existing paths, adding i18n to a live site does not break any current links or bookmarks.
Each language has its own navigation tree: a page only appears in the hierarchy (breadcrumbs, siblings, children, menus) for its own language. An untranslated page simply does not exist in another language’s tree, so menus never show links that fall back to the wrong language.
page.language vs page.translations
Two page-context fields drive multi-language templates:
-
page.language– the content language of the page being rendered (for example"de"). This is what belongs in<html lang>. It is set for every content page (on a single-language site it is simplysite.language), but it can be empty on pages built outside the content pipeline – taxonomy index and custom error pages. For<html lang>, use theor-chain shown below so those pages still resolve to a language. -
page.translations– the list of every available translation of the current page, including the page’s own language. Each entry has:Field Type Description languagestring Language code, e.g. "de"urlstring URL of that translation (default language has no prefix; others are under /{lang}/...)activebool truefor the language currently being displayed
On a single-language site page.translations is empty, so a switcher guarded by
page.translations | length > 1 renders nothing.
The companion site-context fields are site.languages (all configured codes)
and site.current_language (the active language for this request). See the
Page Context reference
for the full field tables.
Building a language switcher
The default theme ships a ready-made partial driven by page.translations.
Include it anywhere in a template – typically in the header next to the version
switcher:
{% include "partials/language-switcher.html.jinja" %}
The partial renders the active language as plain text and the others as links, and renders nothing unless the page has two or more translations. Its source is a complete, minimal example you can copy and restyle:
{% if page.translations | length > 1 %}
<ul class="language-switcher" aria-label="Language">
{% for t in page.translations %}
<li>
{% if t.active %}
<span class="active" lang="{{ t.language }}">{{ t.language | upper }}</span>
{% else %}
<a href="{{ t.url }}" lang="{{ t.language }}" hreflang="{{ t.language }}">{{ t.language | upper }}</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
Set the root element’s lang and dir from the page so assistive technology
and typography track the content language:
<html lang="{{ page.language or site.current_language or site.language }}" dir="{{ page.direction or 'ltr' }}">
The or chain keeps the right language on pages built outside the normal
pipeline (taxonomy indexes, custom error pages) where page.language may be
empty. See Page Locale and Smart Punctuation
for how page.language, page.locale, and page.direction relate.
Per-language feeds
When i18n is active, each language gets its own RSS feed. The default language
keeps the canonical /feed.xml; non-default languages are served under their
prefix:
| Language | Feed URL |
|---|---|
en (default) | /feed.xml |
de | /de/feed.xml |
fr | /fr/feed.xml |
Static builds
accent build mirrors the served URLs:
- Default-language pages are written to their normal paths
(
/home->output/home/index.html). - Non-default languages are written under a language directory
(
/de/home->output/de/home/index.html). - The sitemap includes
hreflangalternates linking each page to its translations, so search engines can offer the right language.
Serve and build produce identical output, so a switcher that works under
accent serve works the same way in a static build.
Summary
- i18n is opt-in: it activates only when
site.languageshas 2+ entries. - Translations are sibling files with a
.<lang>suffix; unrecognized suffixes are left as ordinary filenames. - Frontmatter keys stay canonical (English) in every language; translate only
the values. A renamed key is treated as an unknown custom field and silently
drops out of typed fields, feeds, and structured data (e.g. JSON-LD
Event). - The default language keeps its plain URLs; other languages are served under
/{lang}/.... page.languageis the content language;page.translationslists every translation for building a switcher.- The
partials/language-switcher.html.jinjapartial is a working reference. - Per-language feeds and
hreflangsitemap alternates are generated automatically.