Multilingual Eleventy Sites With the i18n Plugin

Eleventy has no built-in concept of languages. A multilingual Eleventy site is a site whose content happens to be organised by language, with templates that know which language they are rendering. Eleventy 2.0 added an official i18n plugin that handles the part people get wrong most often — finding the equivalent page in another language and building locale-aware links — and leaves the rest to ordinary data files and filters. The result is less magic than Hugo's or Astro's i18n, and correspondingly easier to reason about.

This guide builds a three-language marketing and documentation site (English, German, French; about 180 pages per language) with Eleventy 3 and the i18n plugin, covering content layout, URLs, interface strings, dates, a language switcher and hreflang. It belongs to Internationalization for Static Sites, which compares how generators handle languages.

Prerequisites

  • Eleventy 3.0 (the plugin also works on 2.x).
  • Content in one language that you are about to translate, or translations already in separate files.
  • A decision on URL structure; this guide uses subdirectories (/en/, /de/, /fr/), compared with alternatives in Subdirectories vs Subdomains for Translated Static Sites.

Step 1: Organise Content by Language

src/
  en/
    en.11tydata.js        → { lang: "en" }
    index.md
    pricing.md
    docs/getting-started.md
  de/
    de.11tydata.js        → { lang: "de" }
    index.md
    pricing.md            → permalink: /de/preise/
    docs/getting-started.md
  fr/
    fr.11tydata.js        → { lang: "fr" }
    ...
  _data/
    i18n.js

Each language folder has a directory data file setting lang, which every template inside inherits through the data cascade. Translations share the same relative path within their language folder — de/pricing.md is the translation of en/pricing.md — even when the German page publishes at /de/preise/.

Step 2: Register the Plugin

// eleventy.config.js
import { I18nPlugin } from '@11ty/eleventy';

export default function (eleventyConfig) {
  eleventyConfig.addPlugin(I18nPlugin, {
    defaultLanguage: 'en',
    errorMode: 'allow-fallback',
  });
}

errorMode: 'allow-fallback' makes locale_url fall back to the default language when a translation does not exist, instead of failing the build. During translation that is what you want; once translations are complete, strict turns missing translations into build errors.

How the locale_url filter resolves a link to the current page's language Four steps. A German page's template contains a link to slash en slash pricing passed through the locale_url filter. The plugin reads the page's language, de, from the data cascade. It looks for the same relative path in the de folder and finds de slash pricing.md. It returns that file's permalink, slash de slash preise slash, or falls back to the English URL if no translation exists. How a link finds the right language Link in template "/en/pricing/" | locale_url Read page lang de from data cascade Find same path de/pricing.md Return permalink /de/preise/ or fallback
Templates write one link and get the right language everywhere, including localised slugs.

Navigation is written once, in English URLs, and passed through locale_url:

<nav aria-label="{{ 'nav.main' | t }}">
  <a href="{{ '/en/' | locale_url }}">{{ 'nav.home' | t }}</a>
  <a href="{{ '/en/pricing/' | locale_url }}">{{ 'nav.pricing' | t }}</a>
  <a href="{{ '/en/docs/getting-started/' | locale_url }}">{{ 'nav.docs' | t }}</a>
</nav>

On a German page, the pricing link renders as /de/preise/. The t filter is the site's own, defined next.

Step 4: Translate Interface Strings

Strings live in one data file, keyed by language:

// src/_data/i18n.js
export default {
  en: { 'nav.main': 'Main', 'nav.home': 'Home', 'nav.pricing': 'Pricing', 'nav.docs': 'Docs', 'footer.updated': 'Last updated' },
  de: { 'nav.main': 'Hauptmenü', 'nav.home': 'Start', 'nav.pricing': 'Preise', 'nav.docs': 'Dokumentation', 'footer.updated': 'Zuletzt aktualisiert' },
  fr: { 'nav.main': 'Principal', 'nav.home': 'Accueil', 'nav.pricing': 'Tarifs', 'nav.docs': 'Documentation', 'footer.updated': 'Mis à jour' },
};
eleventyConfig.addFilter('t', function (key) {
  const lang = this.page?.lang ?? this.ctx?.lang ?? 'en';
  const strings = this.ctx.i18n ?? {};
  const value = strings[lang]?.[key] ?? strings.en?.[key];
  if (value === undefined) throw new Error(`missing i18n key "${key}"`);
  if (strings[lang]?.[key] === undefined) console.warn(`[i18n] ${lang} missing "${key}", using en`);
  return value;
});

Missing keys in a language warn and fall back to English; keys missing everywhere fail the build. A CI step counts the warnings and reports them on the pull request, so translators see what is outstanding.

Step 5: Dates and Numbers

Intl formats dates and numbers per locale without any library:

eleventyConfig.addFilter('localDate', function (date) {
  const lang = this.page?.lang ?? 'en';
  return new Intl.DateTimeFormat(lang, { dateStyle: 'long', timeZone: 'UTC' }).format(date);
});

2026-03-04 renders as "March 4, 2026", "4. März 2026" and "4 mars 2026". Prices use Intl.NumberFormat with the currency for each market.

Step 6: A Language Switcher and Hreflang

locale_links returns the page's translations, which drives both the switcher and the hreflang tags:

{% set alternates = page.url | locale_links %}
<ul class="lang-switch">
  <li><span aria-current="true" lang="{{ lang }}">{{ lang | upper }}</span></li>
  {% for link in alternates %}
    <li><a href="{{ link.url }}" lang="{{ link.lang }}" hreflang="{{ link.lang }}">{{ link.lang | upper }}</a></li>
  {% endfor %}
