---
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. Also the key the sitemap module and the breadcrumb builder read, both of which assume the convention that this 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. |

{% endtableBlock %}

---

### 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]]
