I’ve made this blog built with Hugo multilingual. For now, it’s only available in English.

Why I implemented multilingual support

As part of my OSS activities at work, I often cross-post translated articles to platforms like dev.to and Medium. Recently, I’ve been casually translating and cross-posting articles on my own as well.

For example, the English translation of [2025-08-15-1] was cross-posted to these three sites:

  • dev.to - This served as the original English version
  • Hashnode - Set dev.to’s article as the canonical version
  • Medium - Set dev.to’s article as the canonical version

However, since I’m maintaining this blog masutaka.net, I thought it would be better to store the original English articles here and cross-post them to the above three sites. Setting the canonical URL to my own blog seems like it would also be beneficial for SEO.

Hugo’s Multilingual Support

Hugo includes built-in multilingual support with simple configuration. All you need to do is add the following settings in your config.toml file:

defaultContentLanguage = "ja"

[languages.ja]
  weight = 1
  languageName = "日本語"
  title = "マスタカの ChangeLog メモ"

[languages.en]
  weight = 2
  languageName = "English"
  title = "Masutaka's ChangeLog Memo"

Articles with .en.md in their filenames will be the English versions.

  • Japanese version: content/posts/2025-08-15-1.md
  • English version: content/posts/2025-08-15-1.en.md

With this configuration, articles can be accessed via the following URLs:

  • Japanese version: https://masutaka.net/2025-08-15-1/
  • English version: https://masutaka.net/en/2025-08-15-1/

Multilingual Implementation Strategy

When implementing multilingual support, I followed this approach:

1. Maintain Existing URLs Unchanged

The most important policy was to absolutely preserve all existing URLs. Given that this blog has been operational for over 20 years, any changes to URLs would cause numerous broken links.

Therefore, I kept the Japanese version as is and added an /en/ prefix only for the English version.

  • /index.html - Almost unchanged, with /en/index.html added
  • /index.xml - Unchanged, with /en/index.xml added
  • /sitemap.xml - Changed to reference /ja/sitemap.xml and /en/sitemap.xml
  • /llms.txt, /llms-full.txt - Unchanged, with /en/llms.txt and /en/llms-full.txt added

👉 The behavior described above is achieved through the configuration settings for defaultContentLanguage, [languages.ja], and [languages.en].

2. Keep English Menu Options in the Top Right Minimal

The Device, History, and About pages contain content that is primarily Japanese-specific with limited value in translation, so I decided to skip them this time.

Implementation Details

Here’s an overview of the actual changes.

1. Modifications to config.toml

Added language configuration settings and corresponding menu settings for each language.

👉 The diff and the updated config.toml are attached in the Appendix .

2. Creating English versions of static pages

  • content/archives.en.md - Archives page
  • content/search.en.md - Search page
  • content/privacy.en.md - Privacy Policy

⚠️ Initially, I attempted to handle this by creating symbolic links from *.en.md to *.md, but Hugo did not recognize these links.

3. Modifying custom shortcodes

Updated the custom post shortcode to accept an lang argument.

layouts/shortcodes/post.html:

{{- $id := .Get "id" | default (.Get 0) -}}
{{- $lang := .Get "lang" | default .Page.Lang -}}
{{- $url := relref . (dict "path" $id "lang" $lang) -}}
{{- $title := .Get "title" -}}

<a href="{{ $url }}">{{ with $title }}{{ $title }}{{ else }}[{{ $id }}]{{ end }}</a>

Example usage:

<!-- If you specify an article ID as an argument, it generates a link in the current language -->
{{< post "2025-09-23-1" >}}
<!-- -> If the current language is English, this will generate a link to /en/2025-09-23-1/ -->

<!-- Adding the 'lang' argument creates an explicit link to another language -->
{{< post id="2025-09-23-1" lang="ja" >}}
<!-- -> If the current language is English, this will generate a link to /2025-09-23-1/ -->

4. Translation Support for Custom Partials

