Hugo Website with Dual Language Support

31 Oct 2024

Table of Contents


Code for this article is available in GitHub.


Introduction

The first time I used Hugo was when I migrated this site from Blogger. I had no idea what I was doing. I picked a pre-built theme, then hacked about on it to get what I wanted.

My hacks were really horrible, based on almost zero understanding of how Hugo works, and how the underlying Go template packages (text and HTML) should be used in Hugo. I had no understanding of how to use Hugo templates or how the Hugo context works.

The second time I used Hugo was very recently, to build a static website which needed to support content in two languages. This time, I tried to get a better understanding of what I was doing and how to work with Hugo instead of fighting against it.

This article and the related GitHub code present the approach I took.

This is not an “intro to Hugo” article. There are plenty of those.

But what I could not easily find was a very simple example of a Hugo site which integrated the features I needed. What I found was: (a) complex/sophisticated pre-built themes which contained far more bells and whistles than I needed; (b) official documentation which, although comprehensive, was not always easy to turn into working code; (c) community discussions which were sometimes out-of-date and often contradictory or ambiguous (but sometimes very helpful, I need to add).

This is a very focused, minimal demo, using the smallest amount of Hugo code I could manage, the least amount of styling (one small CSS file), and runnable code to show the i18n features I needed. That’s it - nothing more.

It may not be the Best Hugo Code, but I tried to keep it canonical - and I tried to avoid any horrible hackery.


Running the Demo

My website’s basic requirements:

  • Support 2 languages.
  • Be able to toggle between these 2 languages for each specific resource/page.
  • If a translation is missing, indicate this to users.
  • Support two basic taxonomies (“category” and “tag”).
  • Respect localization - for example, when displaying dates.

If you don’t have Hugo installed already, then start here.

To run the demo website:

  1. Clone the GitHub repository.
  2. Open a command line window in the repository’s root directory.
  3. Run the command: hugo server --buildDrafts

This starts a built-in Hugo web server.

The website is typically available at localhost:1313.


Overall Site Structure

There are many different ways you can organize the layout templates and content needed to build a Hugo web site. Even the overall directory structure can vary considerably. I have tried to keep things simple, but with the following caveats:

  • I chose to keep my two sets of language content completely separate. The two languages in this demo are French (fr) and German (de). The files are therefore in /content/fr/ and /content/de directories. You don’t have to do this. You can organize your content in other ways (by file name instead of by directory).

  • I chose to use a custom /config/_default/ directory for my main hugo.toml configuration file. And I split out the language-related configuration settings into their own files: languages.fr.toml and languages.de.toml.

These were both purely personal choices - and for this very small demo, they may look over-engineered. But they make more sense (to me) as the size of the actual site grows (more content; more language settings). YMMV.


Two Languages - Two Sites

These are defined in the config files. In hugo.toml:

1
2
defaultContentLanguage = 'fr'
defaultContentLanguageInSubdir = true

The first setting means the default homepage will be for French content at http://localhost:1313/fr/ - not German content. The German homepage is http://localhost:1313/de/.

So, if a user navigates to http://localhost:1313, Hugo will build a page for this URL which automatically redirects to the default (French homepage).

Each of the separate languages.fr.toml languages.de.toml files contains definitions for the languages - for example:

1
2
3
title = 'Mein i18n Demo'
languageName = 'Deutsch'
contentDir = 'content/de'

But it is those fr and de values in the file names which actually define the multi-language nature of my Hugo website.

If I hadn’t split my config into these separate files, I’d be using something more like this in my hugo.toml:

1
2
3
4
5
[languages]
  [languages.fr]
    ...
  [languages.de]
    ...

In fact, it is important to think about my website as actually being two sites - one for each language I have declared. Hugo reflects this in some of its methods - for example in the Sites method, which:

Returns a collection of all Site objects, one for each language…

As it also says here:

A multilingual project will have two or more sites, one for each language.

You can access the fr and de language codes by iterating over each of your sites and then using Language methods:

1
2
3
{{ range .Site.Sites }}
  {{ .Language.Lang }}
{{ end }}

Note that the Lang method is different from the (optional) LanguageCode method. The languageCode config attribute can be defined like this:

1
2
3
4
5
[languages]
  [languages.de]
    languageCode = 'de-DE'
    languageDirection = 'ltr'
    languageName = 'Deutsch'

But it does not actually affect any behavior of your site, as mentioned here:

This value does not affect localization or URLs.

It’s only used for RSS and aliases (see above link).


Templates

Hugo has a set of predefined template “types”.

I tried to stick to the standard templates as described in the official documentation.

My site has the following templates (all in /layouts/_default/ because I have no need to create more specialized templates - although Hugo supports this).

None of these templates contain any specific localization code:

baseof.html
The overall site structure (basically a website with a header, body and footer).

home.html
Template for my two home pages (one for each language).

section.html
Sections are defined by the directory hierarchy you set up, within the content directory. I have only one section called news/ - so I have content/fr/news/ and content/de/news. My section.html template basically just lists all of the single pages of content contained in a given section (like a table of contents page). Because I have a multilingual site - and because the two sections have the same directory name (news/), Hugo knows they are linked.

single.html
A single page, typically used when rendering the content provided in a single Markdown file. Furthermore, for each French “news” article, there will (typically) also be a corresponding German “news” article (each article is a translation of the other one). That is how my dual-language site works (your dual language site may be different!). I can link these pairs of articles to each other by adding information to the front matter of the content (Markdown) files - more on that later.

There are also two taxonomy-related templates: taxonomy.html and term.html. These will be discussed separately, below.


Content

Each of the following files contains Hugo front matter - which is content-related metadata accessible to Hugo. The rest of the file, below the front matter, is the actual Markdown for the page content.

Setting aside taxonomy-related content in content/*/categories/ (discussed later) the only content files in this demo are as follows:

French Content:

content/fr/_index.md
The French home page content.

content/fr/news/_index.md
The French news section root page. This actually contains no Markdown content, only front matter. This is where we specify that the French page title is “Nouvelles” (French for “news”).

content/fr/news/*.md
All the other Markdown files in the French news directory. Each file represents a page of news. These news pages contain some extra front matter - for example:

1
2
3
translationKey = '1730218999'
categories = ['annonce']
tags = ['chat', 'lapin']

German Content:

This is all in content/de/. It mirrors the structure and purpose of directories and files in content/fr/. The German word for “news” is “Nachricht” (as defined in the front matter of content/de/news/_index.md).

A German news page is linked to its French translation via a shared translationKey value. The file names do not have to be the same - in fact, they should be different, given the file names are used to build language-specific URLs.

Translating “news” in URLs

As already noted, we can use title = 'Nouvelles' in the relevant front matter file to display a French heading for the French “news” section page. But for the URL, we have to provide some configuration data.

Example in config/_default/languages.fr.toml:

1
2
3
4
5
[permalinks]
  [permalinks.section]
    news = '/nouvelles/'
  [permalinks.page]
    news = '/nouvelles/:filename'

These permalink settings control how the section segments of the relevant URLs are built:

  • http://localhost:1313/fr/nouvelles/…
  • http://localhost:1313/de/nachricht/…

Dictionary Files

In some cases, we want a word or phrase in a web page to be translated to match the page language. This is typically needed for content which is not part of a Markdown file. In the above screenshot, an example of this can be seen in the page footer:

langue: français (fr)

This text is generated by the footer.html partial (see below for more about partials):

1
<div>{{ T "language" }}: {{ .Language.LanguageName }} ({{ .Language.LanguageCode }})</div>

The T function in {{ T "language" }} tells Hugo to translate the word “language” into either French or German (depending on which page is being displayed - a French or a German page). The translations are added to the /i18n/fr.toml and /i18n/de.toml files:

1
language = 'langue'

and:

1
language = 'Sprache'

We have already seen the Language object (see earlier).


Hugo Partials

Each Hugo partial template in layouts/partials/ represents a re-usable fragment of code, and helps to prevent layout templates from becoming too unwieldy.

We’ve already seen one partial (the footer partial).


Language Chooser

The website contains a language chooser in the top right hand corner.

partials/language_chooser.html:

1
2
3
4
5
6
7
8
9
{{ if .Translations }}
    {{ range .Translations }}
        <a href="{{ .RelPermalink }}">{{ .Language.LanguageName }}</a>
    {{ end }}
{{ else }}
    {{ range .Site.Home.Translations }}
        <span><s>{{ .Language.LanguageName }}</s></span>
    {{ end }}
{{ end }}

This code builds a clickable link which toggles between the French and German versions of the current page.

.Translations - Returns all of the translations for the current page, except for the current language itself (see also .Alltranslations).

{{ if .Translations }} - The .Translations method returns a collection of pages. If there are no translations, that collection is empty. The if condition considers an empty collection to be “falsy” and a non-empty collection to be “truthy”. So, the following are equivalent:

{{ if .Translations }}

and

{{ if gt .Translations.Len 0 }}

{{ range .Translations }} - This iterates over each item in .Translations. At this point we know this means there is exactly 1 item - the “other” page for the other language not currently being displayed. So. we are iterating over that one item and displaying that page’s .RelPermalink and .Language.LanguageName. This is a clickable link, taking us to that other page (the permalink).

{{ range .Site.Home.Translations }} - If the current page has no translation, then we want to show the user this by displaying a disabled toggler using <s> (HTML strikethrough). Why use .Site.Home here? Because we can be fairly confident that the home page has been translated into both languages! So we use that to grab the “other” language in a simple way.

In the test data for the website, we have one German news article which does not have a French translation - and this results in a disabled language toggler:

You can choose to arrange your language toggler control in various different ways. Some sites use this exact approach; others prefer to show two buttons side-by-side (one indicating the currently active language; and the other for the other language).

(If you are supporting three or more languages, you probably want a drop-down menu showing all languages.)

Regardless of implementation, the above Hugo partial shows the raw materials needed for a variety of approaches.


Hugo supports multilingual menus in a variety of different ways.

My basic implementation is included in the header.html template. (Maybe that should be moved into its own partial?!?):

1
2
3
4
5
6
<span>
  {{ range site.Menus.main.ByWeight }}
    <a class="menu-spacer"
       href="{{ .PageRef }}">{{ .Name }}</a>
  {{ end }}
</span>

The code site.Menus.main refers to menu definitions in each of the config/_default/languages.XX.toml files - for example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[menus]
  # I use the home page icon instead, in my demo
  #[[menus.main]]
  #  name = "Page d'accueil"
  #  pageRef = '/fr/'
  #  weight = 10
  [[menus.main]]
    name = 'Nouvelles'
    pageRef = '/fr/nouvelles'
    weight = 20
  [[menus.main]]
    name = 'Catégories'
    pageRef = '/fr/categories'
    weight = 30
  [[menus.main]]
    name = 'Balises'
    pageRef = '/fr/tags'
    weight = 40

The home page menu item is commented out in my case because I rely on the homepage icon instead. Your choice! And the menu layout is just a sequence of links in the header, for simplicity.

The code is simple: it reads from the relevant config file (e.g. pageRef) and orders the menu items by weight (in ascending order).

As with most other i18n features of Hugo, it’s context-aware: The code knows from the page context whether the page is a French or German language page - and therefore it knows which menu items to use (French or German).

More Hugo documentation links for handling menus:


Taxonomies

My demo site uses the two default taxonomies provided by Hugo out-of-the-box:

  • categories
  • tags

You add categories and tags to content (Markdown) files in their front matter - for example:

1
2
categories = ['annonce']
tags = ['chat', 'lapin']

You can define how you want to use these taxonomies however you wish. In my case this is as follows:

categories - I create a pre-defined set of categories by explicitly creating the content/fr/categories/ and content/de/categories/ directories. I create one sub-directory for each category I want to support. Right now I only have two such categories: “announcement” and “drinks”.

These are intended to be broad definitions describing the type of each content page.

The key point here is that each French category has an equivalent German category. Just like news articles, these pairs of categories are linked in their front matter using the translationKey = "foo" metadata value. This means you can toggle between a French category and its German equivalent.

(It is possible for users to invent a new category by adding it to the front matter of a content file - but that is not encouraged - there will be no directory created and no linkage to a category translation. Dealing with this situation is outside the scope of this article.)

tags - These are intended for any use except those uses already covered by “categories” - for example, a tag could represent the subject of an announcement, the theme of an event, and so on. There are no pre-defined tags. Users can create whatever they want in each article’s front matter. There are no links between French and German tags (no explicitly created tag files; no front matter; no translationKey values, etc.).

Again, just to emphasise, all these are my choices - your choices (and your taxonomy usage) may be different!

There are two taxonomy templates.

taxonomy.html
This template controls the “homepage” for each Hugo taxonomy I am using: the categories taxonomy and the tags taxonomy. The taxonomy template is used to list all of the terms in each taxonomy.

term.html
This is also a taxonomy-related template. It is used to list all of the pages of content which belong to a given term in a taxonomy.

Because taxonomy support is built into Hugo, there is not much we need to do to support our two languages. One small tweak, from the taxonomy.html template:

1
{{ title (T (lower .Title)) }}

This takes the taxonomy title (either “Categories” or “Tags”), converts that word to lowercase (“categories”, “tags”) and then translates that word using our /i18n/dictionary files. Finally it converts the translated word (or phrase) to title case. That is what gets displayed in the web page.


Taxonomy URLs

There is one notable limitation regarding language-related taxonomies in my demo: The URLs for categories and tags all use the English language words, and not translated terms. For example:

  • http://localhost:1313/fr/tags/lapin/

You might have expected that to be /fr/balises/lapin/ instead.

This is a compromise on my part, because I have not found a simple way to handle this one specific case.

All of the text displayed on the web page refers to “balise” and not “tag”. It is only the URL where “tag” is used instead of “balise”. For the sake of simplicity, I can live with this.

If I find an elegant solution, I may reconsider things…


Missing Translations

Currently, my listings pages (e.g. for news articles) is just a basic list of all the articles for the given language.

As shown in an earlier screenshot, there is one case where I have a German news article which does not have a French translation.

So, currently, the French news listing page looks like this:

However, instead of using the layouts/partials/page_list.html partial (see its use in layouts/_default/section.html), I can use layouts/partials/page_list_merged.html - and now I see the following in the French news listing page:

The difference is that now the French news listing page also shows that one German article for which there is no French translation - with a warning marker for visual emphasis.

See the official documentation for .Merge, which my code uses.

If you want to use this code, you will need to edit the section.html template file - and (optionally) also the term.html template file. Replace page_list.html with page_list_merged.html.

The code for this, in page_list_merged.html is heavily annotated. But it’s worth pointing out how this code uses . and $:

  • . - the current “context” at a given line of code
  • $ - the context passed into the layout template (e.g. by Hugo)

The . context inside a with or range section of code is almost certainly different from the original template context outside of those sections. But you can still access that original context using $.

This takes us back to one of the first points at the start of this article: you need to understand how the Hugo context works, to really exploit what Hugo has to offer!


404 (Not Found) Pages

To add custom “404 not found” pages, I added the following files to my project:

/config/development/server.toml
/layouts/404.fr.html
/layouts/404.de.html

During Development

The development config directory is only used when you run your Hugo website in “development” mode, either by specifying the environment on the command line, or by simply using the hugo server command - which, by default, sets the environment to “development”.

The contents of the server.toml file handle the redirects which need to be used by Hugo’s web server:

1
2
3
4
5
6
7
8
[[redirects]]
  from = '/de/**'
  status = 404
  to = '/de/404.html'
[[redirects]]
  from = '/**'
  status = 404
  to = '/fr/404.html'

Note that the default language (fr) is at the end of the redirect rules. This means if there is no /de/ or /fr/ in the URL, then the default 404 page (French) will be used.

Each language has its own 404 template - the language code must be provided as part of the file name, so Hugo knows which one to use.

More details about 404 pages:

More details about the redirect (and rewrite) syntax used by Hugo’s web server:

In Production

Setting up correct 404 redirect rules depends on the specific server/hosting environment on which your web site is deployed (probably not Hugo’s built-in web server!).

The Custom 404 pages documentation covers a range of different hosting providers with links to their configuration capabilities.

One extra hosting option not listed above is Render.com. In their case, you can use the following rules:

Source Destination Action
/de /de/ Redirect
/fr /fr/ Redirect
/de/* /de/404.html Redirect
/* /fr/404.html Redirect

Build the Production Site

You can see all of the final website files by using the hugo command.

One example command:

1
hugo --gc --minify --destination www

This creates a new directory www/ where all the website’s files are placed.

These files no longer contain any Hugo code - just HTML, CSS, JS, static resources, etc.