</ul>
<link rel="alternate" hreflang="{{ lang }}" href="{{ site.url }}{{ page.url }}">
{% for link in alternates %}<link rel="alternate" hreflang="{{ link.lang }}" href="{{ site.url }}{{ link.url }}">{% endfor %}
<link rel="alternate" hreflang="x-default" href="{{ site.url }}{{ page.url | locale_url('en') }}">

locale_links excludes the current page, so the template adds the self-reference explicitly. Setting <html lang="{{ lang }}"> in the base layout completes the picture for screen readers and browsers.

Responsibilities of the i18n plugin compared with site code Two panels. The plugin provides locale_url to resolve links in the current language, locale_links to list translations, and language inference from the folder. Site code provides the folder data files setting lang, interface strings with a t filter, date and number formatting with Intl, the language switcher, and hreflang tags. What the plugin does and what you build i18n plugin locale_url filter locale_links filter language from folder fallback modes link resolution Site code lang via directory data t filter + strings file Intl dates and prices switcher + hreflang ~60 lines
About 60 lines of site code on top of the plugin covered everything this site needed.

Working With Translators

Translators worked in the translation management system, which exported Markdown files into the right language folders through a pull request, as in Translation Workflows with Git and Crowdin. Two Eleventy-specific rules kept those pull requests clean: translators never change file names (the plugin relies on matching paths), and a localised slug goes in permalink front matter. A CI check compares the file list of each language folder with English and reports pages missing in a language as a table on the pull request, which became the translators' to-do list.

Right-to-Left Languages and Fonts

The site later added Arabic, which brought two template concerns the plugin does not touch. The base layout sets dir alongside lang, read from a small map of right-to-left languages in the strings file, and the CSS was converted to logical properties (margin-inline-start instead of margin-left) so one stylesheet serves both directions; Right-to-Left Layouts on Static Sites covers that conversion. Fonts were the other issue: the Latin web font had no Arabic glyphs, so Arabic pages loaded a separate subset only when lang was ar, through a conditional <link rel="preload"> in the head. Because language is known at build time on every page, both decisions cost nothing at runtime.

Testing Each Language

A small Playwright suite loads the home page and one deep page in each language and checks four things: <html lang> matches the URL prefix, the navigation contains no strings from another language (found by checking each link's text against the other languages' string tables), the switcher links to the equivalent page, and dates render in the local format. It runs in about 8 seconds and caught one real regression in its first month, when a new footer partial used a hard-coded English label.

Collections Per Language

Collections need filtering by language, or the German blog index lists English posts too. A collection per language, built from tags or folder globs, keeps templates simple: collections.posts_de is api.getFilteredByGlob('src/de/blog/*.md'). Feeds, sitemaps and the search index are then generated per language from those collections, so a German reader's RSS feed contains only German posts.

Measured Impact

MeasureValueTool
Pages540 (180 × 3 languages)Build output
Full build4.1 s (2.3 s single-language before)hyperfine
Site code for i18n~60 lines + strings filewc -l
Hreflang errors in audit0Crawl script
Missing translations surfaced per PRTable in PR commentCI
Full build time with one, two and three languages Vertical bars. One language, 180 pages, built in 2.3 seconds. Two languages, 360 pages, built in 3.2 seconds. Three languages, 540 pages, built in 4.1 seconds. Build time as languages were added 2.3 s EN only 3.2 s EN + DE 4.1 s EN + DE + FR
hyperfine, five runs each; build time grew roughly linearly with page count.

Pitfalls & Rollback

  • Different file names per language. The plugin matches by relative path; keep names identical and localise with permalink.
  • Forgetting <html lang>. Screen readers use it to choose pronunciation.
  • Silent fallbacks. Warn on missing strings and report missing pages, or untranslated content goes unnoticed.
  • Strict mode too early. Use allow-fallback until translations are complete.
  • Rollback: the plugin only adds filters; removing it means replacing locale_url calls with plain URLs.

Conclusion

The Eleventy i18n plugin does one thing — resolving links and translations by language folder — and leaves strings, dates, switchers and hreflang to ordinary Eleventy code. With folder-per-language content, a strings file and about 60 lines of filters and templates, a three-language site built in four seconds with complete hreflang and visible translation gaps.

FAQ

What does the Eleventy i18n plugin actually do?

It provides two filters and a few helpers. The locale_url filter turns a URL into the equivalent URL in the current page's language, and locale_links lists a page's translations in other languages. It infers a page's language from the first folder in its path. It does not translate strings, format dates or generate a language switcher; those are built with ordinary Eleventy data and filters.

How should multilingual content be organised in Eleventy?

One top-level folder per language, such as en, de and fr, with the same relative path for translations of the same page. A directory data file in each language folder sets the lang value for everything inside it. The i18n plugin relies on matching relative paths to find translations, so keep file names identical across languages.

How do I translate interface strings like navigation labels?

Store them in a data file keyed by language, for example _data/i18n.js exporting objects for each locale, and look them up in templates with a small filter that reads the page's lang. Keep keys stable and let the build fail or warn when a key is missing in a language, so untranslated strings are visible.

Can different languages have different URL slugs in Eleventy?

Yes, by setting permalink in each translation's front matter. The i18n plugin finds translations by file path, not by URL, so slugs can differ freely as long as the source files share the same relative path. This lets German pages use German words in URLs while staying linked to their English counterparts.

Filed underHow-tosEleventy