Close Close menu

Blah Blah Blah Neon sign - Photo by Nick Fewings

Multilingual sites with Eleventy

Eleventy might not have multilingual and localisation capabilities out of the box, but you can build a pretty good setup using global data files, collections and Nunjucks as a templating language.

In order to have a simple project to work with, let's build a fairly straight forward multilingual blog.

Here is the folder architecture we will be working with. It is quite a standard Eleventy architecture and a pretty simple project. However, I believe the principles and techniques can easily be applied to bigger ones.

+-- src
+-- _data
+-- site.js
+-- footer.js
+-- header.js
+-- _includes
+-- layouts
+-- base.njk
+-- partials
+-- header.njk
+-- footer.njk
+-- en
+-- pages
+-- index.html
+-- blog.html
+-- contact.html
+-- posts
+-- posts.json
+-- en.json
+-- fr
+-- pages
+-- index.html
+-- blog.html
+-- contact.html
+-- posts
+-- posts.json
+-- fr.json
+-- .eleventy.js

Set locales

The first step is to create our locales using directory data files.

We simply add en.json and fr.json in the root of our language directories. In each of them, we specify a locale key. That will make the corresponding values accessible in all template files living in those languages directories and subdirectories.

Here what our fr.json file would contain:

"locale": "fr"

{{ locale }} will now output "fr" or "en" for any of our template files, depending on where that template file is located in our folder architecture.

Localised date filter

Nunjucks does not have a date filter. We can easily create one using moment.js and pass it our locale value to localise dates for us, which is an important part of all multilingual projects. In order to do that, we use the following code in our .eleventy.js file:

// date filter (localized)
eleventyConfig.addNunjucksFilter("date", function(date, format, locale) {
locale = locale ? locale : "en";
return moment(date).format(format);

Now, we can just call that filter in our templates and pass it a locale parameter. Note that, since we set the locale to "en" by default, we can use our filter without a locale parameter for our purely numeric dates. Here is a small example.

<p><time datetime="{{ | date('Y-MM-DD') }}">{{ post.item|date("DD MMMM Y", locale) }}</time></p>

Now that our dates are automatically localized, let's move to collections.

Localized collections

We can also use our directory structure to create collections in Eleventy. The simplest way to go about it is to create collections per language. We can easily accomplish that using the getFilteredByGlob method in our .eleventy.js file.

module.exports = function(eleventyConfig) {
eleventyConfig.addCollection("posts_en", function(collection) {
return collection.getFilteredByGlob("./src/en/posts/*.md");

module.exports = function(eleventyConfig) {
eleventyConfig.addCollection("posts_fr", function(collection) {
return collection.getFilteredByGlob("./src/fr/posts/*.md");

Because they live in subdirectories of our language directories, all those markdown files have that handy locale variable available. We can for example use it to create permalinks for all our posts.

Instead of adding a permalink variable in each front-matter, we can simply add a posts.js or posts.json directory data file in each of our three posts folders with the following content:

permalink: "/{{ locale }}/blog/{{ page.fileslug }}/index.html"

Now that we have localised detail pages for all of our posts, we can simply go into all three of our blog.njk pages and loop over our language-specific collections.

{% for post in collections.posts_en | reverse %}
{% if loop.first %}<ul>{% endif %}

<p><time datetime="{{ | date('Y-MM-DD') }}">{{ | date("DD MMMM[,] Y", locale) }}</time></p>
<h3><a href="{{ post.url }}">{{ }}</a></h3>


{% if loop.last %}</ul>{% endif %}
{% endfor %}

We could make use of our locale variable to call our collections too. We would just have to switch to a square brackets notation instead.

{% set posts = collections["posts_" + locale] %}
{% for post in posts %}
{# loop content #}
{% endfor %}

Localised layouts and partials

Although duplicating our pages and posts is quite logical, we don't want to duplicate our layouts and partials.

Luckily, we can avoid it by feeding them localised strings. In order to do that, we only need to create multilingual global data files containing our locales as keys. We can then reference those keys dynamically in our layouts or partials using our trusty locale variable.


Let's start with a simple layout example:

./src/_data/site.js is going to give us variables available under a site key.

module.exports = {
buildTime: new Date(),
baseUrl: "",
name: "mySite",
twitter: "@handle",
en: {
metaTitle: "Title in english",
metaDescription: "Description in english"
fr: {
metaTitle: "Titre en français",
metaDescription: "Description en français"

We can use those variables in our ./src/fr/pages/index.njk file. In this case, we assign some of them to Nunjucks variables instead of using them directly because those are values we might want to be able to easily override for specific pages. We could use the same logic for a posts specific template.

permalink: /{{ locale }}/index.html

{% extends "layouts/base.njk" %}

{% set metaTitle = site[locale].metaTitle %}
{% set metaDescription = site[locale].metaDescription %}
{% set metaImage = site[locale].metaImage %}

{% block content %}
{# page content #}
{% endblock %}

Since our page template extends ./src/_includes/layouts/base.njk, Nunjucks variables declared in the child template as well as our Eleventy global variables are going to be available in that layout too.

<!DOCTYPE html>
<html lang="{{ locale }}">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{{ metaTitle }}</title>
<link rel="stylesheet" href="/css/main.min.css">

<!-- open graph -->
<meta property="og:type" content="article">
<meta property="og:title" content="{{ metaTitle }}">
<meta property="og:image" content="{{ metaImage }}">
<meta property="og:site_name" content="{{ }}">
<meta property="og:description" content="{{ metaDescription }}">

<!-- twitter -->
<meta name="twitter:card" content="summary">
<meta name="twitter:site" content="{{ site.twitter }}">
<meta name="twitter:title" content="{{ metaTitle }}">
<meta name="twitter:description" content="{{ metaDescription }}">
<meta name="twitter:image" content="{{ metaImage }}">

{% include "partials/siteheader.njk" %}
{% block content %}{% endblock %}
{% include "partials/sitefooter.njk" %}


Localizing partials like ./src/_includes/partials/footer.njk can easily be done using the same simple principles.

First, we create a ./data/footer.js file using our locales as keys.

module.exports = {
mapUrl: "",
fr: {
addressTitle: "Adresse",
addressStreet: "Rue du marché",
addressNumber: "42",
addressPostcode: "1000",
addressCity: "Bruxelles",
directionsLabel: "Itinéraire"
en: {
addressTitle: "Address",
addressStreet: "Market street",
addressNumber: "42",
addressPostcode: "1000",
addressCity: "Brussels",
directionsLabel: "Directions"

Then, in ./src/_includes/partials/footer.njk, we just rely on the value of our locale variable to access those keys using brackets notation:

<h2>{{ footer[locale].addressTitle }}</h2>

{{ footer[locale].addressStreet }}, {{ footer[locale].addressNumber }}<br>
{{ footer[locale].addressPostcode }}, {{ footer[locale].addressCity }}
<p><a href="{{ footer.mapUrl }}">{{ footer[locale].directionsLabel }}</a></p>

Just like that, we now have a footer with automatic translations.

Flexible by design

Static sites generators are very flexible in terms of the data structures you can create with them. Eleventy is one of the most flexible SSG I have used so far, which makes it quite a good fit to create a multilingual data structure that can work for pretty much any project.