Designing a multilingual site — holding text in three kinds

Part 3 of 5 — series: Building a publishing tool, and shipping it. Last time was contracting the output. See the series index. This time: how to design multilingual support.

When you hear “make the site multilingual,” translating the body comes to mind first. But once you actually do it, the body isn’t the only text that needs translating. Nav labels, a profile, config values — each has a different nature, and forcing them into one container strains.

What worked was splitting the text by kind and holding each in a way that fits. That’s this part. I’ll use Hugo (what crofty runs inside) as the example.

Text comes in three kinds

Sort the text on a multilingual site and you get roughly three kinds. Where they live, and how they’re translated, all differ.

Kind Example How it’s held
body posts one file per language
fixed strings nav, buttons, “Support” a language → string translation table
config / data site settings, profile per field: “shared” or “per language”

One at a time.

Body — one file per language

A post keeps per-language files side by side in a single folder (a page bundle).

content/posts/my-article/
  index.md       … Japanese (default language)
  index.en.md    … English
  photo.avif     … a bundled image

Build it and you get both /posts/my-article/ and /en/posts/my-article/; switching language is just a link between the pages. No JavaScript needed.

There’s one small trap. Images (non-page files) are only output to the default-language path, so a relative reference from the English version 404s. Fix it by having the English version point at the default-language path with an absolute path.

![figure](photo.avif)                     <!-- Japanese: relative is fine -->
![figure](/posts/my-article/photo.avif)   <!-- English: point with an absolute path -->

Fixed strings — a translation table

Fixed wording that doesn’t depend on any post — nav and button labels — isn’t mixed into the body. It’s held together in a “translation table” that looks up a string by language.

In crofty, these fixed strings are prepared per language, so the language names (“日本語”, “English”) and the support-link labels (“サポートする”, “Support”) come out per language. Even before you’ve written a single post, the site’s frame is ready in both languages.

Config and data — choose per field

Config files, and structured data like a profile, are multilingual too. But values are mixed: some are the same across languages, some you want to vary by language. So you let each field be “just a string” or “a per-language value.”

For a config file (hugo.yaml), you split per-language settings into blocks. Values that change by language — like the site title — go here.

# hugo.yaml — config can hold per-language values too
languages:
  ja:
    title: "わたしのブログ"
  en:
    title: "My Blog"

For a data file (data/profile.yml), you choose string or map per field.

support:
  # the wording is per language → hold it as a map
  message:
    ja: "記事が役に立ったら、活動を応援していただけると励みになります。"
    en: "If these posts helped, a little support keeps the work going."
  # the URL is the same for every language → a plain string
  stripe: "https://donate.stripe.com/..."

message is per language, so it’s a map; stripe is shared, so it’s a string. Either way, the idea is the same: split only the values that change by language.

Delivery — build everything, stay static

Split into these three, and delivery is simple. At build time you generate the pages for every language, then just serve static files. No per-request decision, no client-side JavaScript.

The one separate problem is “I want to auto-route by the browser’s language.” That can’t be decided at build time, so doing it means adding a step at the edge (delivery) or in the client (JavaScript). If you’d rather not add JavaScript, don’t auto-route — let readers choose with a language switcher. That’s crofty’s stance for now.

In short

Going multilingual wasn’t only translating the body. Split text into the three kinds — body, fixed strings, config/data — and pick the container that fits each. Design that up front, and the build hands you every language.

And because each layer stays fully static, the speed and the no-JS stay just as they were.


← Previous: Contract the output | Next: Shipping a CLI with brew and scoop