Add Multi-Language Support
Eleventy's bundled I18n plugin adapts this starter for an international audience. Because the kit runs Eleventy 3 with a CommonJS .eleventy.js, you load the plugin with a dynamic import, keep English at the site root, and add each new language in its own folder with locale-prefixed permalinks. The locale_url and locale_links filters then keep every link pointing at the right language.
Set Up the I18n Plugin
Register the plugin and start building localized pages
Register the Plugin
The I18n plugin is bundled with Eleventy — nothing to install. This starter runs Eleventy 3, which ships as ESM, so you can't require() it from the CommonJS .eleventy.js. Make the config function async and pull the plugin in with a dynamic import():
// .eleventy.js (CommonJS, this starter has no "type": "module")
const eleventyNavigationPlugin = require("@11ty/eleventy-navigation");
module.exports = async function (eleventyConfig) {
// Eleventy 3 is ESM-only, so load the bundled I18n plugin dynamically.
const { I18nPlugin } = await import("@11ty/eleventy");
eleventyConfig.addPlugin(I18nPlugin, {
// A BCP 47 tag, the language you serve at the site root
defaultLanguage: "en",
});
// ...the starter's existing plugins (navigation, sitemap, images, minify)
eleventyConfig.addPlugin(eleventyNavigationPlugin);
return {
dir: { input: "src", includes: "_includes", output: "public" },
htmlTemplateEngine: "njk",
};
};💡 Note: defaultLanguage should be the language you publish at the root. In this starter that's English — the page files already carry lang: en.
Organize Content Into Language Folders
Keep English where it already lives under src/content/, and give each new language its own folder. This starter builds every URL from each page's permalink, so add the locale prefix there:
src/
└── content/
├── content.json # { "tags": "sitemap" }, shared by all content
├── pages/ # English = default, served at the site root
│ ├── about.html → /about/ (permalink: "/about/", lang: en)
│ └── contact.html → /contact/
└── es/ # Spanish lives under its own /es/ prefix
├── es.json # { "lang": "es" }, sets lang for the folder
├── about.html → /es/about/ (permalink: "/es/about/")
└── contact.html → /es/contact/Add a directory data file so every page in the folder shares one lang — no need to repeat it on each page:
// src/content/es/es.json
{
"lang": "es"
}✨ Folders aren't URL segments in this kit — permalink is. Serving English at the root and prefixing the rest is exactly the official guide's “implied default language” layout. Keep page slugs aligned across languages so the locale filters can map between them.
Add a Localized Page
Localized pages follow the same convention as the English ones — an explicit permalink plus {% extends %}. There's no lang here because es.json already set it for the folder:
---
title: "Acerca de | Eleventy Starter"
permalink: "/es/about/"
---
{% extends "layouts/base.html" %}
{# Your Spanish markup goes in the same blocks as the English pages #}Link Between Locales
Use the locale_url filter to rewrite any path to the current page's language. Pass a second argument to force a specific locale — it also swaps an existing language code in the path:
{# On any /es/ page, locale_url prefixes paths with /es/ #}
<a href="{{ "/about/" | locale_url }}">Acerca</a>
<a href="{{ "/blog/" | locale_url }}">Blog</a>
{# Pass a language to target it explicitly (swaps an existing code too) #}
<a href="{{ "/es/about/" | locale_url("en") }}">About (English)</a>🎉 You're Multi-Language!
Eleventy now builds localized routes from your folders and permalinks, and locale_url keeps every link pointing at the right language.
Output the Correct <html lang>
Let the plugin set the language attribute for you
The plugin adds page.lang to every template — read from the URL's language code, falling back to your defaultLanguage. This starter's base layout currently hardcodes lang="en"; wire it to page.lang so it's right on every locale:
<!-- src/_includes/layouts/base.html -->
<!-- before: <html lang="en"> (hardcoded) -->
<html lang="{{ page.lang or "en" }}">
<head>
<!-- ... -->
</head>
</html>✨ page.lang is always available once the plugin is registered, so you get a correct <html lang> for accessibility and SEO with no per-page wiring. The lang you set in es.json stays available too, for your own translation logic.
Fallbacks for Missing Translations
Decide what happens when a localized page doesn't exist yet
The errorMode option controls how locale_url behaves when a translation is missing. Use "allow-fallback" to serve the default language instead of throwing a build error:
// inside addPlugin(I18nPlugin, { ... }) in .eleventy.js
eleventyConfig.addPlugin(I18nPlugin, {
defaultLanguage: "en",
// "strict" → throw if /es/page/ has no localized content (default)
// "allow-fallback" → only throw if missing at BOTH /es/page/ and /page/
// "never" → never throw; return the URL untouched
errorMode: "allow-fallback",
});strict
The default. Throws a build error when the localized content is missing, so gaps surface early.
allow-fallback
Only errors when content is missing in both the locale and the default language.
never
Returns the URL as-is and never throws — you handle missing pages yourself.
With allow-fallback, a link built with locale_url on an /es/ page falls back to the English version when src/content/es/my-page.html doesn't exist yet — no broken link.
Build a Language Switcher
The locale_links filter returns every other localized version of the current page (the current page is excluded) — perfect for a dropdown or hreflang tags.
link.url
The localized URL for that translation of the current page.
link.lang
The BCP 47 language code, e.g. es or pt-br.
link.label
A human-friendly language name when one is available for that code.
{# A language switcher listing every translation of this page #}
<ul>
{%- for link in page.url | locale_links %}
<li>
<a href="{{ link.url }}" lang="{{ link.lang }}" hreflang="{{ link.lang }}">
{{ link.label }}
</a>
</li>
{%- endfor %}
</ul>💡 Eleventy builds static HTML, so there's no runtime browser detection. Pair this switcher with hreflang tags so search engines route each visitor to the right language.
Localized Blog Navigation
Prev/next links that stay in the reader's language
The collection filters — getNextCollectionItem, getPreviousCollectionItem, and getCollectionItem — automatically prefer the localized item in the current page's language, with no extra config. This starter tags blog posts with post, so in layouts/post.html:
{# layouts/post.html, keep prev/next in the reader's language #}
{%- set nextPost = collections.post | getNextCollectionItem %}
{%- if nextPost %}
<a href="{{ nextPost.url | locale_url }}">{{ nextPost.data.title }} →</a>
{%- endif %}Ready to Go Global! 🌍
Your site now speaks multiple languages with clean URLs and smart fallbacks.
