DEV Community

Cover image for Making this Hugo-built blog multilingual
Takashi Masuda
Takashi Masuda

Posted on • Originally published at masutaka.net

Making this Hugo-built blog multilingual

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"
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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/ -->
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

i18n/ja.toml:

sendMessage = "メッセージ送信"
Enter fullscreen mode Exit fullscreen mode

i18n/en.toml:

sendMessage = "Send Message"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)