---
title: 'multilang'
description: 'Language infrastructure: normalises language config, attaches per-page locale data, builds the translation map, and exposes translation filters. Opt-in.'
slug: 'multilang'
type: 'article'
date: 2026-08-18T00:00:00.000Z
lang: 'en'
url: 'https://www.eleventy-baseline.dev/docs/modules/multilang/'
---

`INTERNAL_KEY`: '\_multilang'

---

## What it does

The multilang module wires Eleventy's built-in `I18nPlugin` (the locale-aware URL plugin), normalises your language config, attaches flat per-page language fields (`lang`, `locale`, `translationKey`, `isDefaultLang`), builds the translation map, and registers three translation filters for cross-language lookups in templates.

Relationships are also published to runtime stores, so consumers outside the cascade can read them: the head module builds hreflang alternates from them once the cascade closes.

A translation key is the identifier you set on a page (`translationKey: 'about'` in front matter) so Baseline knows the English `/about/` and the French `/fr/a-propos/` are the same page in different languages. Hreflang is the HTML link relation that tells search engines about those alternates.

Translating UI strings is a separate job with a separate home. This module answers "which pages are versions of each other"; the [[filters | `t` filter]] answers "what is the label for this key in this language", reads `_data/translations/`, and is registered whether or not this module is on.

---

### Active when

All three of these must be set, otherwise the module exits early without registering anything:

- `options.multilingual: true`
- `settings.defaultLocale`, or `settings.defaultLanguage` as its alias (a non-empty string)
- `settings.languages` (a non-empty object or array)

`defaultLocale` is the BCP 47 tag (`'en-US'`) and the preferred key. `defaultLanguage` is the short code (`'en'`); set it when you do not need a region and the locale is derived for you through `Intl.Locale`. Either one satisfies activation, and the module resolves both from whichever you supplied.

There is no inference on the other half. Setting the language keys without the explicit `multilingual: true` will not activate the module.

---

### Lifecycle

