Internationalization with Eleventy 2.0 and Netlify

Published:
Last edit:
Reading time:
21 min.

I’m from Germany, now living and working in Spain. My daily life is trilingual, as English is the language in which much of my professional activity takes place.

When I rebuilt my personal website (that’s where you are right now!) a few months ago, I came up with the idea of making all the content available in all three languages. If you think about it, it is really a contribution to accessibility!

Okay, let’s say it’s at least an Inclusive Design effort. And as a natural consequence I’m basically increasing my reach.

So I went for it! And I have learned a lot along the way. For example, I’ve never been aware of the accept-language header, which indicates the natural language and locale that the user prefers.

In this article, which I’m publishing along with my lightning talk at TheJam.dev 2023, I explain how a basic setup for internationalization can be done with Eleventy.

Eleventy bundles a plugin specifically for this purpose with version 2.0 that does the tricky stuff for us in the background.

My goal is to build a multilingual starter project, that is as simple and comprehensible as possible.

Let’s begin!

Skip table of contents

Table of contents

Installing Eleventy 2.0

Make sure you have Node.js installed first. After running npm init, you get a package.json file to hold metadata about your project and to record your dependencies.

Create a new project folder, then cd into it. I have called mine “eleventy-i18n-starter”.

To install Eleventy in your project, on the command line, run:

npm install @11ty/eleventy --save-dev

We do not need to install anything else! 🎉

Basic adjustments

