Hugo - Tutorial Part 2 - Create i18n multilingual site

i18n - Part 2 of 3 instructions for creating a Hugo multilingual website. The second part goes into depth on theme development and shows how to program the multilingual website. With the following topics: Entries in the html and head tag of the baseof.html and menu structure in the hugo.toml, including the navigation partials nav.html and sidepanel.html.
- Hugo - Tutorial Part 1 - Create i18n multilingual site
Part 1 describes initial configuration steps in hugo.toml, the Page Bundle structure of the web pages, and Front Matter entries in the Markdown files.
- Hugo - Tutorial Part 3 - Create i18n multilingual site
With the following topics: The T (Translate) feature of Hugo, switching the language on the website including source code and SCSS, Hugo problems with multilingual tags, tag cloud as topic overview, tag post list including source code and SCSS, internal linking.
Post updated on June 6, 2023
I have revised this post a bit and adapted it to the Hugo changes of version 0.112.
baseof.html - the framework for a multilingual website
The baseof.html
must be thought of as a picture frame around the content. A base template that is used on all websites. This saves the eternal repetition of things that must be present on every web page. A web page created manually in HTML has to be changed in all web pages created until then.
In the Hugo Documentation - Base Templates and Blocks
- the inclusion of content is explained by the Hugo statement of, for example,
My shortened source code in the themes/tekki/layouts/_default/baseof.html
looks like this:
<!doctype html>
<html lang="{{- .Site.Language.Lang -}}">
<head>
<meta charset="utf-8">
{{ if eq .Params.sitemap_exclude true }}
<meta name="robots" content="noindex, nofollow, noarchive">
{{ else }}
<meta name="robots" content="index, follow, archive">
{{ end }}
{{ if .IsHome }}
<!-- Homepage -->
<title>{{ .Site.Params.description }} - {{ .Site.Params.mydomain }}</title>
{{ if .Site.Params.description }}
<meta name="description" content="{{ .Site.Params.description }}">
{{end}}
{{ else }}
<!-- Page, Blog Page -->
<title>{{ .Title }} - {{ .Site.Params.mydomain }}</title>
{{if .Description }}
<meta name="description" content="{{ .Description }}">
{{ else }}
<!-- Tags -->
{{ if eq .Site.Language.Lang "de" }}
{{ if eq .Title "Tags" }}
<meta name="description" content="Übersicht der Tags von {{ .Site.Params.mydomain }}">
{{ else }}
<meta name="description" content="Das Tag #{{ .Title }} ist mit diversen Blogbeiträgen verknüpft.">
{{ end }}
{{ else }}
{{ if eq .Title "Tags" }}
<meta name="description" content="Overview of the tags from {{ .Site.Params.mydomain }}">
{{ else }}
<meta name="description" content="The tag #{{ .Title }} is linked to various blog posts.">
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ if .IsTranslated }}
{{ range .Translations }}
<link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}">
{{ end }}
<link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}">
{{ end }}
</head>
<body>
{{ partial "header.html" . }}
{{ partial "nav.html" . }}
{{ partial "sidepanel.html" . }}
<div id="content">
{{ block "main" . }}{{ end }}
</div>
{{ partial "footer.html" . }}
</body>
</html>
I shortened the source code to the entries relevant for i18n for clarity and explainability. For the explanations I have divided the baseof.html
shown above into corresponding code blocks:
html lang=“de” or “en”
The language specification determines the language for the entire page. This signals to screen readers in which language they should convert the output. Google does not use this attribute for search, but determines the main language of a page based on its content.
With Site.Language.Lang
, the language of the current page is output. Hugo creates this global variable by parsing the [languages]
parameter in the hugo.toml
. I described this parameter in part 1, in the section - i18n entries in the hugo.toml
- described.
head - meta charset=“utf-8”
UTF-8
is a character set that can also represent characters of Far Eastern languages and languages from the African region as a multibyte character string. The default character encoding in HTML-5 is UTF-8, but your source code editor must also save the HTML document as UTF-8.
head - meta name=“robots” content=
{{ if eq .Params.sitemap_exclude true }}
<meta name="robots" content="noindex, nofollow, noarchive">
{{ else }}
<meta name="robots" content="index, follow, archive">
{{ end }}
If the current web page contains the Front Matter parameter sitemap_exclude: true
, search robots are instructed not to index, follow, or archive the web page. If this is not the case they should do all this.
I described the Front Matter parameter sitemap_exclude: true
in the first part of the HowTo series - What and for what are _index.md files? - Unintended effect on sitemap.xml
.
head - HTML-Tag title and Meta-Tag name=“description”
In the following section, the Title and Description for the current web page are put together. There are the sections Homepage, Web Page and Tags. Each section must be created individually.
{{ if .IsHome }}
<!-- Homepage -->
<title>{{ .Site.Params.description }} - {{ .Site.Params.mydomain }}</title>
{{ if .Site.Params.description }}
<meta name="description" content="{{ .Site.Params.description }}">
{{end}}
{{ else }}
<!-- Page, Blog Page -->
<title>{{ .Title }} - {{ .Site.Params.mydomain }}</title>
{{if .Description }}
<meta name="description" content="{{ .Description }}">
{{ else }}
<!-- Tags -->
{{ if eq .Site.Language.Lang "de" }}
{{ if eq .Title "Tags" }}
<meta name="description" content="Übersicht der Tags von {{ .Site.Params.mydomain }}">
{{ else }}
<meta name="description" content="Das Tag #{{ .Title }} ist mit diversen Blogbeiträgen verknüpft.">
{{ end }}
{{ else }}
{{ if eq .Title "Tags" }}
<meta name="description" content="Overview of the tags from {{ .Site.Params.mydomain }}">
{{ else }}
<meta name="description" content="The tag #{{ .Title }} is linked to various blog posts.">
{{ end }}
{{ end }}
{{ end }}
{{ end }}
The start page has a different title and description than a normal web page. It gets the title and description from the hugo.toml
. For the web page, the title and description comes from the Front Matter parameters of the md
file. For the tags, I have the tag cloud and for a single tag, a list of posts. More about that below.
Using and keeping apart global variables .Site.Params.description
and Page / Front Matter variables .Description
made me run into my own errors several times in the beginning. But actually, once you understand it, it’s easy. The Hugo documentation is very informative about this:
Title and description of the home page
{{ if .IsHome }}
<!-- Homepage -->
<title>{{ .Site.Params.description }} - {{ .Site.Params.mydomain }}</title>
{{ if .Site.Params.description }}
<meta name="description" content="{{ .Site.Params.description }}">
{{end}}
{{ else }}
If the current web page is the home page, the contents for description and title are taken from the hugo.toml
. In .Site.Params.mydomain
is the domain for me.
Title and description of the web pages
{{ else }}
<!-- Page, Blog Page -->
<title>{{ .Title }} - {{ .Site.Params.mydomain }}</title>
{{if .Description }}
<meta name="description" content="{{ .Description }}">
{{ else }}
If the current web page is not the start page, this is a normal web page and the title and description is taken from the front matter variables of the page. I extend the title with mydomain
from the hugo.toml
, which is the global, country-specific variable with the domain name.
Title and description of the tags
{{ else }}
<!-- Tags -->
{{ if eq .Site.Language.Lang "de" }}
{{ if eq .Title "Tags" }}
<meta name="description" content="Übersicht der Tags von {{ .Site.Params.mydomain }}">
{{ else }}
<meta name="description" content="Das Tag #{{ .Title }} ist mit diversen Blogbeiträgen verknüpft.">
{{ end }}
{{ else }}
{{ if eq .Title "Tags" }}
<meta name="description" content="Overview of the tags from {{ .Site.Params.mydomain }}">
{{ else }}
<meta name="description" content="The tag #{{ .Title }} is linked to various blog posts.">
{{ end }}
{{ end }}
{{ end }}
{{ end }}
Now it gets a little more complicated. If the current web page is not the start page and the Front Matter parameter .Description
is not present, tags are currently displayed.
I have already put together the title for the tags for the “normal” web pages. With meta name=description
is based on different pages. With else
branch, the teaser list is displayed with the respective tag.
SEO tip for the HTML tag title
A longer and accurately descriptive title pleases search engines better than 1 or 2 words. In the search results, only 55 to 60 characters of the title are displayed. The rest is cut off. So the important stuff should be in those first 55 characters.
Source: MDN - Page titles and SEO
SEO Tip for the Meta Tag name=description
Google displays the description from the description meta tag in the results list below the title links. The description tag is therefore very important. If you check the character length of the texts on the results page, it shows that about 160 characters are displayed. I limit myself to less than 140 characters. This should ensure that everything I want is displayed.
head - HTML tag link rel=alternate
If a web page is also available in a translated version, you should include an alternative link for search engines. In addition, there must be an alternative link to the page itself. A so-called self-referencing.
{{ if .IsTranslated }}
{{ range .Translations }}
<link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}">
{{ end }}
<link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}">
{{ end }}
When the current web page is translated, the range
creates the alternative links to the other web pages. Since I only provide one other language, the range
is exited after one pass. The output of the second alternative link references the language of the current web page.
This source text is the simplest variant. For paginated web pages - i.e. URLs with the extension /page/X/
such as the second page of the homepage or tag lists starting on page 2 - it gets more complicated. In my blog post - SEO - Self-referencing hreflang on multilingual website
- I have described this.
SEO Tip for link rel=“alternate”
Google explains the link rel=alternate
extensively in the Google Search Central documentation:
body - Hugo’s block keyword
In body
the content of the web page is included by block
is explained in the - Hugo Documentation - Base Templates and Blocks
- explained:
<body>
{{ partial "header.html" . }}
{{ partial "nav.html" . }}
{{ partial "sidepanel.html" . }}
<div id="content">
{{ block "main" . }}
{{end}}
</div>
{{ partial "footer.html" . }}
</body>
</html>
In the partial nav.html
the menu is created. The menu is made invisible from a certain display width with the CSS statement display: none
. Otherwise the menu items pile up ugly on top of each other. The partial sidepanel.html
then takes over the menu function. But more about that below.
hugo.toml - Multilingual menu structure
Since my menu structure is very clear, I put it directly into the hugo.toml
. The Hugo menu documentation consists of the following documents:
- In the - Hugo Documentation - Menus - the general use of menus is explained.
- The - Hugo Documentation - Menu Entry Properties - describes the menu variables and functions.
- The multilingual part is documented in the - Hugo Documentation - Multilingual Menus .
Following are my multilingual relevant entries in the hugo.toml
:
..
defaultContentLanguage = "de"
[languages]
[languages.de]
languageName = "Deutsch"
weight = 1
title = "Hugo, Webdev, SEO, Tools"
[languages.de.params]
description = "Blog über Hugo, Webdev, SEO, Tools"
mydomain = "tekki-tipps.de 🇩🇪"
[[languages.de.menu.main]]
url = "/tags/"
name = "Tags"
weight = 1
[[languages.de.menu.main]]
url = "/datenschutz/"
name = "Datenschutz"
weight = 10
[[languages.de.menu.main]]
url = "/impressum/"
name = "Impressum"
weight = 20
[[languages.de.menu.main]]
url = "/kontakt/"
name = "Kontakt"
weight = 30
[languages.en]
languageName = "English"
weight = 2
title = "Hugo, Webdev, SEO, Tools"
[languages.en.params]
description = "Blog about Hugo, Webdev, SEO, Tools"
mydomain = "tekki-tipps.de/en/ 🇬🇧"
[[languages.en.menu.main]]
url = "/tags/"
name = "Tags"
weight = 1
[[languages.en.menu.main]]
url = "/data-protection/"
name = "Data Protection"
weight = 10
[[languages.en.menu.main]]
url = "/legal-notice/"
name = "Legal notice"
weight = 20
[[languages.en.menu.main]]
url = "/contact/"
name = "Contact"
weight = 30
The entries in the hugo.toml
were subsequently adapted by me to the Hugo version 0.112. See also - Multilingual - Changes in Hugo as of version 0.112
.
Menu items - languages.–.menu.main
The menu contents for German and English are identical except for the language. Hugo is very sensitive about the spelling of the menu item in the different languages. The syntax [[languages.de.menu.main]]
and [[languages.en.menu.main]]
must be followed.
Menu item - Variable url
The content of url
is relative to the baseURL
in the hugo.toml
. The url = “/tags/"
Menu item - Variable name
The variable name
is displayed as menu item text. The language assignment displays the country-specific text in each language.
Menu item - Variable weight
The order of the menu items is determined by the weight
variable and is in ascending order.
i18n Menu - Partial nav.html
The navigator bar disappears on this website when you scroll down the page. I use the hamburger icon for a sidepanel that also displays the menu. Once the navigation bar is no longer displayed, the hamburger button slides into the header bar to the left of my logo. Below is the sourcecode for the navigation themes/tekki/layouts/partial/nav.html
:
<nav id="navigation-bar">
<div class="container">
<div class="navbutton">
<button type="button" class="btn btn-link menu-hamburger openbtn" onclick="openNav()"><span>☰</span> {{ T "Menu" }}</button>
</div>
<ul class="menulinks">
{{ $currentPage := . }}
{{ range .Site.Menus.main }}
{{ $menu_item_url := .URL | relLangURL }}
{{ $page_url := $currentPage.RelPermalink | relLangURL }}
<li {{ if eq $menu_item_url $page_url }}class="active"{{ end }}>
<a href="{{ $menu_item_url | absLangURL }}">{{ .Name }}</a>
</li>
{{ end }}
</ul>
</div>
</nav>
Hamburger-Button for the side panel
The hamburger button, the correct name in Unicode is Trigram for Heaven
☰, is displayed by the decimal HTML encoding ☰
. When the button is clicked, the JavaScript function openNav()
is called. More on this below. The Hugo T function is used to translate a string, in this case - Menü / Menu - into the current language. More about this in part 3 of this HowTo series.
Assembling the menu list structure
With main
in the hugo.toml
.Site
” is interpreted by Hugo as [languages.de]
or [languages.en]
. After that, the variable $menu_item_url
stores the country-specific URL, for the menu item searched in the loop. The $page_url
variable is assigned the country-specific URL of the web page. When the HTML tag li
is merged, it examines whether the current web page matches the URL of the menu list item. If it does, then the CSS class active
is assigned. Then the link is built and the name of the menu item is inserted.
CSS/SCSS - Partial nav.html
I will not explain the SCSS instructions in particular. That is web design and belongs to the tools of the trade. If you don’t understand SCSS, there are websites on the internet that offer a conversion from SCSS to CSS.
In the CSS ID navigation-bar
and other CSS classes, I assign the value to a variable, for example var(–wk-container-bg);
. The reason for assigning by variable is the darkmode of my theme. By default, the theme starts in lightmode. By a Theme Switcher icon - on top, the second from the right - can be switched to darkmode.
Further down, the width of the container, the variable $site-width
is assigned. In SCSS, such assignments are possible and provide flexibility. The SCSS is stored with me in the following file - themes/tekki/assets/scss/layouts/basic.scss
:
#navigation-bar {
display: flex;
width: 100%;
margin-top: 5.5rem;
background-color: var(--wk-container-bg);
border-top: 1px dotted var(--wk-accent-border-color);
border-bottom: 1px dotted var(--wk-accent-border-color);
.container {
display: flex;
width: $site-width;
background-color: var(--wk-container-bg);
.navbutton {
display: flex;
padding-right: 1.0rem;
.btn {
border-radius: 0;
color: var(--wk-link-color);
}
.btn:focus {
box-shadow: none;
}
.btn-link {
text-decoration: none;
}
span {
padding-bottom: 0.1rem;
padding-right: 0.3rem;
}
.menu-hamburger {
margin-left: -1.5rem;
padding-right: 1.0rem;
border-right: 1px dotted var(--wk-accent-border-color);
background-image: none;
}
}
.menulinks {
display: flex;
flex-direction: row;
list-style: none;
margin: 0;
padding: 0;
a {
float: left;
display: block;
color: var(--wk-link-color);
text-align: center;
padding: 15px;
text-decoration: none;
}
.active {
border-top: 2px solid red;
}
}
}
}
// Responsive
//
@media (max-width: 767.8px) {
#navigation-bar {
.container {
.menulinks {
display: none;
}
}
}
}
@media (max-width: 575.8px) {
#navigation-bar {
.container {
width: 100%;
.navbutton {
.menu-hamburger {
margin-left: -1.0rem;
}
}
}
}
}
If the navigation bar is still visible, i.e. it has not yet been scrolled down, the menu items are made invisible up to a maximum view width of 767.8px. Otherwise the menu items would pile up and that doesn’t look good. The menu is always accessible via the sidepanel even in Responsive Mode.
How you can check the Responsive display, I described in another post - Check the responsive viewport .
i18n Menu - Partial sidepanel.html
Sourcecode: themes/tekki/layouts/partial/sidepanel.html
:
<div id="tt-sidepanel" class="sidepanel">
<div class="closebtn" onclick="closeNav()">×</div>
<a href='{{ "/" | relLangURL }}'>{{ T "Startseite" }}</a>
<ul class="menulinks">
{{ $currentPage := . }}
{{ range .Site.Menus.main }}
{{ $menu_item_url := .URL | relLangURL }}
{{ $page_url:= $currentPage.RelPermalink | relLangURL }}
<li {{ if eq $menu_item_url $page_url }}class="active"{{ end }}>
<a href="{{ $menu_item_url | absLangURL }}">{{ .Name }}</a>
</li>
{{ end }}
</ul>
<a href='{{ "/rss-info/" | relLangURL }}'>{{ T "rssInfo" }}</a>
</div>
The second div
displays the close symbol. When the x
is clicked, the JavaScript function closeNav
is called. More on this below. The first link is responsible for the Home page menu item. In the hugo.toml
I have not entered a menu item for this. All entries between the ul
correspond to the partial nav.html
explained above. At the end, a link is added that is also not contained in the partial nav.html
. Otherwise the horizontal menu will be too large.
CSS/SCSS - Partial sidepanel.html
Sourcecode: themes/tekki/assets/scss/layouts/basic.scss
:
.sidepanel {
width: 0;
position: fixed;
z-index: 100;
top: 0;
left: 0;
background-color: var(--wk-container-bg-4);
overflow-x: hidden; /* Disable horizontal scroll */
padding: 3.0rem 0 2.0rem;
transition: 0.5s;
a {
display: block;
padding: 0.5rem 0.5rem 0.5rem 1.0rem;
text-decoration: none;
font-size: 25px;
color: var(--wk-accent-color-5);
transition: 0.3s;
}
a:hover {
color: #f1f1f1;
}
.closebtn {
position: absolute;
top: 0;
right: 0.5rem;
font-size: 36px;
background-image: none;
}
.menulinks {
list-style: none;
margin: 0;
padding: 0;
}
}
JavaScript - Partial sidepanel.html
Sourcecode: themes/tekki/assets/wkjs/wk-1.0.js
:
/* Set the width of the sidebar to 250px (show it) */
function openNav() {
document.getElementById("tt-sidepanel").style.width = "250px";
}
/* Set the width of the sidebar to 0 (hide it) */
function closeNav() {
document.getElementById("tt-sidepanel").style.width = "0";
}
Conclusion
If you are using a ready-made theme for your Hugo project, you probably won’t be able to use these deep interventions described here. Nevertheless, some hints may be useful.
As I said in part 1 - this is my way to build a multilingual website. I didn’t want to depend on the theme again. It goes without saying that if theme authors don’t want to continue developing a theme, they have the freedom to discontinue the service. As I have experienced this myself, I prefer to program my themes myself.
I completely underestimated the amount of work required for a series like this. I try to explain the single Hugo steps exactly, so that hopefully also a i18n beginner understands my sourcecode. At the same time I notice unpleasant things in my own sourcecode. I then correct them and try around until I am satisfied with the public result.
At least until I look at my own sourcecode again weeks later …
Link list for this article
- Hugo Documentation - Base Templates and Blocks
- i18n entries in the hugo.toml
- Hugo Documentation - Site Variables
- Hugo Documentation - Page Variables
- MDN - Page titles and SEO
- SEO - Self-referencing hreflang on multilingual website
- Google Search Central Documentation - Tell Google about localized versions of your page - HTML tags
- Hugo Documentation - Menus
- Hugo Documentation - Menu Entry Properties
- Hugo Documentation - Multilingual Menus
This might also interest you
- Hugo - Tutorial Part 1 - Create i18n multilingual site
Part 1 describes initial configuration steps in hugo.toml, the Page Bundle structure of the web pages, and Front Matter entries in the Markdown files.
- Hugo - Tutorial Part 3 - Create i18n multilingual site
With the following topics: The T (Translate) feature of Hugo, switching the language on the website including source code and SCSS, Hugo problems with multilingual tags, tag cloud as topic overview, tag post list including source code and SCSS, internal linking.
- Hugo - Page Bundle shortcodes for images
Include images in the Markdown file using shortcodes from the page bundle.
- Hugo - i18n Multilingual - customize lastmod date for specific country
i18n - Adjust the country-specific notation of the lastmod date according to the language.
With the German language setting, comments are not displayed in the English version of the website and vice versa.