Post

Multilingual Without Losing Track of the Page

SSG has had multilingual builds for a while and I have never written a word about them, which is a shame, because the interesting part is not the feature list. It is that a translated site breaks an assumption every static generator starts from: that a page is a file.

Once a page exists twice, in two languages, under two different slugs, "which page is this" stops being answerable from the path. Everything downstream — canonical URLs, hreflang, the link from one document to another, the sitemap — depends on an answer, and if you do not give it one explicitly, it will invent a wrong one.

The shape of the problem

flowchart LR
    A["content/pages/o-nas.md
lang: pl"] --> K{{"translation_key:
about"}} B["content/pages/about.en.md
lang: en"] --> K K --> U1["/o-nas/"] K --> U2["/en/about/"] K --> H["hreflang pair
+ x-default"]

Two files, two slugs, one page. The key in the middle is the whole design.

1. The slug cannot be the identity

The obvious approach is to match translations by filename or slug. It works until the first time a translation is any good — because a good Polish slug is not the English one transliterated. /o-nas/ and /about/ should not have to look alike to be the same page.

So identity is explicit:

1title: About
2slug: about
3lang: en
4translation_key: about

translation_key is what pairs them. Slugs stay free to be idiomatic in each language, which is the point of translating at all.

When the key is absent SSG derives one from the filename — deterministically, so two builds agree — and when lang is absent it resolves to the default language. Both defaults exist so a single-language site never has to think about any of this, and a site adding its second language can do it one file at a time.

2. Does the default language get a prefix?

This is a one-line setting and a decision you cannot quietly reverse:

1i18n:
2  prefix_default_language: false   # /o-nas/  and /en/about/
3  # prefix_default_language: true  # /pl/o-nas/ and /en/about/

false gives the primary audience clean URLs and treats the other languages as additions. true treats every language as equal and costs you a redirect from / — but it means adding a third language never reshuffles the first one's URLs.

Both are defensible. What is not defensible is changing your mind in month eight, because every URL you already published moves. Pick it when the site has one language, not when it has three.

Here is the one that surprised me most while using it.

Write [installation](installation.md) in a Polish page. What should it point at? Not installation.md — that file is the English document. It should point at the Polish translation of that document, because the reader is reading Polish and a link that drops them into another language mid-sentence is a bug, not a feature.

So rewrite_md_links is language-aware: it resolves to the active language's translation of the target. An explicit installation.en.md keeps the author's choice, because sometimes you really do mean "the English one".

And when the translation does not exist yet? SSG does not silently substitute another language. It leaves the link alone and warns, naming the link and the language — once per pair, so a missing translation does not flood the build log with one line per page that links to it. Falling back is opt-in (i18n.content_fallback: true), because "quietly send Polish readers to English" is a policy, not a default.

4. The tags nobody enjoys writing

hreflang is tedious, easy to get subtly wrong, and invisible when it is wrong. It is exactly the kind of thing a build step should own.

Given the translation keys, SSG already knows every language a page exists in, so it emits the alternates itself: XHTML language alternates and x-default in the sitemap, Open Graph locale metadata and JSON-LD inLanguage when SEO injection is on. The template side is a loop if you want a language switcher:

{{ range .Page.Translations }}
  <a href="{{ .URL }}" hreflang="{{ .Lang }}"
     {{ if .IsCurrent }}aria-current="page"{{ end }}>{{ .Lang }}</a>
{{ end }}

Note IsCurrent. A language switcher that renders the current language as a link to itself is the small detail that makes a site feel unfinished, and it is one if away.

5. Interface strings are not content

Content lives in Markdown. "Read more", "Published on", "5 minutes" do not — they belong to the theme, and putting them in content files means every translated page carries a copy of the furniture.

1# i18n/pl.yaml
2navigation:
3  home: Strona główna
4post:
5  reading_time: " minut"
{{ t "navigation.home" }}
{{ t "post.reading_time" (dict "count" .ReadingTime) }}

One deliberate limitation: catalog values are never executed as templates. Only named placeholders are substituted, and the result comes back as an ordinary string subject to normal escaping. A translation file is often the one file in a project edited by someone who is not a developer, sometimes pasted from a translation service. It should not be able to run code.

6. Failing before rendering, not after

The part I would defend hardest is the least visible: the whole language configuration is validated before any page renders.

Duplicate (translation_key, lang) pairs. Unknown language codes. Cycles in the fallback chain. Invalid timezones. Two pages that would write to the same output path.

Every one of these has a "keep going and see" version, and every one of those produces a site that builds successfully and is wrong — the worst possible outcome, because nothing tells you. A fallback cycle in particular would either hang or silently pick an arbitrary language, and you would find out from a reader.

The mode is yours where the answer is genuinely a judgement call:

1missing_translation: warn   # error, warn, fallback, empty
2invalid_language: fail      # fail, warn
3duplicate_translation: fail # fail, warn

A missing translation is a content state — you are mid-way through translating, and the build should let you work. A duplicate key is a mistake. The defaults say so.

What it actually costs

A single-language site pays nothing: none of this switches on until languages: has more than one entry, and the compact form is two lines.

1languages: [pl, en]
2default_language: pl

From there, each translated page needs lang and — if the slugs differ, which they should — a translation_key. The alternates, the sitemap entries, the locale metadata and the language-aware links follow from that.

The full reference is in internationalisation. The one idea worth carrying over from this post: decide what makes two files the same page, and write it down. Every other multilingual problem is downstream of that question, and the tooling can only be as right as your answer.