Multilingual Sites with Hugo
Hugo has the most complete multilingual model of any mainstream static site generator. Languages are declared in configuration, each renders as a full site of its own, pages are linked to their translations automatically, menus and taxonomies are per language, and interface strings come from translation tables with plural support. The result is that a multilingual Hugo site needs very little custom code — most of the work is choosing a content layout that suits the people doing the translating.
This guide sets up a documentation site in English, German, French and Japanese with per-language content directories, translation keys, translated menus and strings, and CI checks that keep the locales honest. It is part of Internationalization for Static Sites; for the build-speed side of large Hugo sites see Hugo Build Times for Large Repositories.
Prerequisites
- Hugo extended 0.123 or newer (the examples were measured on 0.134.3).
- A single-language site whose content is in
content/. - A decision on URL structure — here English stays at the root and other languages use
/de/,/fr/,/ja/.
Step 1: Declare the Languages
# hugo.toml
baseURL = "https://docs.example.com/"
defaultContentLanguage = "en"
defaultContentLanguageInSubdir = false
[languages]
[languages.en]
weight = 1
languageName = "English"
languageCode = "en"
contentDir = "content/en"
[languages.de]
weight = 2
languageName = "Deutsch"
languageCode = "de"
contentDir = "content/de"
[languages.fr]
weight = 3
languageName = "Français"
languageCode = "fr"
contentDir = "content/fr"
[languages.ja]
weight = 4
languageName = "日本語"
languageCode = "ja"
contentDir = "content/ja"
hasCJKLanguage = true
Move the existing content/* into content/en/. With defaultContentLanguageInSubdir = false, English URLs do not change. hasCJKLanguage = true makes .WordCount and .Summary count Japanese correctly, since there are no spaces to split on.
Step 2: Link Translations With translationKey
Hugo links pages whose paths match relative to their content directory. That breaks as soon as translators localize file names, so set a key:
# content/de/guide/anleitung-installation.md
---
title: Installation
translationKey: install
slug: installation
---
Templates then use .Translations (other languages) or .AllTranslations (including the current one). The hreflang partial built on .AllTranslations is in hreflang and Language Alternates on Static Sites. A language switcher is almost the same loop:
<nav aria-label="{{ i18n "languageNav" }}">
<ul>
{{ range .AllTranslations }}
<li><a href="{{ .RelPermalink }}" lang="{{ .Language.Lang }}" hreflang="{{ .Language.Lang }}"
{{ if eq .Language.Lang $.Language.Lang }}aria-current="true"{{ end }}>{{ .Language.LanguageName }}</a></li>
{{ end }}
</ul>
</nav>
Step 3: Menus and Strings Per Language
Menus can be defined per language in configuration or in each page's frontmatter. Configuration keeps the order consistent:
[languages.de.menus]
[[languages.de.menus.main]]
name = "Anleitungen"
pageRef = "/guide"
weight = 10
pageRef resolves to the German section automatically, so links stay correct even if the German section has a localized slug.
Interface strings go in i18n/<lang>.toml, with plural forms where needed:
# i18n/de.toml
[readingTime]
one = "{{ .Count }} Minute Lesezeit"
other = "{{ .Count }} Minuten Lesezeit"
[editThisPage]
other = "Diese Seite bearbeiten"
In templates, {{ i18n "readingTime" .ReadingTime }} selects the right plural. Dates use time.Format with the language's locale, so {{ .Date | time.Format ":date_long" }} prints "25. September 2026" on German pages.
Step 4: Keep Locales Honest in CI
Three checks run on every pull request.
# .github/workflows/build.yml (excerpt)
- name: Build and report missing strings
run: |
hugo --gc --minify --printI18nWarnings 2>&1 | tee build.log
if grep -q "i18n|MISSING_TRANSLATION" build.log; then
echo "::error::Missing UI translations"; grep "MISSING_TRANSLATION" build.log; exit 1
fi
- name: Check hreflang groups
run: node scripts/check-hreflang.mjs
- name: Report stale translations
run: node scripts/stale-translations.mjs >> "$GITHUB_STEP_SUMMARY"
The stale-translation report compares each translated page's recorded sourceCommit with the latest commit touching its English source, as described in Translation Workflows with Git and Crowdin. It is a report rather than a failure, because stale pages are a content backlog, not a broken build.
Step 5: Watch the Build Time
Each language is a site pass, so build time grows with locales. Profile with hugo --templateMetrics --templateMetricsHints after adding languages: partials that were cheap once become four times as expensive. On this site, caching the sidebar partial per section and language with partialCached cut the four-language build from 41.7 s to 34.1 s — the technique is covered in Hugo partialCached for Faster Builds. Include the language in the cache key: {{ partialCached "sidebar.html" . .Section .Language.Lang }}, or German pages will show the English sidebar.
Step 6: Untranslated Pages, Shared Assets and Taxonomies
Three details decide how the multilingual site behaves day to day.
Untranslated pages. A page that exists only in content/en/ simply does not exist in the German site pass, and .IsTranslated is false. That is usually right: the German sidebar does not list it, no alternate points at it, and nothing 404s. If a German section should still link to the English original, render the link explicitly with site.Sites.Default and label it with the language: "Troubleshooting (English)". Resist the option of copying English files into the German tree to fill gaps; each copy becomes a German URL with English text, and the stale-translation report can no longer tell a copy from a translation.
Shared images and assets. Screenshots often contain UI text and so need per-language versions, while diagrams and photos usually do not. Page bundles make this manageable: put language-neutral images in the English bundle and reference them with .Resources via the default site, and put localized screenshots next to the translated page. Hugo's [module] [[module.mounts]] configuration can also mount a shared assets/ directory into every language so logos and icons exist once in the repository but are available in every site pass.
Taxonomies. Tags are per language. A German page tagged Installation and an English page tagged installation produce separate term pages in separate sites, which is correct, but a tag cloud that should be the same across languages needs the tags translated like any other string. Keep the tag vocabulary small and define display names in the i18n tables, with the tag keys themselves left in English in frontmatter. That keeps term URLs stable for all languages (/de/tags/installation/) while the page shows "Installation" or "インストール" as appropriate.
Measured Impact
| Measure | Result |
|---|---|
| Pages built (4 languages) | 2,560 |
| Build time, median of five runs | 34.1 s |
| Custom template code for i18n | 38 lines (switcher, hreflang partial) |
Missing UI strings caught by --printI18nWarnings at first run | 26 |
| URLs changed for English | 0 |
| Sitemap files | 1 index + 4 language sitemaps |
Pitfalls & Rollback
partialCachedwithout the language in the key. Serves one language's markup to all languages.- Forgetting
hasCJKLanguage. Japanese summaries become the whole page and reading times are wrong. - Mixing directory and suffix layouts. Hugo supports both, but using both in one site makes translation linking hard to predict.
defaultContentLanguageInSubdirflipped late. Moving English to/en/after launch changes every URL; decide before going live.- Per-language
baseURLby accident. SettingbaseURLinside a language block turns that language into a separate host; only do it deliberately for country domains, and check that the certificate and CDN cover it. - Relative links in Markdown. A link written as
/guide/in German content points at the English page. Userelreforrefshortcodes, or a render hook that resolves links within the current language, so translated pages link to translated pages. - Rollback: languages are configuration. Removing a language block drops that site pass and its output directory; the English site is unaffected.
Conclusion
Hugo makes a multilingual site mostly a matter of configuration: declare languages, give translators their own content directories, link translations with a key, and put strings in i18n tables. Add three CI checks — missing strings, hreflang reciprocity, stale translations — and watch template cost as each language multiplies it. The four-language documentation site needed 38 lines of custom template code and still builds in about half a minute.
FAQ
Should I use per-language directories or filename suffixes in Hugo?
Per-language content directories scale better for teams because translators work in a separate tree and a translation management system can map one directory per locale. Filename suffixes such as install.de.md keep translations next to their source, which suits small sites maintained by one person.
How does Hugo know two pages are translations of each other?
By default, pages with the same path relative to their language's content directory are linked. Setting translationKey in frontmatter overrides that, so translations can have different file names and slugs and still be linked.
Does each language slow the Hugo build down?
Yes, roughly linearly, because Hugo renders each language as its own site. On a 640-page documentation site, four languages took the build from 9.8 seconds to 34.1 seconds, which is still fast enough for every pull request.
How do I find untranslated UI strings in Hugo?
Run hugo with the --printI18nWarnings flag. It lists every i18n key that is missing for a language, and you can fail CI if the output is not empty.
Related
- Parent: Internationalization for Static Sites — the wider i18n decisions.
- i18n Routing in Astro — the equivalent setup in Astro.
- hreflang and Language Alternates on Static Sites — alternates from
.AllTranslations. - Profiling Hugo Templates with Template Metrics — finding partials multiplied by languages.
- Indexing Hugo Sites with Pagefind — per-language search indexes.