Two languages, one taxonomy

This site exists in full in Spanish and in English. Building a bilingual site is not translating the text: it is deciding what the two versions share and what they don't. Every time you decide that something is different in each language, you buy the reader something that reads naturally and you pay for it in plumbing.
In A blog that publishes itself I described how an article gets to the internet. This one describes how it gets there twice, and why almost every problem along the way ended up being solved by the same idea.
Third part. The two before it describe the site as if it had a single version of each page; from here on it has two.
The approach is not mine
Before writing anything I looked for someone who had already done this, and I found "Internationalization with Eleventy 2.0 and Netlify", by Lene Saile. I am not citing her out of politeness: the structure of all this comes from there. Her site, lenesaile.com, is trilingual —English, German and Spanish— and it is built by hand, with no off-the-shelf theme, with performance and accessibility first. That is why it fit here.
The article is written for 11ty 2.0 and it shows its age in one specific place: back then you had to install the internationalization plugin, and now it comes with 11ty itself. The version I use is 3.1.6, and the line that turns it on imports the plugin from the main package:
const { EleventyI18nPlugin } = require("@11ty/eleventy");
What has not aged is the reasoning about the URLs.
The URLs, the decision that shapes the rest
The first thing to decide in a bilingual site is the shape of the addresses, because it is the only part that cannot be changed for free later on.
I followed her recommendation exactly: every language carries its code in the URL, the default language included.
/es/articulos/blog-que-se-publica-solo/
/en/articles/blog-that-publishes-itself/
The alternative was to leave Spanish at the root —/articulos/— and put English
under /en/articles/. It is shorter, and it is asymmetric: from then on,
every template that builds a link has to ask whether the current language is the
one at the root or one of the others. That if does not appear once, it appears
everywhere. With the explicit scheme both versions are treated the same and
there is no special case.
So far I have been following her approach. The next decision is where I went my own way.
URLs get read, so I wrote them in each language
Look at that pair of addresses again. The article is not called
blog-que-se-publica-solo in both: the English one says
blog-that-publishes-itself, and the directory holding it is called articles
and not articulos.
I could have used the same slug in both languages. It would have been much easier, and that is exactly the point: this is the decision that breaks everything else in the article.
I kept it because a URL is text that people read. It shows up in the address
bar, in search results, and in any link somebody pastes somewhere else. An
English reader who runs into /en/articulos/blog-que-se-publica-solo/ is
looking at half a translation, and the missing half is the one that gets seen
outside the site.
And that is where the path I was following ends. Lene matches translations by
directory name: her en/about.md and her de/about.md are the same page
because they are called the same thing, and that is exactly what the plugin
knows how to do. With different names there is nothing to match.
This is not a flaw in her method or a limitation of the plugin, and I would rather put it accurately: her approach works; I wanted something else. Everything that follows exists because I made this decision.
What the hosting would not let me do
There is a second difference from her method, and I did not choose this one.
When a visitor lands on the root of the site, the natural thing would be to send
them to the language of their browser. That is what Lene does, and it is even in the
title of her article: a netlify.toml with a conditional redirect that reads
the
Accept-Language
header and answers with a 302 to whichever language fits.
That needs a dynamic server, one that runs code on every request. GitHub Pages is static: it hands over files that are already written, and runs nothing. There is nowhere to put that decision, and what sits at the root is this:
<meta http-equiv="refresh" content="0; url=/es/">
<link rel="canonical" href="/es/">
It works on any static host but it is worse: the visitor downloads one page in order to immediately download another, and the language they end up in does not depend on them.
transKey: a key that appears in no URL
With different slugs, the site needs to know that two files with different names are the same article. That is the only plumbing of my own that this site has, and it fits in one idea: a canonical key that matches two things that look different.
Before writing it I checked that the plugin did not already carry something like
it. I did not assume: I read its source (src/Plugins/I18nPlugin.js), where the
matching is done by swapping the language code inside the file path and
looking for something at the result. There is no translationKey property you
can put in the front matter to say "these two go together".
So each pair of translations shares one line in its header:
transKey: blog-que-se-publica-solo
The rule is that transKey is always the Spanish slug, even when the English
one is different. It doesn't matter which of the two you pick, as long as you
pick only one; what cannot happen is for each article to decide on its own.
Which pages share a key is worked out during the build, in the data file of the articles directory:
eleventyComputed: {
localeLinks: (data) => {
if (!data.transKey) return false;
return data.collections.all
.filter(
(p) => p.data.transKey === data.transKey && p.data.lang !== data.lang
)
.map((p) => ({ url: p.url, lang: p.data.lang }));
},
},
It took me a while to understand why this is an
eleventyComputed and not a template
filter. Computed data is evaluated once the collections already exist, so
from inside it can look at every page in the site at once, which is what you
need to answer "who else has this key?".
The language of each page, by the way, is not written in any article. A
directory data file —src/es/es.11tydata.js— says lang: "es" and it applies
to everything below it. A new article has nothing to remember.
The safety net
The base template resolves the language links in one line:
{%- set links = localeLinks if localeLinks else (page.url | locale_links) -%}
If the page has a transKey, the site uses its own matching. If it does not
—the home page, which does share an address in both languages— it falls back to
the plugin's locale_links filter, which works perfectly for that case.
What this buys is not fewer lines of code. It is a single <nav> for the whole
site instead of two separate paths to keep in step with each other. The
template does not know which of the two produced the list.
That same list feeds the tags that search engines read:
<link rel="alternate" hreflang="es" href="https://…/es/articulos/…/">
<link rel="alternate" hreflang="en" href="https://…/en/articles/…/">
<link rel="alternate" hreflang="x-default" href="https://…/es/articulos/…/">
There are three
hreflang
rules that are easy to break, and I broke all three before reading them
properly. The URLs have to be absolute, because the search engine reads
these tags outside the context of the page. The list has to include the page
itself, not only the other versions. And x-default says where to send anyone
who does not fit any declared language.
From template to data
There is a difference between a translated site and a bilingual one, and it is in where the text that is not the article lives.
The menu is a data file, not a template:
module.exports = {
es: [
{ title: "Home", url: "/es/" },
{ title: "Artículos", url: "/es/articulos/" },
…
],
en: [ … ],
};
Adding an entry to the menu is one line, and it appears in both languages or in neither. Written by hand in the template, what usually happens is that you add it in one and forget the other.
The same goes for the site's own texts: the tagline, the names of the languages and —this is the one people forget— the interface texts you cannot see. The menu button, the name of each navigation area, the three options of the theme switcher: none of that appears on screen, but a screen reader announces it. Written straight into the HTML it stays in one language and nobody notices, because looking at the page there is nothing to notice.
Two texts stay out of all this on purpose: the name of the site, which is the brand, and the signature in the footer, which is the person who answers for what is here. Neither changes when the language changes.
The one difference I decided not to make: dates
A date is one of the first things that give a half-translated site away.
Translate the text and leave 2026-09-04 underneath — or worse, "4 de
septiembre" — and the reader can tell.
So I wrote the two filters you need in order to give each language its own date.
dateISO produces the machine format, which is required in the datetime
attribute of <time> because that is what the browser and the search engine
read. dateReadable produces the reader's format, different in each language:
new Date(date).toLocaleDateString(lang === "en" ? "en-US" : "es-ES", {
year: "numeric", month: "long", day: "numeric",
});
And nothing uses it. The site shows the date in ISO in the five places where it appears: the article header, the two indexes and the two topic pages.
The reason is in the index. There the dates sit in a column, and a column only lines up if every date is the same width — which "September 4, 2026" and "May 1, 2026" are not. ISO earned its place there through the alignment. In the article header there is no column to line up, so there ISO only has its cost: it reads like a machine value.
I could use each format where it suits. I don't, because a date with two shapes depending on the page is an inconsistency the reader does notice, and the one that reads like a machine value I had already accepted in the index.
Topics: the same idea, again
The site has a taxonomy of topics, and every topic needs a name and a URL in each language. By the time I got to this I already knew how it was solved, because it is the same problem wearing different clothes:
module.exports = {
development: {
es: { label: "Desarrollo", slug: "desarrollo" },
en: { label: "Development", slug: "development" },
},
…
};
The key —development— is a neutral canonical id, the same in both languages
and visible nowhere. Each language hangs its own visible label and its own piece
of URL off it. It is transKey applied to something else: an internal key
matching two things that look different.
Articles store the ids in their header, never the labels:
topics: [development, learning]
And the topic pages do not exist as files. They are generated by pagination over that dictionary, one per entry and per language, with the URL built from the slug of whichever language applies. Adding a topic is one more entry in one file; there is no need to create two pages or to remember to link them to each other.
Removing or renaming one is the expensive part, and there is no trick that helps: the slug of a topic is a published URL, and the id is written in the header of every article that carries it. Changing it means editing them one by one. It happened to me when I retired a topic left over from the trial phase, and I told that story in part one because the problem it exposed was a different one.
The four things that broke
The language switcher came out empty
The first symptom of everything above was this one: opening an article and not seeing the link to the other version. Nothing failed, nothing warned, there was simply nothing to draw.
It is the direct consequence of having chosen different slugs, and it is the
failure that forced me to write transKey. I am telling it here and not in the
decisions section because that is the order in which it appeared.
The index listed itself, and the obvious fix broke the languages
The article index appeared inside its own list.
The cause is a convenience with a price: the data file of the articles directory applies the collection tag to everything inside it, and the index lives inside. The obvious fix is the property 11ty provides for exactly this:
eleventyExcludeFromCollections: true
And that property takes the page out of every collection, collections.all
included. Which is precisely where localeLinks looks for its pairs by
transKey. It fixed the index and left the English index without its link to
the Spanish one: a three-word fix that broke something else two files away.
I checked it in 11ty's code before ruling it out, I did not assume it. The fix that stayed is dumber and does not reach anything else: inside the loop, skip the entry whose URL is the page's own.
{%- if post.url !== page.url %}
The index did not know how to be empty
That same file has another, less obvious consequence: the collection is never empty, because the index always counts itself. Asking for its length in order to decide whether to show a "no articles yet" message does not work, since with no articles the list still measures one.
The failure could only be seen the day the blog was down to zero, and until then there had never been a case where the list had nothing to show. Now the flag goes up inside the loop, past the filter.
A shared cover has to be written the same way in both languages
Both versions of an article show the same photo, and the photo lives once, in the directory of the Spanish article. The English version points at it:
portada: /es/articulos/dos-idiomas-una-taxonomia/foto.jpg
With an absolute path in both. If the Spanish one writes the relative path —which is the natural thing, since the file is right next to it— and the English one writes the absolute path, the image plugin treats them as two different images and generates two identical sets of variants, one next to the article and one in the shared directory. The site looks perfect and the published output carries both.
What I learned
The problems in this article do not look like each other: an empty switcher, an index that listed itself, topics that needed a name in two languages. All three were solved with the same idea, and I invented it twice without noticing it was the same one.
A canonical key, visible nowhere, matching two things that are visible and
that look different. transKey matches articles; the topic id matches topics.
Both times the problem was the same: I had decided that the visible part would
be different in each language, and something had to stay the same so that they
could still be related.
That is what a bilingual site costs, and it is not the translation. It is that everything you decide to show differently needs something behind it that stays the same.