Hugo - Structured schema data for better SEO

Structured data allows search engines to better understand the data on the website, and thereby make search results more appealing to users. This post explains my Hugo Partial for the Schema.org types WebSite and BlogPosting. With i18n entries, explanation of the source code and testing of the JSON-LD structure.
General information about structured data
Google provides good information for developers. Below are some links on the topic:
- Introduction to markup for structured data in Google Search
- Markup for structured data supported by Google Search
The Schema.org vocabularies for structured data, founded by Google, Microsoft, Yahoo and Yandex, are developed in an open community process and are taken into account by the search engines accordingly.
- Schema.org - Complete schema hierarchy
- Schema.org - Schema type WebSite
- Schema.org - Schema type BlogPosting
I would like to thank Ashish Lahoti. His blog post - Add Structure Data JSON-LD in Hugo Website Pages - helped me a lot in programming my partial.
Relevant hugo.toml entries
The following are the entries in the Hugo configuration file used in the schema:
[author]
name = "Frank Kunert"
license = "Frank Kunert"
..
[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.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/ π¬π§"
..
The entries in the hugo.toml
were subsequently adapted by me to the Hugo version 0.112.
Relevant Front Matter entries
The following Front Matter entries are used in the partial site-schema.html:
title: "Hugo - Structured schema data for better SEO"
description: "Hugo Partial for Schema.org types WebSite and Blog with i18n entries. Explanation of the source code and testing of the JSON-LD structure. For better SEO."
subtitle: "Schema.org type WebSite and BlogPosting - structured data for better SEO of my Hugo website."
author: "Frank Kunert"
Partial site-schema.html
The partial is included in the head
tag of the baseof.html
:
<!DOCTYPE html>
<html lang="{{- .Site.Language.Lang -}}">
<head>
<meta charset="UTF-8">
...
...
{{ partial "site-schema.html" . }}
</head>
<body>
</body>
</html>
A partial for different areas of the website:
- Home page including the pagination pages - page/2, page/3 …
- Tag lists including the pagination pages - page/2, page/3 …
- Blog web pages
The source code of themes/tekki/layouts/partials/site-schema.html
looks like this:
{{ if or ( .IsHome ) ( not .Description ) -}}
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "WebSite",
"name": "{{ .Site.Params.mydomain }}",
"url": "{{ .Permalink }}",
{{ if .IsHome }}
"description": "{{ .Site.Params.description }}",
{{ else }}
{{ if eq .Site.Language.Lang "de" }}
{{ $u := urls.Parse .Permalink }}
{{ $u := strings.TrimRight "/" $u.Path }}
{{ $u := substr $u 6 }}
"description": "Tag {{ $u }} - {{ .Site.Params.description }}",
{{ else }}
{{ $u := urls.Parse .Permalink }}
{{ $u := strings.TrimRight "/" $u.Path }}
{{ $u := substr $u 9 }}
"description": "Tag {{ $u }} - {{ .Site.Params.description }}",
{{ end }}
{{ end }}
"inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
"thumbnailUrl": "{{ "/icon-144.png" | absURL }}",
"image": {
"@type": "ImageObject",
"@id": "{{ "/tekki-tipps-og.png" | absURL }}",
"url": "{{ "/tekki-tipps-og.png" | absURL }}",
"height": "630",
"width": "1200"
},
"license": "{{ .Site.Author.license }}"
}
</script>
{{ else if .IsPage }}
{{ if eq .Section "blog" }}
{{ $author := or (.Params.author) (.Site.Author.name) }}
{{ $org_name := .Site.Params.mydomain }}
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "BlogPosting",
"articleSection": "{{ .Section }}",
"name": "{{ .Title | safeJS }}",
"headline": "{{ .Title | safeJS }}",
"alternativeHeadline": "{{ .Params.subtitle }}",
"description": "{{if .IsPage}}{{ .Summary | markdownify }}{{ else }}{{ if .Description }}{{ .Description | safeJS }}{{ end }}{{ end }}",
"inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
"isFamilyFriendly": "true",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ .Permalink }}"
},
"author" : {
"@type": "Person",
"name": "{{ $author }}",
{{ with .Site.GetPage "/ueber-mich" -}}"url": "{{- .Permalink -}}"{{ end }}
},
"creator" : {
"@type": "Person",
"name": "{{ $author }}"
},
"accountablePerson" : {
"@type": "Person",
"name": "{{ $author }}"
},
"copyrightHolder": "{{ $org_name }}",
"copyrightYear": "{{ .Date.Format "2006" }}",
"dateCreated": "{{ .Date.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"datePublished": "{{ .PublishDate.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"publisher":{
"@type":"Organization",
"name": {{ $org_name }},
"url": {{ .Permalink }},
"logo": {
"@type": "ImageObject",
"url": "{{ "/icon-144.png" | absURL }}",
"width": "144",
"height": "144"
}
},
{{ with .Resources.GetMatch "article-img" }}{{ $banner := $.Page.Resources.GetMatch "article-img" }}
"image": {
"@type": "ImageObject",
"@id": "{{ $banner.Permalink }}",
"url": "{{ $banner.Permalink }}",
"height": "{{ $banner.Height }}",
"width": "{{ $banner.Width}}"
},
{{ end }}
"url": "{{ .Permalink }}",
"wordCount": "{{ .WordCount }}",
"genre": [ {{ range $index, $tag := .Params.tags }}{{ if $index }}, {{ end }}"{{ $tag }}" {{ end }}]
}
</script>
{{ end }}
{{ end }}
Further below I divide the source code according to the individual areas and explain some things.
Home page and tag lists incl. pagination pages - page/2 and following
Unfortunately Hugo offers very limited possibilities to determine the next page number of a pagination. I would have liked to include this in the title and description of the following pages. But at this point of the baseof.html
it is not possible to detect if the current page is a continuation page.
{{ if or ( .IsHome ) ( not .Description ) -}}
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "WebSite",
"name": "{{ .Site.Params.mydomain }}",
"url": "{{ .Permalink }}",
{{ if .IsHome }}
"description": "{{ .Site.Params.description }}",
{{ else }}
{{/* Tag-Listen */}}
{{ if eq .Site.Language.Lang "de" }}
{{ $u := urls.Parse .Permalink }}
{{ $u := strings.TrimRight "/" $u.Path }}
{{ $u := substr $u 6 }}
"description": "Tag {{ $u }} - {{ .Site.Params.description }}",
{{ else }}
{{ $u := urls.Parse .Permalink }}
{{ $u := strings.TrimRight "/" $u.Path }}
{{ $u := substr $u 9 }}
"description": "Tag {{ $u }} - {{ .Site.Params.description }}",
{{ end }}
{{ end }}
"inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
"thumbnailUrl": "{{ "/icon-144.png" | absURL }}",
"image": {
"@type": "ImageObject",
"@id": "{{ "/tekki-tipps-og.png" | absURL }}",
"url": "{{ "/tekki-tipps-og.png" | absURL }}",
"height": "630",
"width": "1200"
},
"license": "{{ .Site.Author.license }}"
}
</script>
..
This source code section also contains the code for the tag lists. The only difference is the creation of the "description"
. But first things first.
With {{ if or ( .IsHome ) ( not .Description ) -}}
I query whether the page to be generated is the home page or pagination following page of the home page. Or if the page has ( not .Description )
. The home page also does not have a Description, but it is recognized by .IsHome
. The tag lists are the only pages on my site that don’t have a description either. With this trick I can access the tag lists. This partial is included in the baseof.html
, so at a very early stage. Only there the head
HTML tag can be accessed and the json file can be created.
When assigning "name": "{{ .Site.Params.mydomain }}",
I access the hugo.toml
and there the parameter mydomain
. Depending on the language of the current web page, the content is assigned.
"url": "{{ .permalink }}"
assigns the current URL. Unfortunately, Hugo cannot output the subsequent pages with .Permalink
. It only outputs their URL for the home page. I’m not aware of any other Go function that can be used to read the URL directly. Whether that is even possible at this early stage of generation is unknown to me. If it were easy, I’m sure the Hugo team responsible would have provided this by now. For the tag lists, there is still a /tags/<tag-name>/
added, but also no /page/<following-page-number>
.
Now we come to the description
. With {{ if .IsHome }}
I ask again if the current web page is a start or follow page of the home page. If yes, then I assign the language specific description from the hugo.toml
.
If no, the current web page is a tag list or subsequent page of a tag list. Now it gets a little more complicated. What I want to do is to output before the general description from the hugo.toml
the word tag and then the tag name with hyphen. To do this, I need to operate the tag name out of the URL. Since the URL for translated pages is built after the pattern /en/tags/<tag-name>/
I have to cut out 3 more characters there. Therefore the query for the language. With Scratch
I would have produced even more code, also therefore the code repetition.
{{ $u := urls.Parse .Permalink }}
the variable $u
is assigned the Hugo function - urls.Parse
- is assigned to the .Permalink
. {{ $u := strings.TrimRight “/” $u.Path }}
trims the last /
from the string. And {{ $u := substr $u 6 }}
cuts the first 6 characters in the German version. In the English version it is 9 characters. The rest of the code is self-explanatory.
Test result for the home page and tag list
The schema.org website provides a - Schema Markup Validator - is available. The test result for the home page looks like this:

For the tag list, the result looks like this:

Blog Websites
Since I only want to offer my blog web pages to the search engines by schema, I also filter out only these pages by {{ if eq .Section "blog" }}
. Privacy and imprint do not need a schema in my opinion.
..
{{ else if .IsPage }}
{{ if eq .Section "blog" }}
{{ $author := or (.Params.author) (.Site.Author.name) }}
{{ $org_name := .Site.Params.mydomain }}
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "BlogPosting",
"articleSection": "{{ .Section }}",
"name": "{{ .Title | safeJS }}",
"headline": "{{ .Title | safeJS }}",
"alternativeHeadline": "{{ .Params.subtitle }}",
"description": "{{if .IsPage}}{{ .Summary | markdownify }}{{ else }}{{ if .Description }}{{ .Description | safeJS }}{{ end }}{{ end }}",
"inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
"isFamilyFriendly": "true",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ .Permalink }}"
},
"author" : {
"@type": "Person",
"name": "{{ $author }}",
{{ with .Site.GetPage "/ueber-mich" -}}"url": "{{- .Permalink -}}"{{ end }}
},
"creator" : {
"@type": "Person",
"name": "{{ $author }}"
},
"accountablePerson" : {
"@type": "Person",
"name": "{{ $author }}"
},
"copyrightHolder": "{{ $org_name }}",
"copyrightYear": "{{ .Date.Format "2006" }}",
"dateCreated": "{{ .Date.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"datePublished": "{{ .PublishDate.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"publisher":{
"@type":"Organization",
"name": {{ $org_name }},
"url": {{ .Permalink }},
"logo": {
"@type": "ImageObject",
"url": "{{ "/icon-144.png" | absURL }}",
"width": "144",
"height": "144"
}
},
{{ with .Resources.GetMatch "article-img" }}{{ $banner := $.Page.Resources.GetMatch "article-img" }}
"image": {
"@type": "ImageObject",
"@id": "{{ $banner.Permalink }}",
"url": "{{ $banner.Permalink }}",
"height": "{{ $banner.Height }}",
"width": "{{ $banner.Width}}"
},
{{ end }}
"url": "{{ .Permalink }}",
"wordCount": "{{ .WordCount }}",
"genre": [ {{ range $index, $tag := .Params.tags }}{{ if $index }}, {{ end }}"{{ $tag }}" {{ end }}]
}
</script>
{{ end }}
{{ end }}
Since $author
and $org_name
are needed in more than one place, I filled these variables with the appropriate content for this. Each blog post has a subtitle for me. With "alternativeHeadline": "{{ .Params.subtitle }}"
I assign the subtitle to the property.
I assign the summary of the blog post to the description with "description": "{{if .IsPage}}{{ .Summary | markdownify }}{{ else }}{{ if .Description }}{{ .Description | safeJS }}{{ end }}{{ end }}"
. With | markdownify
I filter out the p
HTML tag. If there is no summary - doesn’t happen with me - the description is taken from the front matter of the current page.
The test tool of Google pointed out me with the property author
that a URL should be added to a web page about the author. For this reason, I specified my "I about me" web page on this site. With {{ with .Site.GetPage "/ueber-mich" -}}"url": "{{- .Permalink -}}"{{ end }}
I search for the page and add its permalink. This also works in the English version. "/ueber-mich" is the Page Bundle directory name of my "I about me" website.
I assign an image to my blog posts in the page bundle. This always has the same resource name. With {{ with .Resources.GetMatch "article-img" }}{{ $banner := $.Page.Resources.GetMatch "article-img" }} … {{ end }}
, I get the image and assign it to the property "image":
.
Test result for the blog web pages
The test result for the blog websites looks like this:

Testing the JSON-LD schema data
In addition to the Schema.org validator described above, there is also a corresponding validator for rich search results from Google. Schema.org checks if the schemas are respected. Google’s test for rich search results checks if the additional criteria of Google are met.
-
Google Search Central - Test structured data
At first I didn’t know which validator to use. Google doesn’t have special rich results for blogs. That’s why the Schema.org validator is better for testing. - Schema.org - Schema Markup Validator
- Google - Test for rich search results
Even if no or only few rich results are possible on the own website, the search engines are provided with an additional possibility to understand the web page better with the schemas. Whether this then also affects the search results position is not guaranteed.
Conclusion
At first glance the partial source code looks a bit complicated, but on closer inspection this is not so bad. The properties for each Schema.org type are explained quite well on Schema.org. I have already noticed the first effects in the Google search. There is now sometimes a small image next to the search entries of my blog posts.
Link list to this post
- Introduction to markup for structured data in Google Search
- Markup for structured data supported by Google Search
- Schema.org - Complete schema hierarchy
- Schema.org - Schema type WebSite
- Schema.org - Schema type BlogPosting
- Add Structure Data JSON-LD in Hugo Website Pages
- Hugo Dokumentation - urls.Parse
- Schema.org - Schema Markup Validator
- Google Search Central - Test structured data
- Google - Test for rich search results
This might also interest you
- SEO - Self-referencing hreflang on multilingual website
Make search engines aware of the localized versions of your website.
- Hugo - Canonical Pagination Links for Blog Posts and Tags
Canonical links for pagination pages - page/2, page/3 ...
With the German language setting, comments are not displayed in the English version of the website and vice versa.