I built my own bilingual site. Indonesian and English. Two audiences, two intent patterns, two sets of search queries. The content is the same core idea, but the surface is completely different. Not just translated. Rewritten.

When I first reached for Eleventy to manage this, I expected the i18n plugin to solve everything. It didn't. But it solved the three things that matter most: language routing, hreflang injection, and translated slug management. Those are the three areas where bilingual sites break in ways that are invisible until you check Google Search Console six weeks later and realize half your pages got suppressed.

If you've read my walkthrough on setting up bilingual hreflang correctly, you know the stakes. Wrong hreflang means Google picks one version and buries the other. Eleventy's i18n plugin exists to automate that correctness, so you don't have to maintain it by hand across hundreds of pages.

This essay is a practitioner's walkthrough. Not a feature tour. I'll show you what works, what breaks, and what the documentation doesn't emphasize enough.

Key concept: Eleventy's i18n plugin does not translate your content. It manages the relationship between translated pages: which URL maps to which language, how alternate links get injected, and how templates resolve content across language directories. Translation is your job. Routing is the plugin's job.

What the plugin actually does

The Eleventy Internationalization plugin, built into Eleventy since v2.0.0, provides three capabilities:

  1. Language-aware URL generation via the locale_url filter
  2. Cross-language page linking via the locale_links filter
  3. Error handling for missing translations via configurable error modes

That's it. It's deliberately narrow. The plugin handles the plumbing that connects translated pages. It doesn't touch string localization, date formatting, or number rendering. For those, you'll reach for a third-party library like i18next, rosetta, or eleventy-plugin-i18n (the community plugin, not the built-in one).

This separation of concerns is actually a strength. The built-in plugin does one thing well. It makes sure your language versions know about each other at the URL level. Everything else can be swapped without touching the routing layer.

Directory structure: the foundation

The plugin's behavior depends entirely on how you organize your files. Language directories at the project root define the language code for everything inside them. This is not configurable. It's a convention the plugin enforces.

Here's what a bilingual Indonesian-English project looks like:

src/
├── en/
│   ├── en.json
│   ├── index.md
│   ├── about.md
│   ├── services.md
│   └── blog/
│       ├── blog.json
│       ├── knowledge-graph.md
│       └── schema-markup.md
├── id/
│   ├── id.json
│   ├── index.md
│   ├── tentang.md
│   ├── layanan.md
│   └── blog/
│       ├── blog.json
│       ├── knowledge-graph.md
│       └── schema-markup.md
├── _includes/
│   ├── layouts/
│   │   └── base.njk
│   └── partials/
│       ├── nav.njk
│       └── footer.njk
└── _data/
     └── translations.json

Two things to notice. First, the language directories (en/ and id/) mirror each other. Same filenames for pages that are translations of each other. The plugin uses filename matching to establish the relationship. en/about.md is the English version. id/about.md is the Indonesian version. They're linked automatically.

Second, each language directory has a JSON data file named after itself. en/en.json sets cascade data for every file inside en/. At minimum:

{
  "lang": "en",
  "dir": "ltr"
}

And id/id.json:

{
  "lang": "id",
  "dir": "ltr"
}

The lang value is what the plugin reads to determine language. If you forget this file, the plugin can't identify the language of any page in that directory. No errors. Just silent failure.

Installation and configuration

The plugin ships with Eleventy. No npm install. Just import and register.

// eleventy.config.js (ESM, Eleventy v3+)
import { I18nPlugin } from "@11ty/eleventy";

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

For Eleventy v2 with CommonJS:

// .eleventy.js (CJS, Eleventy v2)
const { I18nPlugin } = require("@11ty/eleventy");

module.exports = function(eleventyConfig) {
  eleventyConfig.addPlugin(I18nPlugin, {
    defaultLanguage: "en",
    errorMode: "allow-fallback"
  });
};

Two configuration options matter:

defaultLanguage determines which language gets clean URLs (no language prefix). If you set "en", your English pages live at /about/ while Indonesian pages live at /id/about/. If you want explicit language codes for both, set it to an empty string.