First, we create an Eleventy config file. Here we specify where our source files are located, and what our output folder should be called. I also let Eleventy know that we use Nunjucks` as the default global template language for our markdown and HTML files.

module.exports = function (eleventyConfig) {
  return {
    dir: {
      input: 'src',
      output: 'dist'
    },
    markdownTemplateEngine: 'njk',
    htmlTemplateEngine: 'njk'
  };
};

Next, create the input folder src, and in there two folders, called _data (we’ll need it later) and _includes. Inside of _includes add a file called base.njk. It contains our main layout:

base.njk:

<!doctype html>
<html lang="" dir="">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <title>{{ title }}</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/gh/kimeiga/bahunya/dist/bahunya.min.css"
    />

    <link rel="canonical" href="{{ meta.url }}{{ page.url }}" />
    <meta name="description" content="{{ description }}" />
  </head>

  <body>
    {% include "header.njk" %}
    <main>
      <h1>{{ title }}</h1>
      {{ content | safe }}
    </main>

    {% include "footer.njk" %}
  </body>
</html>

For our simple example, we only need one layout that we use to wrap all content.

I only put some very basic and required details in the head section.
The title tag and meta description will be pulled in from our markdown template files, for styling I use a 10KB classless CSS framework on a CDN, since this article is not about CSS.

The landmark main contains our content, which is generated by markdown files. To avoid it getting too cluttered, I outsource the header and footer landmarks into layout partials.

Add the header.njk and footer.njk partials in the same folder.
For now, just leave them empty.

To be able to start Eleventy with a quick command, we add the CLI script in the scripts section of the package.json.

package.json:

  "scripts": {
    "start": "eleventy --serve --watch",
    "build": "eleventy"
  },

Internationalization and Localization

Before we go any further, let’s take a quick look at the terms internationalization (als known as “i18n”) and localization (“l10n”), which you’re inevitably going to come across.

Localization

Localization strives to put contents in a structurally and linguistically correct context for a determined locale.

Locales are circumscribed by ISO language codes, composed of both a base language, and the country (territory) of use.
Using German as an example, there are the following locale codes:

de
German
de-AT
German (Austria)
de-CH
German (Switzerland)
de-DE
German (Germany)
de-LI
German (Liechtenstein)
de-LU
German (Luxembourg)

As part of the localization process, you translate your content, adjust the tone of voice to fit the culture, add currencies and units of measurement used in that region, or adjust multimedia so that the message comes across correctly in that cultural context.

We are worrying more about internationalization in the scope of this article.

Internationalization

With internationalization you prepare your code for the requirements of different locales and the whole localization process.

For example, we want to make sure that languages with a right-to-left spelling are handled correctly by CSS, or that text contents from different languages can take up different amounts of space without breaking the design. German words can be quite long!

If you want to know more about it, here are some long German words.
For some reason I have collected them over the years.

Long list of long German words
  • Pfarrgemeinderatsmitglied
  • Liegenschaftskataster
  • Kunstfahndungsdienststelle
  • Nichtregierungsorganisationen
  • Mobilfunkanrufaufzeichnungen
  • Säbelscheidenschienbein
  • Strukturentwicklungszulagen
  • nordwestmecklenburgisch
  • Unabhängigkeitsreferendum
  • Junggesellinnenabschiedsparty
  • Reiseschnäppchenportal
  • Selbstverwirklichungsversprechen
  • Unabhängigkeitsbestrebungen
  • Nachwuchsunternehmerin
  • Desinformationskampagne
  • Bundesprogrammkommission
  • Hauptstadtbüroleiterin
  • Altmedienbesitzstandswahrung
  • Weltanschauungsvereinigungen
  • aufmerksamkeitsökonomisch
  • Majestätsbeleidungsparagrafen
  • Zweckentfremdungsverbot
  • Suchmaschinenergebnisseiten
  • Lebensmittelverteilungszentrum
  • Rassekaninchenzüchterverein
  • Bundespsychotherapeutenkammer
  • Auslandsdirektinvestitionen
  • Nachwuchsleistungszentren
  • Wildwasserkajakstrecke
  • Satellitenbeobachtungsprogramm
  • Onlinelebensmittellieferdienst
  • Jahresdurchschnittstemperaturen
  • Androgenisierungserscheinungen
  • Urananreicherungszentrum
  • Senatsuntersuchungsausschuss
  • Energieforschungskooperationen
  • Antiglobalisierungstendenzen
  • Renationalisierungstendenzen
  • Eiskunstlaufenthusiastinnen
  • Antikorruptionsstaatsanwältin
  • Selbstzerfleischungsprozess
  • Unterbringungsmöglichkeiten
  • Heimatschutzministerium
  • Verkehrsüberwachungsmaßnahmen
  • Geschwindigkeitsbegrenzungsschild
  • Kunsthandwerkergenossenschaft
  • Fruchstaftgetränkehersteller
  • Wahlkampffinanzierungsgesetze
  • Unabhängigkeitsbefürworterin
  • Vizepräsidentschaftskandidatin
  • Geldwäscheverdachtsmeldungen

Serious internationalization and localization are not an easy task.

On my personal site (and within the scope of this project) I take the liberty of determining that there is only one English or Spanish language for all visitors, so all my readers must put up with me happily mixing British and American English in spelling and expressions, because I do not know any better.

Like on this website here, I use English, Spanish and German for the starter project, simply because I can speak these languages and I don’t trust automatic translations. Otherwise, an RTL language would of course have been intriguing.

Let’s start thinking of a structure for our three languages.

Patterns for locale-specific URLs

There are several patterns to build identifiers into the URLs.

You can choose to buy country-specific domains (yourwebsite.de), set up subdomains (de.yourwebsite.com) add URL parameters (yourwebsite.com?lang=de) or create localized subdirectories (yourwebsite.com/de).
Google Search Central provides a table with some pro and cons.

Eleventy compiles based on folder structure, so localized subdirectories work perfectly. This is also the procedure recommended by Eleventy.

Explicit and implicit localization

Next, we need to consider whether we want to have explicit or implicit localized sub directories. That is, do we want a two-letter locale code for all languages in the URL (explicit), or should our primary language be displayed in the URL without an identifier (implicit)?

Both variants are possible. If you have a very clear primary language on the website and maybe don’t even want to translate all the contents, it makes sense to not show a language code in the URL for your primary language. In the docs of the plugin it is explained how this is set up.

On my personal site all content is translated. And even though I consider English to be the primary language here, I value all three languages equally. So I opted for the explicit scheme, and that’s how we want to do it in this starter project as well.

Creating the folder structure

Let’s add three folders, named by the two-character ISO code of each language, and in each one create a file called index.md.

Our folder structure now looks like that:

│
├── src
│  │
│  ├── _data
│  ├── _includes
│  ├── de
│  │  └── index.md
│  ├── en
│  │  └── index.md
│  ├── es
│  │  └── index.md
│

We’ll put some localized contents in each file:

en/index.md:

---
title: 'English Page'
description: 'This is the english version of the homepage'
---

This is a minimal starter for localized content with Eleventy.

de/index.md:

---
title: 'Deutsche Seite'
description: 'Dies ist die deutsche Version der Startseite'
---

Dies ist ein minimaler Starter für lokalisierte Inhalte mit Eleventy.

es/index.md:

---
title: 'Página en español'
description: 'Esta es la versión en español de la página inical'
---

Este es un starter mínimo para contenido localizado con Eleventy.

If we don’t change the permalink structure in Eleventy, it will take over our folder structure inside the input directory as is. The index.md file is recognized as the root of the language directory and transferred directly as index.html.

Let’s add a subpage, the classic “about me”, and add some localized contents.

I call this page about.md in both languages. This has various advantages:

  • The Eleventy internationalization plugin recognizes the related pages
  • all pages are displayed in the same order
  • and it keeps my starter project as minimal and simple as possible.

If we look in our output folder, we see that three new subfolders have now been created, all named “about”.

│
├── dist
│  │
│  ├── de
│  │  └── index.html
│  │  └── about
│  │     └── index.html
│  ├── en
│  │  └── index.html
│  │  └── about
│  │     └── index.html
│  ├── es
│  │  └── index.html
│  │  └── about
│  │     └── index.html
│

Note that this is the final structure of our website, so the link for the German “About me” page would be something like: www.mywebsite.com/de/about.

Localized URL slugs

I prefer the languages to be consistent within themselves. So let’s adjust the permalinks.

de/about.md:

---
title: 'Über mich'
description: 'Eine deutsche Unterseite'
permalink: /de/ueber-mich/index.html
---

Ich bin Webentwicklerin und Designerin, geboren in Berlin, zu Hause in Madrid.

If you want to automize this a little more and make it more intuitive to use, here is a smart solution I found in the Eleventy Base Blog.

You have to add Parent Directory Data Files inside all localized subfolders.

Here, you pass in the respective language in the permalink and you check for an optional frontmatter data entry in your template, that gets slugified.

es/es.11tydata.js:

module.exports = {
  lang: 'es',
  permalink: function (data) {
    // Slug override for localized URL slugs
    if (data.slugOverride) {
      return `/${data.lang}/${this.slugify(data.slugOverride)}/`;
    }
  }
};

es/about.md:

---
title: 'Sobre mí'
description: 'Una subpágina en español'
slugOverride: sobre mi
---

Soy una desarrolladora y diseñadora nacida en Berlín, viviendo en Madrid.

In our case we could just slugify the title, but I prefer to treat these things separately, as I may choose to adjust the title string, while keeping the permalink intact.

Seperating code from translation

To understand how all this is working it’s crucial you dive into Eleventys Data Cascade.

We always want to seperate code from translation. We already seperate code from contents: our code lives in layout files (in this case just base.njk), and our contents are created by Markdown in template files. We also need to store some localized strings for the layout. You could store them in the Frontmatter of the layout file, but I prefer having all in one place.

Global data comes into play!

Global data

In the _data folder we put everything we want to be made globally available in our project. Valid are all *.json and module.exports values from *.js files.

Create the following data files:

meta.js:

// holds all our meta data
module.exports = {
  url: process.env.URL || 'http://localhost:8080',
  siteName: '18n-starter',
  siteDescription:
    "Minimal starter for localized content, using Eleventy's own Internationalization (I18n) plugin"
};

languages.js:

// same locale codes as in your localized subdirectories
module.exports = {
  en: {
    dir: '', // stands for the direction of the language set in the head, defaults to LTR (left to right)
    availableText: 'This page is also available in:'
  },
  de: {
    availableText: 'Diese Seite ist auch verfügbar in:'
  },
  es: {
    availableText: 'Esta página también está disponible en:'
  }
};

layout.js:

// sets a global layout for all templates, can be overwritten later in the Eleventy Data Cascade
module.exports = 'base.njk';

navigation.js:

// just my personal preference for creating navigation in Eleventy
module.exports = {
  en: [
    {
      text: 'Home',
      url: '/en/'
    },
    {
      text: 'About me',
      url: '/en/about-me/'
    }
  ],
  de: [
    {
      text: 'Startseite',
      url: '/de/'
    },
    {
      text: 'Über mich',
      url: '/de/ueber-mich/'
    }
  ],
  es: [
    {
      text: 'Inicio',
      url: '/es/'
    },
    {
      text: 'Sobre mi',
      url: '/es/sobre-mi/'
    }
  ]
};

I always try to avoid unnecessary complexity. I put all my localized strings in my global data folder within languages.js, and access them using dot notation.

If I find myself in need of more localized strings, I’ll just add them there. Same goes for the navigation. I like to have maximum control over which pages I show where, and what they should be called in the menu.

Using the Eleventy internationalization plugin

To activate the bundled i18n-Plugin, add it to your eleventy.js:

const {EleventyI18nPlugin} = require('@11ty/eleventy');

module.exports = function (eleventyConfig) {
  eleventyConfig.addPlugin(EleventyI18nPlugin, {
    defaultLanguage: 'en' // Required
  });

  // other settings
};

We need to specify a default language. I choose English, but it can be any language: defaultLanguage: 'en'

The plugin gives us an addition to the page variable (page.lang) as well as two new universal filters (locale_url and locale_links) for the most common template languages.

Let’s take a look at page.lang first.

Using the language tag for the current page template

page.lang represents the language tag for the current page template, and will default to the value we have passed as defaultLanguage in the plugin.

First of all we use it for the html lang attribute and to access the direction value (dir) that can be set in languages.js.

base.njk:

<!doctype html>

<html lang="{{ page.lang }}" dir="{{ languages[page.lang].dir or 'ltr' }}">
  <!-- rest of the template -->
</html>

It takes your localized directory name and sets it as the language of the current document, and in case of the dir attribute, loops through the languages.js in _data and gets the corresponding direction (RTL, or by default LTR).

This is very important.

The lang attribute is used to define the language of an element. It is used by screen readers to provide the correct accent and pronunciation, user agent can select the correct glyph variants and quotation marks, hyphenation, ligatures, and spacing.
Like everything in accessibility, it also benefits the discoverability.

In this case, we define the language of the whole document, but you can also set the language on any element, e.g. <p lang="fr">Bonjour mon ami</p>, inside a document that is written in another language.

I have done that earlier in the article with the German words list. Imagine a screen reader trying to read out this annoying list when a language other than German is set.

Next, we use it for our navigation in the header landmark and as a simple language switcher in the footer landmark.

header.njk:

{% set activePage = page.url %}

<header>
  <nav aria-label="Primary">
    <ul>
      {% for item in navigation[page.lang] %}
      <li>
        <a href="{{ item.url }}">{{ item.text }}</a>
      </li>
      {% endfor %}
      <li>
        <a href="https://github.com/madrilene/eleventy-i18n">GitHub</a>
      </li>
    </ul>
  </nav>
</header>

This partial loops through our global navigation file, and based on the value set by page.lang, shows the menu in the current language.

footer.njk:

<footer>
  {{ languages[page.lang].availableText }}

  {% for link in page.url | locale_links %} {%-if not loop.first %}/{% endif %}
  <a href="{{ link.url }}" lang="{{ link.lang }}" hreflang="{{ link.lang }}"
    >{{ link.label }}</a
  >
  {% endfor %}
</footer>

In the footer we first loop through languages.js to get the text string in the current language, then we use the first of the newly available universal filters: locale_links.

locale_links returns an array of the alternative content for a specified URL. The original page passed to the filter is not included in the results.
Looping through this array gives us a perfectly fine language switcher!

You could also create a language switcher with two character locale codes that shows the current language first:

<!-- alternative language switcher -->
<footer>
  <nav>
    <a href="{{ page.url }}" lang="{{ page.lang }}" hreflang="{{ page.lang }}">
      {{ page.lang | upper }}
    </a>

    {% for link in page.url | locale_links %}
    <a href="{{ link.url }}" lang="{{ link.lang }}" hreflang="{{ link.lang }}">
      {{ link.lang | upper }}</a
    >
    {% endfor %}
  </nav>
</footer>

In the head element in base.njk we let the user client know that there are alternative versions of the current document. The hreflang attribute indicates which language the linked resource is in.

Note that each language version must list itself, as well as all other language versions. Alternate URLs must be fully-qualified, including the transport method - that is http or https.

We add first the link attribute to the current URL, then we loop over the alternative versions.

base.njk:

<!-- stylesheet here -->

<link rel="alternate" hreflang="{{ page.lang }}" href="{{ meta.url }}{{ page.url }}" />

    {% for link in page.url | locale_links %}
    <link
      rel="alternate"
      hreflang="{{ link.lang }}"
      href="{{ meta.url }}{{ link.url }}"
    />
    {% endfor %}

<!-- Canonical URL here -->

The fact that the current language is “filtered out” of the filter is very helpful for this use case! But now I want to be very strict and also take the x-default hreflang annotation into account. The hreflang x-default annotation is used to indicate the default language or regional targeting of a page when no other language or region is explicitly specified. Not completely sure what that means, but I will include it with the strategy in mind, that English is my default language. Please correct me if I am worng here.

For this to work I need the canonical link to my default language, English, on every page. So looping through the locale_links filter does not help me.

Good thing, there is one more filter we haven’t talked about yet.

Using the ‘locale_url’ Filter

Put in the words of the Eleventy documentation, locale_url accepts any arbitrary URL string and transforms it using the current page’s locale. Works as expected if the URL already contains a language code. This is most useful in any shared code used by internationalized content (layouts, partials, includes, etc).

In our starter project we don’t need it, but imagine using it as a layout partial for a call to action, sending the visitor to the localized blog page, for example. The syntax is as follows:

<a href="{{ "/blog/" | locale_url }}">Blog</a>

It only seems to work if you don’t touch the permalinks though, this why I’m not making use of it like that for now.

‘locale_url’ override for ‘x-default hreflang’

If you take a look at the docs, you will also see the note that overriding the root locale with a second argument is possible.

The docs say it is “unlikely that you’ll need to”, but in our case that is exactly what we need.

Under our loop for alternative versions, we now add this link attribute:

<link
  rel="alternate"
  hreflang="x-default"
  href="{{ meta.url }}{{ page.url | locale_url('en') }}"
/>

It’s quite possible that your EleventyI18n plugin is now complaining in the console. In the Eleventy configuration file we have to set the errorMode from default “strict” to “allow-fallback”.

eleventyConfig.addPlugin(EleventyI18nPlugin, {
  defaultLanguage: 'en',
  errorMode: 'allow-fallback'
});

Further accessibility adjustments

We need to make some importand adjustements in terms of accessibility. Navigation landmarks (nav) should have a label, and these labels must be translated as well. The same goes for the “skip navigation” link.
Both strings can be set in languages.js.

You can find that implemented in the GitHub repo for the header.njk and languages.js.

Redirecting the visitor to their preferred language directory

Before we can use our code on a live site, we need to adjust one very important detail.

Right now, we get a 404 in the URL root, as we are not yet redirecting the visitor to their preferred language directory.

How is the preferred language determined?

The language-based redirects we will set up match against the first language reported by the browser in the Accept-Language header.

This can be adjusted in the browser’s preference settings. Also, if you don’t hide your IP address, it tells user clients about your geolocation on a country level.

In theory, I could target myself by being a German speaker living in Spain, and show some very specific content for that use case, as long as I share that info with my browser. Right?

You can see what values are set in the dev tools looking at the network tab. My different browsers show different preferences, but Chrome gives me this:

accept-language: en-GB,en;q=0.9,es-ES;q=0.8,es;q=0.7,de-DE;q=0.6,de;q=0.5,en-US;q=0.4

As you can see, targeting folks based on these indicators is not a good idea.
My preferred language is set to (British) English.

In our project, apart from providing an easy way for users to switch to their actual preferred language, we want to always default back to a base language (in case the visitor’s preferred language is set to french, for example).

Setting redirect rules with Netlify

In the scope of our example project we are working with Netlify Hosting.

Netlify provides a straighforward implementation for redirect rules, either using the _redirects file, or the Netlify configuration file.

Personally, I prefer to use the _redirects file only for automized internal redirects when a page has permanently changed location (some kind of separation of concerns).

Since I create a Netlify configuration file (netlify.toml) anyway to inform which is my output directory and which is my build script, I put my three language-based redirects there as well. I also like the clean syntax!

netlify.toml:

# tell netlify about your build script and output directory

[build]
  command = "npm run build"
  publish = "dist"

# redirect to german, spanish or english landing pages

[[redirects]]
  from = "/"
  to = "/de"
  status = 302
  force= true
  conditions = {Language = ["de"]}

[[redirects]]
  from = "/"
  to = "/es"
  status = 302
  force = true
  conditions = {Language = ["es"]}

[[redirects]]
  from = "/"
  to = "/en"
  status = 302
  force = true

We redirect the root directory based on the preferred language - the one that comes first in the Accept-Language header string (conditions = {Language = ["de"]}). This is an array, so you could set multiple values.

Set as many redirects as you have created languages. The last one should be the fallback language, i.e. the language you have set as defaultLanguage in the i18n-Plugin.

Final notes

The project is ready to be deployed!

My preferred way of doing that is to create a new repo on GitHub and deploy it to Netlify.

There are many things still missing. For example, you might need localized collections and a localized date filter for your blog.

I wanted to keep this starter as simple and straightforward as possible. You can refine and automate many processes, be it the navigation, the permalinks, or the subpages of the respective languages, which can be assigned to each other with keys in the frontmatter, instead of using the same folder name.

Unfortunately, CSS had no place in this article. By all means, please replace the CDN stylesheet with your own, it only serves to give a reasonably nice appearance without having to set classes.

Honorable CSS mention

The :lang pseudo-class matches elements based on the language they are determined to be in. A really nice way to give your languages a unique touch!

nav:lang(de) {
  background-color: darkseagreen;
}

Logical properties provide automatic support for internationalization and help you build stable and inclusive frontends. I recommend reading more about logical properties in the “Learn CSS” course on web.dev.

.element {
  padding-block-start: 2em;
  padding-block-end: 2em;
}

Forking the repo

You can fork the repo I created along with the article. I added some extra features, like a helper script to indicate the current page to screen readers, redirects to localized 404 pages and global data strings for improved accessibility (translated aria labels, skip-link…). To avoid render blocking, I inlined the minified CSS after removing styles that are not used.

I would be happy if more languages were added! Here is an explanation of how to contribute to projects. Add a new folder with your locale in src, and create the corresponding template files in your language. Add the navigation array to navigation.js, the localized strings to languages.js and add the redirects to netlify.toml.

TheJam.dev 2023 Lightning Talk


I try to keep my articles up to date, and of course I could be wrong, or there could be a better solution. If you see something that is not true (anymore), or something that should be mentioned, feel free to edit the article on GitHub.

Webmentions

11 Reactions (Likes, reposts, links and comments)

jcletousey jcletousey Ryan Gittings Ryan Gittings Eleventy 🎈 v2.0.0 Ryan Gittings Eleventy 🎈 v2.0.0

Have you published a response? Let me know where: