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- See reference: 2025-05-18-1
👉 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.
- Japanese Version: Archive, Tags, Search, Device, History, About
- English Version: Only includes Archive, Tags, Search
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
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
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 = "@masutaka@mstdn.love"
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
Top comments (0)