- **Build-time.** Adds `I18nPlugin`, registers the translation filters, registers the computed per-page language fields, normalises the language config through [`normalizeLanguageMap`](https://github.com/apleasantview/eleventy-plugin-baseline/blob/main/_baseline/core/utils/normalize-language-map.js).
- **Cascade-time.** The `translationsMap` collection walks every page with a `translationKey` and writes the per-key map to a runtime store. Separately, `page.translations` groups the content graph, and the head module reads that same grouping to emit hreflang.

---

## How it works

{% stepsBlock %}

1. **Normalise the language config.** [`normalizeLanguageMap`](https://github.com/apleasantview/eleventy-plugin-baseline/blob/main/_baseline/core/utils/normalize-language-map.js) accepts an object map or an array of strings, lowercases the keys, drops invalid entries (logged when `verbose: true`), and returns the normalised object.
2. **Activate `I18nPlugin`.** Adds Eleventy's built-in plugin with the resolved `defaultLanguage` and `errorMode: 'allow-fallback'`.
3. **Compute the per-page language fields.** Four independent `eleventyComputed.page.*` registrations: `lang` (the short code), `locale` (the BCP 47 string), `translationKey`, and `isDefaultLang`.
4. **Build the map.** One walk per build writes `translationsMap` to a runtime store. Pages without a `translationKey` are skipped silently. Pages whose `lang` is outside the allowed set are logged.
5. **Register filters.** `translationsFor`, `translationIn`, `defaultTranslation`.
6. **Expose `page.translations`.** Grouped from the [[content-graph | content graph]] rather than a collection, so a page can list its siblings without one.

{% endstepsBlock %}

---

## Defaults

- **`errorMode`** for `I18nPlugin`: `'allow-fallback'`, so an untranslated page serves the default-language version rather than a 404.
- **Language resolution per page**, in order: `data.lang`, `data.language`, the language derived from `data.locale`, then `settings.defaultLanguage`.
- **Allowed-languages set**, built from `settings.languages` keys; a page whose `lang` is not in it is logged and skipped during collection building.

Neither default key falls back to `'en'` automatically. With both absent, the module stays inactive (see [Active when](#active-when)).

---

### Settings

The multilang module reads three parts of the `settings` argument. Full shape on [[site-settings | Site settings]].

{% tableBlock true %}

| Key                        | Type                 | Used for                                                                                                                        |
| -------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `settings.defaultLocale`   | `string`             | BCP 47 default locale (`'en-US'`). The preferred key, and the fallback `page.locale` for languages with no `locale` of their own. |
| `settings.defaultLanguage` | `string`             | Short default-language code (`'en'`), an alias for the above. Either key serves every consumer: the sitemap module resolves the default through the same resolver this module uses, and the breadcrumb builder reads the computed `page.isDefaultLang` rather than the setting. All of them assume the convention that the default language sits at unprefixed URLs and the others under `/<lang>/`. Writing those permalinks is still yours. |
| `settings.languages`       | `object \| string[]` | Map of language codes to per-language overrides, or a flat array of codes. Arrays are normalised to objects with empty entries. Six keys are read off an entry: `title`, `tagline`, `description`, `locale`, `languageName` and `homeLabel`. |

{% endtableBlock %}

An entry can be empty, and `{ en: {}, sv: {} }` is a valid configuration that activates the module. It is rarely the one you want: with nothing on the entry, pages in that language fall back to the site-level `title`, `tagline` and `description`, the language switcher labels itself with the bare code (`sv` rather than `Svenska`), the first breadcrumb reads `Home` in every language, and `page.locale` falls back to `defaultLocale`. Nothing warns, because nothing is wrong. See [[site-settings#Keys | Site settings]] for what each key does and what it falls back to.

---

### Options

{% tableBlock true %}

| Option         | Type      | Default | Meaning                                            |
| -------------- | --------- | ------- | -------------------------------------------------- |
| `multilingual` | `boolean` | `false` | Activate the module. Required, alongside settings. |

{% endtableBlock %}

---

## Per-page language fields

Every page receives four flat language fields on `page`, resolved during the cascade:

```js
export default {
	page: {
		lang: 'en', // short code, resolved and lowercased
		locale: 'en-US', // BCP 47 string, from `data.locale` or the language's `locale`
		translationKey: 'about', // from `data.translationKey`, or undefined
		isDefaultLang: true // lang === defaultLanguage
	}
};
```

`page.locale` is the BCP 47 string itself. The head module reads these fields for hreflang. The sitemap partitions on the front-matter `lang` rather than the computed `page.lang`, so a page that declares only `locale` gets a resolved `page.lang` but still files under the default language's sitemap.

---

## `page.translations`

The current page's siblings in other languages, current page excluded, sorted by language code. Each entry is `{ url, lang, label, title, description, isDefaultLang }`.

{% raw %}

```nunjucks
{% for entry in page.translations %}
	<a href="{{ entry.url }}" hreflang="{{ entry.lang }}">{{ entry.label }}</a>
{% endfor %}
```

{% endraw %}

It is grouped from the content graph rather than from a collection, which is what lets a page see its siblings during its own cascade at all. One consequence worth knowing: **the graph's membership rules are the translation set's membership rules.** A page marked `_internal`, excluded with `baselineExcludeFromGraph`, or not rendering to `.html` has no graph node, so it is absent here even when it has a `translationKey`. It still appears in `collections.translationsMap` and in the filters below, which walk the collection instead. The list is also empty during the pre-pass, the build that produces the graph in the first place.

`label` names the language ("Nederlands"), while `title` and `description` name the sibling page, so a switcher can show either without a second lookup.

A language switcher wants the current language shown too, so add it back from `page.lang`.

---

## Collection

One collection, keyed by `translationKey`:

- **`translationsMap`** is a nested map: `translationsMap[translationKey][lang]`. Each leaf carries `{ url, lang, label, title, description, isDefaultLang }`.

If you want the pages themselves rather than these records, group `collections.all` by `data.lang` or `data.translationKey`. That is a two-line collection of your own and it stays yours to shape.

The map is also written to a runtime store, so transform-time consumers can read it without going through `collections`. That is how wikilinks resolve `[[slug:lang]]`.

---

## Filters

Three filters for cross-language lookups in templates. Full reference on [[filters | Filters]].

- `page | translationsFor`: every language variant of that page, itself included.
- `page | translationIn(lang)`: the specific-language variant, or `null`.
- `page | defaultTranslation`: the default-language variant, or `null`.

They read the translation map directly, so there is no collection to pass. For the page being rendered, `page.translations` is the shorter route and excludes the current language.

---

## Tips

- Every localised page needs both `translationKey` and `lang` in its front matter. Without `translationKey`, the page does not appear in translation collections; without `lang` (or a default), the resolver falls back and may misclassify the page.
- Keep the default-language page present for every translation key. It powers the `x-default` alternate and the head module's fallback resolution.
- With multilang active and more than one language in the map, the sitemap module automatically emits per-language sitemaps plus an index. A single-language map keeps the one flat `/sitemap.xml`. See [[sitemap | sitemap]].
- The "Unknown lang ..." log line is your signal that a page declared a language not in `settings.languages`. Add it to the languages map or fix the page's front matter.

---

### Rendering alternate links

The head module emits hreflang automatically when this module is active and the page has a `translationKey`. If you want to render alternates yourself (or override the default markup), `translationsMap` is the source:

{% raw %}

```nunjucks
{% set t = collections.translationsMap[translationKey] %}
{% if t %}
	{% for lang, entry in t %}
		<link rel="alternate" hreflang="{{ entry.lang }}" href="{{ entry.url }}">
		{% if entry.isDefaultLang %}
			<link rel="alternate" hreflang="x-default" href="{{ entry.url }}">
		{% endif %}
	{% endfor %}
{% endif %}
```

{% endraw %}

---

## Peer deps

None. Uses Eleventy's built-in `I18nPlugin`.

---

## See also

- [[site-settings | Site settings]] for `defaultLanguage` and `languages`.
- [[page-context | Page context]] for where the language fields surface on the per-page object.
- [[filters | Filters]] for the translation filter signatures, and for `t`, the string layer.
- [[head | head module]] for the hreflang consumer.
- [[sitemap | sitemap module]] for per-language sitemaps.
- [[multilingual-baseline-site | Tutorial: multilingual site]]
- [[multilingual-index | How-to: multilingual index]]
