When I migrated my blog from WordPress to Hugo, the content was all in English. Simple. One language, a handful of posts, done in a day.
Then I looked at help.pdf-pro.net—the help center for my PDF app. Dozens of articles. Eleven languages. Hundreds of pages total. English, German, Spanish, French, Italian, Dutch, Portuguese, Japanese, Chinese, Korean, and Russian.
Spoiler: it was worth doing. But it took a few days instead of one, and I learned more about Hugo’s internals than I ever planned to.
The WordPress Multi-Language Problem
WordPress doesn’t natively support multiple languages. You need a plugin—WPML, Polylang, TranslatePress, pick your poison. Each one works differently, stores translations differently, and creates its own flavor of headaches.
The plugin I was using stored translations as separate posts linked by metadata. Every article in every language was its own row in wp_posts, connected through a proprietary relationship table that only made sense if you were that specific plugin.
Exporting this cleanly from a SQL dump required understanding not just WordPress’s schema but the plugin’s schema on top of it. Fun times.
Hugo’s Multi-Language Options
Hugo has built-in multi-language support, which is already a step up from WordPress. But there are two fundamentally different approaches to organizing translated content, and choosing the right one matters.
Option 1: Filename-Based Translations
content/articles/
├── getting-started.md # English
├── getting-started.de.md # German
├── getting-started.es.md # Spanish
├── getting-started.fr.md # French
└── ... 8 more language files
This is Hugo’s default approach. Same directory, language suffix in the filename. Hugo automatically links translations by matching the base filename.
With dozens of articles in 11 languages, that’s hundreds of files in a single directory. I tried it. The directory listing was a wall of text. Finding the German version of a specific article meant scrolling past ten other language versions. It was technically functional and practically miserable.
Option 2: Module Mounts
content/
├── articles/ # English
├── articles.de/ # German
├── articles.es/ # Spanish
├── articles.fr/ # French
├── articles.it/ # Italian
├── articles.ja/ # Japanese
├── articles.ko/ # Korean
├── articles.nl/ # Dutch
├── articles.pt/ # Portuguese
├── articles.ru/ # Russian
└── articles.zh/ # Chinese
Each language gets its own directory. Hugo’s module mount system maps them all to the same logical content path:
[[module.mounts]]
source = "../content/articles"
target = "content/articles"
lang = "en"
[[module.mounts]]
source = "../content/articles.de"
target = "content/articles"
lang = "de"
# ... repeat for each language
Same article slug in each directory. Hugo matches translations by slug across the mounted directories. Clean separation, easy to navigate, easy to diff between languages.
I went with option 2. No contest.
The Language Configuration
Hugo needs to know about every language upfront. The config gets verbose, but it’s straightforward:
defaultContentLanguage = "en"
defaultContentLanguageInSubdir = false # English at root, no /en/ prefix
[languages]
[languages.en]
languageName = "English"
weight = 1
[languages.de]
languageName = "Deutsch"
weight = 6
[languages.es]
languageName = "Español"
weight = 2
[languages.fr]
languageName = "Français"
weight = 3
# ... and so on for all 11 languages
The weight controls the order in the language switcher. Setting defaultContentLanguageInSubdir = false means English articles live at the root (/article-slug/) while all other languages get a prefix (/de/article-slug/, /es/article-slug/, etc.).
One decision that simplified everything: I kept article slugs identical across all languages. The English article at /getting-started/ becomes /de/getting-started/ in German, not /de/erste-schritte/. The language prefix already tells you which version you’re reading. Translating slugs would’ve meant maintaining a mapping table and rewriting every internal link per language. Not worth it.
Localized Taxonomy URLs
Here’s where it got interesting. I wanted category URLs to be localized—/topics/ in English, /themen/ in German, /temas/ in Spanish, /rubriques/ in French, and so on. Hugo supports this through per-language permalink configuration:
[languages.en.permalinks]
categories = "/topics/:slug/"
[languages.de.permalinks]
categories = "/themen/:slug/"
[languages.es.permalinks]
categories = "/temas/:slug/"
[languages.fr.permalinks]
categories = "/rubriques/:slug/"
For CJK languages (Japanese, Chinese, Korean) and Russian, I kept the English /topics/ path. Translating URL path segments into non-Latin scripts creates more problems than it solves—URL encoding, readability in the address bar, link sharing.
But there’s a catch. Hugo’s taxonomy system keys categories by their exact string value. An English article with categories: ["Getting Started"] and a German article with categories: ["Schnellstart"] are, as far as Hugo is concerned, two completely unrelated categories. Hugo doesn’t know that “Schnellstart” is the German translation of “Getting Started.”
The categories.toml Solution
This was the trickiest problem in the entire migration. Hugo’s built-in approach would have me create _index.md files for every category in every language—one per combination. Each file containing roughly the same information in a slightly different language.
Instead, I created a single data file—categories.toml—that centralizes everything:
[[categories]]
term = "getting started"
icon = "bi-rocket-takeoff"
translations = { de = "schnellstart", es = "empezando", fr = "commencer", it = "iniziare", nl = "beginnen", pt = "começando", ja = "入門", ko = "시작하기", ru = "начиная", zh = "入门" }
[[categories]]
term = "file management"
icon = "bi-folder"
translations = { de = "datei management", es = "gestión de archivos", fr = "gestion de fichiers", it = "gestione file", nl = "bestandsbeheer", pt = "gestão de arquivos", ja = "ファイル管理", ko = "파일 관리", ru = "управление файлами", zh = "文件管理" }
One file. All translations. All icons. All metadata. My templates read from this file to resolve category names, build localized URLs, and generate the category grid on the homepage.
The alternative—a separate _index.md file for every category in every language, scattered across the content directory—would have been tedious to maintain. Add a new language? Create a file for every category. Add a new category? Create a file for every language. With the data file approach, it’s one new line in one file.
i18n for Interface Strings
Article content is translated per-file. But what about the interface—buttons, labels, navigation, breadcrumbs? Hugo handles this with i18n string files.
One TOML file per language in hugo/i18n/:
# en.toml
[heroTitle]
other = "How can we help?"
[popularArticles]
other = "Popular Articles"
[searchPlaceholder]
other = "Search the User Guide..."
[articles_count]
one = "{{ .Count }} article"
other = "{{ .Count }} articles"
# de.toml
[heroTitle]
other = "Wie können wir behilflich sein?"
[popularArticles]
other = "Beliebte Artikel"
[searchPlaceholder]
other = "Im Benutzerhandbuch suchen..."
[articles_count]
one = "{{ .Count }} Artikel"
other = "{{ .Count }} Artikel"
Templates use {{ i18n "heroTitle" }} and Hugo automatically picks the right string for the current language. The one/other distinction handles pluralization—English has different forms for “1 article” vs “5 articles,” though some languages (like German) use the same word for both.
The Language Switcher
Building a language switcher that actually works is harder than it sounds. You need it to:
- Link to the same article in the target language (if a translation exists)
- Link to the translated category page (if on a category page)
- Fall back to the language homepage (if neither applies)
Hugo provides .Translations on every page—an array of the same content in other languages. For articles, this works perfectly:
{{ range .Translations }}
<a href="{{ .RelPermalink }}">{{ .Language.LanguageName }}</a>
{{ end }}
For category pages, it gets complicated. Hugo doesn’t automatically translate taxonomy terms. I had to write a helper partial that takes a category name in the current language, looks it up in categories.toml, finds the translation in the target language, and constructs the correct URL with the localized path segment.
It’s about 30 lines of Go template code. Not elegant, but it works for every language combination.
Per-Language Search
The blog migration used a single SQLite FTS5 database for search. With eleven languages, I needed eleven databases—one per language, indexed from each language’s content directory.
The build script loops through all languages:
LANGUAGES=("en" "de" "es" "fr" "it" "ja" "ko" "nl" "pt" "ru" "zh")
for lang in "${LANGUAGES[@]}"; do
if [ "$lang" = "en" ]; then
content_dir="content/articles"
else
content_dir="content/articles.$lang"
fi
hugo-search-indexer \
--content "$content_dir" \
--output "$TEMP_DIR/data/search-$lang.sqlite"
done
The frontend detects the current page language from the <html lang="..."> attribute and queries the matching database. A German user searching on the German version of the site searches the German index. Simple, fast, and the results are always in the right language.
WordPress Redirect Mapping
The old WordPress site used a multi-language plugin that put language codes in the URL. Those URLs were indexed by search engines, bookmarked by users, and linked from forums. Breaking them wasn’t an option.
The .htaccess redirect rules handle three patterns:
# Old WordPress plugin URL structure
RedirectMatch 301 "^/de/tutorials/(.+)$" "/de/$1"
RedirectMatch 301 "^/fr/tutorials/(.+)$" "/fr/$1"
# Old category structure
RedirectMatch 301 "^/category/(.+)$" "/topics/$1"
RedirectMatch 301 "^/de/kategorie/(.+)$" "/de/themen/$1"
# Legacy query parameter (WordPress tab navigation)
RewriteRule ^(.+)\?tab=(.+)$ /$1 [R=301,L,QSD]
Every old URL pattern I could find got a redirect rule. I crawled the old sitemap, checked Google Search Console for indexed URLs, and tested every redirect manually.
What This Architecture Handles
The finished setup generates hundreds of HTML pages from hundreds of Markdown files. Every page has:
- Correct
hreflangtags pointing to all other language versions - Localized navigation, breadcrumbs, and interface strings
- Language-specific search
- Proper canonical URLs
- Schema.org structured data in the correct language
Adding a new article means creating one Markdown file per language in the matching directories. Adding a new language means adding a content directory, an i18n file, and a config section—no changes to templates.
The Timeline
The migration took a few focused days. The first day was Hugo project setup, template building, and getting the English version rendering. The second day was where most of the complexity lived—multi-language config, module mounts, i18n strings, the language switcher, and the category translation system. The last stretch was search indexing, redirect mapping, SEO metadata, testing, and deployment.
The multi-language taxonomy problem alone took a solid chunk of time to solve properly. But a few days for a fully localized site with eleven languages—I’ll take that over maintaining a WordPress plugin stack.
Lessons Learned
Module mounts beat filename conventions at scale. With 11 languages, the filesystem organization matters. Separate directories per language is the way to go.
Centralize translation data. The categories.toml approach saved me from maintaining 99 files. Any time you find yourself creating the same file structure for every language, consider a data file instead.
Keep slugs consistent across languages. The temptation to translate URL slugs is strong. Resist it. The complexity it introduces—mapping tables, per-language link generation, redirect chains—isn’t worth the marginal SEO benefit.
Test the language switcher obsessively. Every page type (article, category, homepage) needs different fallback logic. Test with missing translations, edge-case categories, and deep-linked URLs.
Search needs per-language indexes. A single multilingual index returns mixed-language results that confuse users. Separate indexes per language are more work to build but dramatically better in practice.
Series Wrap-Up
This is the final post in my WordPress to Hugo migration series. We went from ranting about WordPress bloat, through the practical migration steps, into building search with SQLite FTS5, and now the deep end of multi-language static sites.
If you’re running a WordPress site and thinking about making the switch—especially a multi-language one—I hope this series gave you a realistic picture of what’s involved. It’s not trivial, but it’s not rocket science either. It’s just careful, methodical work. And the result is a stack you actually understand, because you built every piece of it yourself.