Since some translations were needed in layouts/partials/*.html, I created i18n/ja.toml and i18n/en.toml files.

Usage example:

<a href="https://example.com/" target="_blank" rel="noopener">{{ i18n "sendMessage" }}</a>

i18n/ja.toml:

sendMessage = "メッセージ送信"

i18n/en.toml:

sendMessage = "Send Message"

Conclusion

I successfully added multilingual support to this Hugo-built blog. The implementation was cleaner than I expected 👍

  • All existing URLs remained unchanged, limiting the impact to the /en/ directory
  • Achieved using only Hugo’s standard features with minimal customization
  • Reduced maintenance costs by keeping the English menu minimal
  • sitemap.xml and llms.txt were automatically multilingualized

Moving forward, I’ll translate new articles as needed and use https://masutaka.net/en/ as the original source while cross-posting to dev.to, Hashnode, and Medium.

While I’m not sure if there’s actually an English-speaking audience, I’m satisfied with the result. With recent AI translation being fairly accurate, it’s not that difficult anyway 😎

References

Appendix

Changes to config.toml

Show details
diff --git a/config.toml b/config.toml
index 0830be8b..a6bae12c 100644
--- a/config.toml
+++ b/config.toml
@@ -7,7 +7,6 @@ googleAnalytics = "G-K28CQCC064"
 hasCJKLanguage = true
 languageCode = "ja"
 theme = "papermod"
-title = "マスタカの ChangeLog メモ"
 
 [permalinks]
   posts = "/:filename"
@@ -27,14 +26,12 @@ title = "マスタカの ChangeLog メモ"
   isPlainText = true
   mediaType = "text/plain"
   rel = "alternate"
-  root = true
 
 [outputFormats.llmsfull]
   baseName = "llms-full"
   isPlainText = true
   mediaType = "text/plain"
   rel = "alternate"
-  root = true
 
 #
 # papermod configuration
@@ -51,14 +48,9 @@ title = "マスタカの ChangeLog メモ"
   author = "masutaka"
   comments = true
   defaultTheme = "auto"
-  description = "マスタカの変更履歴が記録されていくブログです。"
   showtoc = true
   tocopen = true
 
-[params.homeInfoParams]
-  Title = "マスタカネット"
-  Content = "マスタカの変更履歴が記録されていくブログです。"
-
 [params.assets]
   theme_color = "#ffffff"
   msapplication_TileColor = "#da532c"
@@ -79,46 +71,81 @@ title = "マスタカの ChangeLog メモ"
 [[params.socialIcons]]
   name =  "GitHub"
   url = "https://github.com/masutaka"
-[[params.socialIcons]]
-  name =  "Dev"
-  url = "https://dev.to/masutaka"
-[[params.socialIcons]]
-  name =  "Hashnode"
-  url = "https://masutaka.hashnode.dev/"
-[[params.socialIcons]]
-  name =  "Medium"
-  url = "https://medium.com/@masutaka"
 [[params.socialIcons]]
   name =  "RSS"
   url = "/index.xml"
 
-[[menu.main]]
+#
+# Multilingual
+#
+
+[languages.ja]
+  weight = 1
+  languageName = "日本語"
+  title = "マスタカの ChangeLog メモ"
+
+[languages.ja.params]
+  description = "マスタカの変更履歴が記録されていくブログです。"
+
+[languages.ja.params.homeInfoParams]
+  Title = "マスタカネット"
+  Content = "マスタカの変更履歴が記録されていくブログです。"
+
+[[languages.ja.menu.main]]
   identifier = "archives"
   name = "Archive"
   url = "/archives/"
   weight = 1
-[[menu.main]]
+[[languages.ja.menu.main]]
   identifier = "tags"
   name = "Tags"
   url = "/tags/"
   weight = 2
-[[menu.main]]
+[[languages.ja.menu.main]]
   identifier = "search"
   name = "Search"
   url = "/search/"
   weight = 3
-[[menu.main]]
+[[languages.ja.menu.main]]
   identifier = "device"
   name = "Device"
   url = "/device/"
   weight = 4
-[[menu.main]]
+[[languages.ja.menu.main]]
   identifier = "history"
   name = "History"
   url = "/history/"
   weight = 5
-[[menu.main]]
+[[languages.ja.menu.main]]
   identifier = "about"
   name = "About"
   url = "/about/"
   weight = 6
+
+[languages.en]
+  weight = 2
+  languageName = "English"
+  title = "Masutaka's ChangeLog Memo"
+
+[languages.en.params]
+  description = "This is a blog that records Masutaka's change history."
+
+[languages.en.params.homeInfoParams]
+  Title = "Masutaka Net"
+  Content = "This is a blog that records Masutaka's change history."
+
+[[languages.en.menu.main]]
+  identifier = "archives"
+  name = "Archive"
+  url = "/en/archives/"
+  weight = 1
+[[languages.en.menu.main]]
+  identifier = "tags"
+  name = "Tags"
+  url = "/en/tags/"
+  weight = 2
+[[languages.en.menu.main]]
+  identifier = "search"
+  name = "Search"
+  url = "/en/search/"
+  weight = 3

config.toml after multilingualization

Show details
baseURL = "https://masutaka.net/"
defaultContentLanguage = "ja"
disablePathToLower = true
enableEmoji = true
enableRobotsTXT = true
googleAnalytics = "G-K28CQCC064"
hasCJKLanguage = true
languageCode = "ja"
theme = "papermod"

[permalinks]
  posts = "/:filename"

[taxonomies]
  tag = "tags"

[markup.goldmark.renderer]
  hardWraps = true
  unsafe = true

[outputs]
  home = ["html", "rss", "llms", "llmsfull"]

[outputFormats.llms]
  baseName = "llms"
  isPlainText = true
  mediaType = "text/plain"
  rel = "alternate"

[outputFormats.llmsfull]
  baseName = "llms-full"
  isPlainText = true
  mediaType = "text/plain"
  rel = "alternate"

#
# papermod configuration
#

[params]
  AmazonJpAffiliateID = "masutaka04-22"
  DateFormat = "2006-01-02 (Mon)"
  ShowAllPagesInArchive = true
  ShowCodeCopyButtons = true
  ShowFullTextinRSS = true
  ShowPageNums = true
  ShowPostNavLinks = true
  author = "masutaka"
  comments = true
  defaultTheme = "auto"
  showtoc = true
  tocopen = true

[params.assets]
  theme_color = "#ffffff"
  msapplication_TileColor = "#da532c"

[params.social]
  fediverse_creator = "@[email protected]"
  twitter = "@masutaka"

[[params.socialIcons]]
  name =  "Mastodon"
  url = "https://mstdn.love/@masutaka"
[[params.socialIcons]]
  name =  "Bluesky"
  url = "https://bsky.app/profile/masutaka.net"
[[params.socialIcons]]
  name =  "Twitter"
  url = "https://twitter.com/masutaka"
[[params.socialIcons]]
  name =  "GitHub"
  url = "https://github.com/masutaka"
[[params.socialIcons]]
  name =  "RSS"
  url = "/index.xml"

#
# Multilingual
#

[languages.ja]
  weight = 1
  languageName = "日本語"
  title = "マスタカの ChangeLog メモ"

[languages.ja.params]
  description = "マスタカの変更履歴が記録されていくブログです。"

[languages.ja.params.homeInfoParams]
  Title = "マスタカネット"
  Content = "マスタカの変更履歴が記録されていくブログです。"

[[languages.ja.menu.main]]
  identifier = "archives"
  name = "Archive"
  url = "/archives/"
  weight = 1
[[languages.ja.menu.main]]
  identifier = "tags"
  name = "Tags"
  url = "/tags/"
  weight = 2
[[languages.ja.menu.main]]
  identifier = "search"
  name = "Search"
  url = "/search/"
  weight = 3
[[languages.ja.menu.main]]
  identifier = "device"
  name = "Device"
  url = "/device/"
  weight = 4
[[languages.ja.menu.main]]
  identifier = "history"
  name = "History"
  url = "/history/"
  weight = 5
[[languages.ja.menu.main]]
  identifier = "about"
  name = "About"
  url = "/about/"
  weight = 6

[languages.en]
  weight = 2
  languageName = "English"
  title = "Masutaka's ChangeLog Memo"

[languages.en.params]
  description = "This is a blog that records Masutaka's change history."

[languages.en.params.homeInfoParams]
  Title = "Masutaka Net"
  Content = "This is a blog that records Masutaka's change history."

[[languages.en.menu.main]]
  identifier = "archives"
  name = "Archive"
  url = "/en/archives/"
  weight = 1
[[languages.en.menu.main]]
  identifier = "tags"
  name = "Tags"
  url = "/en/tags/"
  weight = 2
[[languages.en.menu.main]]
  identifier = "search"
  name = "Search"
  url = "/en/search/"
  weight = 3