Going Multilingual: i18n for My Blog

Writing a tech blog has an awkward problem: Chinese content serves domestic readers, but there’s also considerable overseas traffic. So I went with both languages.

This blog is bilingual, Chinese and English. The same article has both versions, each with its own URL, independent of each other.

i18n has two parts: content internationalization (articles in both languages) and UI internationalization (buttons, menus, text switches with language).

Content Internationalization

Directory Structure

Using Hugo’s module.mounts for language mapping:

module:
  mounts:
    - source: content/en
      target: content
      lang: en
    - source: content
      target: content
      lang: zh
      excludeFiles: 'en/**'

English content comes from content/en, Chinese content from content with en/ excluded. They don’t interfere with each other, each in their own directory.

Chinese articles are in content/archive/, English in content/en/archive/.

translationKey Pairing

Using Hugo’s translationKey to pair articles. Same key for both language versions:

# Chinese article
translationKey: zh0189
title: Git系列之关于add命令的一些事
slug: git-series-about-add-command

# English article
translationKey: zh0189
title: Git Series: About the Add Command
slug: git-series-about-add-command

Hugo automatically recognizes these as translation pairs. In templates, use .Translations to get the other language version for language switching.

I wrote a script to verify pairing correctness:

#!/usr/bin/env python3
import os, sys

def get(path, field):
    with open(path) as f:
        for line in f:
            if line.startswith(field + ':'):
                return line.split(':',1)[1].strip().strip('\"\'')
    return None

en_by_key = {}
for root, dirs, files in os.walk('content/en/archive'):
    for f in files:
        if not f.endswith('.md'): continue
        path = os.path.join(root, f)
        key = get(path, 'translationKey')
        slug = get(path, 'slug') or f.replace('.md', '')
        if key:
            en_by_key[key] = {'slug': slug, 'path': path}

zh_by_key = {}
for root, dirs, files in os.walk('content/archive'):
    for f in files:
        if not f.endswith('.md'): continue
        path = os.path.join(root, f)
        key = get(path, 'translationKey')
        slug = get(path, 'slug')
        if key:
            zh_by_key[key] = {'slug': slug, 'path': path}

matched = 0
errors = []
for key, en in en_by_key.items():
    if key in zh_by_key:
        matched += 1
        zh = zh_by_key[key]
        if en['slug'] != zh['slug']:
            errors.append(f'SLUG [{key}]: EN={en["slug"]} ZH={zh["slug"]}')

zh_orphans = [k for k in zh_by_key if k not in en_by_key]
en_orphans = [k for k in en_by_key if k not in zh_by_key]

print(f'Pairs: {matched}, Slug mismatch: {len(errors)}')
print(f'Orphans: ZH={len(zh_orphans)} EN={len(en_orphans)}')

if errors or zh_orphans or en_orphans:
    sys.exit(1)
print('All OK')

Run it and you’ll know which articles are not paired and which slugs don’t match.

One gotcha: if translationKey is misspelled, Hugo won’t complain. The two articles just won’t be paired. The language switcher won’t show the other language version. That’s why I wrote this script to check regularly.

URL Strategy

Chinese: /zh/archive/<slug>/ English: /en/archive/<slug>/

Slugs are consistent across languages. Old URL compatibility in static/_redirects:

/archive/*  /zh/archive/:splat  301

Content Organization

Articles organized by category in subdirectories:

content/archive/
  Git/
    Git系列之关于add命令的一些事.md
  React/
    React系列之JSX.md

content/en/archive/
  Git/
    git-series-about-add-command.md
  React/
    react-series-jsx.md

Both languages share the same category structure, 43 categories total.

UI Internationalization

i18n Files

UI text uses Hugo’s i18n system. i18n/en.toml and i18n/zh.toml:

# i18n/zh.toml
[home]
other = "首页"

[archive]
other = "归档"

[tags]
other = "标签"

[about]
other = "关于"

[articleLink]
other = "文章链接:"

[latestArticles]
other = "# 最新文章"

[noPostsFound]
other = "此标签暂无文章。"
# i18n/en.toml
[home]
other = "Home"

[archive]
other = "Archive"

[tags]
other = "Tags"

[about]
other = "About"

[articleLink]
other = "Article Link:"

[latestArticles]
other = "# Latest Articles"

[noPostsFound]
other = "No posts found for this tag."

Template Usage

Templates use {{ i18n "key" }}:

<h2>{{ i18n "articleLink" }}</h2>
<a href='{{ .RelPermalink }}'>{{ .RelPermalink }}</a>

Hugo automatically picks the right text based on current language. If a key is missing, it displays the key itself.

Language Switcher

The language switcher in the nav bar adapts based on translation status:

<li class="lang-switcher has-dropdown">
  <a href="#">{{ .Site.Language.LanguageName }}</a>
  <ul class="dropdown">
    {{ range $.Site.Home.AllTranslations }}
    <li>
      <a href="{{ .Permalink }}">{{ .Language.LanguageName }}</a>
    </li>
    {{ end }}
    {{ if .IsTranslated }}
    {{ range .Translations }}
    <li>
      <a href="{{ .Permalink }}">{{ .Language.LanguageName }}</a>
    </li>
    {{ end }}
    {{ end }}
  </ul>
</li>

Configuration

Hugo’s language config in config.yml:

defaultContentLanguage: "en"
defaultContentLanguageInSubdir: true

languages:
  en:
    weight: 1
    locale: en-GB
    label: English
    title: Time Friend
    menu:
      main:
        - identifier: home
          name: Home
          url: /en/
  zh:
    weight: 2
    locale: zh-CN
    label: 中文
    title: Time Friend
    menu:
      main:
        - identifier: home
          name: 首页
          url: /zh/

Key points:

  1. defaultContentLanguageInSubdir: true — both languages have prefixes, no bare URLs
  2. Each language has its own params, including description and menu
  3. Locale difference: en-GB vs zh-CN, affects Open Graph and other places

Performance

i18n has minimal impact on build performance. module mounts is just file system mapping, no runtime cost. i18n text is compiled in at build time.

200+ articles in both languages, build time is still seconds. Overhead is negligible.

One thing to watch: excludeFiles: 'en/**' must be correct, otherwise the Chinese build will include English content, doubling article count. It took me half a day to figure this out when I first set it up.

Article Link:

/en/archive/blog-architecture-i18n/

# Related Articles