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.

How Hugo renders a multilingual site A single configuration declares four languages. Hugo builds one site per language from its own content directory. English renders to the public root, German to public/de, French to public/fr and Japanese to public/ja. Pages sharing a translationKey are linked across the passes. One Hugo build, four site passes hugo.toml four languages declared one config file content/en, content/de, content/fr, content/ja one directory per language translators own their tree Four site passes menus, taxonomies, i18n tables per language 34.1 s total for 2,560 pages public/, public/de/, public/fr/, public/ja/ linked by translationKey plus a sitemap index
Each language is a complete site pass with its own menus, taxonomies and sitemap; translations are linked across passes by key.

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.

Hugo multilingual features and what they cover A matrix of features. Content directories per language, handled natively. Translation linking by translationKey, native. Menus per language, native. Interface strings with plurals, native through i18n tables. Localized dates, native through time.Format. Sitemap per language with an index, native. Browser language redirect, not handled and needs an edge function. What Hugo localizes for you Concern Hugo support Where configured Content per language Native contentDir per language Translation linking Native translationKey front matter Menus Native languages.xx.menus UI strings and plurals Native i18n/xx.toml Localized dates Native time.Format :date_long Sitemap per language Native automatic sitemap index Browser language redirect Not handled edge function on the root URL
Everything except request-time language detection is built in; that one needs a few lines at the edge.

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.

Hugo build time per number of languages Vertical bars of median build time. One language 9.8 seconds, two languages 18.9 seconds, three languages 27.6 seconds, four languages 41.7 seconds before caching, four languages 34.1 seconds after caching the sidebar partial per section and language. Build time as languages were added 9.8 s 1 language 18.9 s 2 languages 27.6 s 3 languages 41.7 s 4 languages 34.1 s 4 with partialCached
Median of five hyperfine runs on an 8-vCPU runner with a warm resources cache.

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

MeasureResult
Pages built (4 languages)2,560
Build time, median of five runs34.1 s
Custom template code for i18n38 lines (switcher, hreflang partial)
Missing UI strings caught by --printI18nWarnings at first run26
URLs changed for English0
Sitemap files1 index + 4 language sitemaps

Pitfalls & Rollback

  • partialCached without 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.
  • defaultContentLanguageInSubdir flipped late. Moving English to /en/ after launch changes every URL; decide before going live.
  • Per-language baseURL by accident. Setting baseURL inside 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. Use relref or ref shortcodes, 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.

Filed underHow-tosHugo