errorMode controls what happens when a translated page is missing. Three options:

  • "strict": Build fails if any page is missing a translation. Good for fully bilingual sites.
  • "allow-fallback": Only fails if content is missing at both the language path and the root path. Good for sites that translate progressively.
  • "never": Never throws errors. Silently skips missing translations. Dangerous for production.

For a bilingual Indonesian-English site, I recommend "allow-fallback". You'll want the flexibility to publish English-only content without breaking the build. Not every blog post needs an Indonesian version on day one.

The i18n routing flow

Here is how Eleventy processes a bilingual build from source files to output URLs. This is the flow that most documentation skips over.

flowchart TD A["Source files
en/ and id/ directories"] --> B["Data cascade
lang set via en.json / id.json"] B --> C{"defaultLanguage
set to 'en'?"} C -->|Yes| D["English pages
output to /about/, /blog/"] C -->|Yes| E["Indonesian pages
output to /id/about/, /id/blog/"] C -->|No / empty| F["English pages
output to /en/about/"] C -->|No / empty| G["Indonesian pages
output to /id/about/"] D --> H["locale_links filter
matches pages by filename"] E --> H F --> H G --> H H --> I["hreflang injection
link rel=alternate for each pair"] I --> J["Output HTML
correct hreflang on every page"]

The critical step is the filename matching in the locale_links filter. This is where the plugin decides which pages are translations of each other. It matches on the input file path relative to the language directory. So en/blog/knowledge-graph.md matches id/blog/knowledge-graph.md because the path after the language prefix is identical.

This means your translated pages must have the same filename. If your English page is knowledge-graph.md and your Indonesian page is grafik-pengetahuan.md, the plugin won't link them. They'll be treated as unrelated pages.

Translated slugs: the tricky part

Here's where practitioners run into trouble. You want your English URL to be /blog/knowledge-graph/ and your Indonesian URL to be /id/blog/grafik-pengetahuan/. Different slugs for the same content. Better for SEO in each language.

The plugin requires matching filenames for linking. But the output URL can be different. You control the URL through the permalink field in frontmatter.

English file (en/blog/knowledge-graph.md):

---
title: "What Is a Knowledge Graph"
permalink: /blog/knowledge-graph/
---

Indonesian file (id/blog/knowledge-graph.md):

---
title: "Apa Itu Knowledge Graph"
permalink: /id/blog/grafik-pengetahuan/
translatedSlug: grafik-pengetahuan
---

Same filename. Different permalink. The plugin links them because the filename matches. Google sees the correct localized URL because the permalink controls the output path.

If you want to automate this, use a directory data file. In id/blog/blog.11tydata.js:

import slugify from "@sindresorhus/slugify";

export default {
  eleventyComputed: {
    permalink: (data) => {
      if (data.translatedSlug) {
        return `/id/blog/${slugify(data.translatedSlug)}/`;
      }
      return `/id/blog/${data.page.fileSlug}/`;
    }
  }
};

This checks for a translatedSlug frontmatter field. If it exists, the Indonesian page gets a localized URL. If not, it falls back to the English filename. Graceful degradation.

Hreflang injection in templates

The whole point of the plugin is to get hreflang tags correct on every page, automatically. As I explained in the hreflang essay, incorrect hreflang causes Google to suppress one of your language versions. Manual maintenance of hreflang across hundreds of pages is unsustainable. This is what the plugin automates.

In your base layout (_includes/layouts/base.njk):

<!doctype html>
<html lang="{{ lang }}" dir="{{ dir }}">
<head>
  <meta charset="utf-8">
  <title>{{ title }}</title>

  <!-- Self-referencing hreflang -->
  <link rel="alternate" hreflang="{{ lang }}" href="{{ page.url }}">

  <!-- Alternate language versions -->
  {% for link in page.url | locale_links %}
  <link rel="alternate" hreflang="{{ link.lang }}" href="{{ link.url }}">
  {% endfor %}

  <!-- x-default for language negotiation -->
  <link rel="alternate" hreflang="x-default" href="{{ page.url | locale_url('en') }}">
</head>
<body>
  {{ content | safe }}
</body>
</html>

Three things happening here. The self-referencing tag declares the current page's language. The locale_links loop adds a tag for every other language version. And the x-default tag tells search engines which version to show when they can't determine the user's language.

The locale_url filter is what makes the x-default tag work. It transforms the current page's URL into the URL for a specific language version. page.url | locale_url('en') gives you the English version of whatever page you're on.

Language switcher component

Your visitors need a way to switch languages. The locale_links filter provides everything you need.

<!-- _includes/partials/lang-switcher.njk -->
<nav aria-label="Language switcher">
  <a href="{{ page.url }}" lang="{{ lang }}"
     hreflang="{{ lang }}" aria-current="true">
    {{ 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>

This renders a simple EN / ID switcher. The lang and hreflang attributes on each link are important. They tell the browser and search engines what language the linked page is in. Screen readers use these attributes too.

Filtering collections by language

This is a gotcha the docs mention but don't emphasize enough. When you create collections in Eleventy, they include all pages from all languages. If you have 10 English blog posts and 10 Indonesian blog posts, your "blog" collection has 20 items.

You need to filter collections by language in your templates:

<!-- Blog listing that respects language -->
{% set posts = collections.blog | filter("data.lang", lang) %}
{% for post in posts | reverse %}
  <article>
    <h2><a href="{{ post.url }}">{{ post.data.title }}</a></h2>
    <time datetime="{{ post.date | dateToISO }}">{{ post.date | readableDate }}</time>
  </article>
{% endfor %}

The filter("data.lang", lang) keeps only posts that match the current page's language. Without this filter, your Indonesian blog listing shows English posts too. It's a subtle bug that looks correct in development (you might not notice mixed languages in a list of 5 posts) but breaks badly in production.

For navigation, the same principle applies. If you use the EleventyNavigation plugin, add a language filter. The navigation plugin doesn't know about languages.

Comparing i18n approaches in Eleventy

There are several ways to handle multilingual content in Eleventy. The built-in plugin is one approach. Here is how they compare for a bilingual Indonesian-English site.

Approach Routing Hreflang String Translation Complexity Recommendation
Built-in I18nPlugin Automatic via directories locale_links filter Not included Low Use this
Built-in + i18next Automatic via directories locale_links filter Full ICU support Medium Best for UI-heavy sites
eleventy-plugin-i18n (community) Manual (you manage directories) Manual Dictionary-based Medium Viable alternative
Manual directory convention Manual permalinks Manual Manual High Avoid for 10+ pages
Rosetta + custom filters Manual Manual Lightweight key-value Medium Good for minimal UI strings

My recommendation: start with the built-in I18nPlugin alone. It handles routing and hreflang, which are the parts where mistakes cost you rankings. Add i18next only when you have enough UI strings (navigation labels, button text, form fields) to justify the dependency. For a content-heavy site with minimal UI, the built-in plugin is enough.

Common mistakes and how to avoid them

After building my own bilingual site and reviewing several others, these are the errors I see most often.

1. Mismatched filenames

If your English file is about.md and your Indonesian file is tentang.md, the plugin can't link them. Keep filenames identical across language directories. Use permalink for localized URLs.

2. Missing language data files

Forgetting en/en.json or id/id.json means the plugin can't determine the language of pages in that directory. The build won't fail. But locale_links returns nothing, and your hreflang tags disappear silently.

3. Unfiltered collections

Every collection query needs a language filter. Blog listings, tag pages, navigation, RSS feeds. Miss one and you're mixing languages in a list.

4. Hardcoded URLs in templates

Don't write href="/about/" in a shared template. Use href="{{ '/about/' | locale_url }}". The locale_url filter rewrites the URL to include the correct language prefix based on the current page's language. Hardcoded URLs send Indonesian visitors to English pages.

5. Forgetting x-default

The x-default hreflang tag tells search engines which version to show when they can't determine the user's language. Without it, Google guesses. And Google's guess for Indonesian-English sites is often wrong, especially for audiences in Malaysia and Singapore who browse in both languages.

What the plugin doesn't do

Knowing the boundaries matters as much as knowing the features.

The built-in plugin does not provide string translation. It doesn't localize dates, numbers, or currency. It doesn't generate translated slugs from a dictionary. It doesn't handle right-to-left language support beyond what you configure in your data files. It doesn't provide language detection or content negotiation.

For those features, you need additional tools. I covered the static site architecture considerations in why static sites perform better for AI visibility. The same principles apply: keep your stack minimal, understand what each layer does, and don't add dependencies you can't debug.

If you want a complete bilingual static site and you've already read the Eleventy setup guide, the i18n plugin is the next step. It's the layer that turns "two separate sites that happen to share a domain" into "one bilingual site that search engines understand."

Production checklist

Before deploying a bilingual Eleventy site, verify these:

  1. Every language directory has its [lang].json data file with lang and dir values
  2. Translated pages have matching filenames across language directories
  3. The base layout includes self-referencing hreflang, locale_links loop, and x-default
  4. All collection queries filter by data.lang
  5. All internal links in templates use locale_url filter
  6. The language switcher is accessible and uses correct lang/hreflang attributes
  7. The sitemap includes hreflang alternates for each page
  8. Run Google's hreflang testing tool on 5 random pages from each language
  9. Check Google Search Console's International Targeting report within two weeks of launch

Items 1 through 6 are things you configure once. Items 7 through 9 are ongoing verification. The plugin handles the heavy lifting, but you still need to confirm the output is correct.

Frequently Asked Questions

Do I need the Eleventy i18n plugin if I only have two languages?

Yes. Two languages is the minimum where hreflang management matters, and it's exactly where manual maintenance starts breaking. The plugin automates the cross-linking between language versions. Even with just Indonesian and English, that's two hreflang tags per page, self-referencing links, and x-default tags. On a 50-page site, that's 300 tags to maintain. The plugin generates them from your directory structure.

Can I use translated URLs (slugs) with the i18n plugin?

Yes, but through permalink overrides, not filename changes. Keep filenames identical across language directories (the plugin needs this for matching). Set the permalink field in frontmatter to your localized URL. The plugin links pages by filename. Google sees the localized permalink. Both systems get what they need.

What happens when a translated page doesn't exist yet?

It depends on your errorMode setting. With "strict", the build fails. With "allow-fallback", it looks for a non-localized version at the root path. With "never", it silently skips the missing translation. For progressive translation workflows, use "allow-fallback". The hreflang tags simply won't include the missing language, which is correct behavior. Google won't penalize you for having some English-only pages.

Should I put the language code in every URL or only for non-default languages?

For bilingual Indonesian-English sites, I recommend putting the language code only on the non-default language. Set English as default with defaultLanguage: "en". English pages get clean URLs (/about/). Indonesian pages get prefixed URLs (/id/about/). This preserves existing English URLs if you're adding Indonesian later, and it matches the expectation that the "main" site is English.

How does the i18n plugin interact with the EleventyNavigation plugin?

They don't interact automatically. The navigation plugin builds menus from all pages regardless of language. You must add a language filter to your navigation template. Query the navigation collection, then filter items where data.lang matches the current page's lang. Without this filter, your Indonesian navigation includes English pages and vice versa. The Eleventy team has acknowledged this as a known gap.

References

  1. Eleventy Documentation. "Internationalization (I18n) Plugin." 11ty.dev. Link
  2. Eleventy Documentation. "Internationalization (I18n): How to organize your files." 11ty.dev. Link
  3. Saile, Lene. "Internationalization with Eleventy 2.0 and Netlify." lenesaile.com, 2023. Link
  4. Beaudet, Francis. "Making a multilingual website with Eleventy." francisbeaudet.com, 2024. Link
  5. rubenwardy. "Eleventy (11ty) string-based translation with i18next." blog.rubenwardy.com, 2025. Link