robinvanderknaap.dev
Header image

Everything I learned about Hugo: Building robinvanderknaap.dev

September 15, 2024

Last year I’ve been building two websites using Hugo: our corporate website communicatie-cockpit.nl and my personal blog robinvanderknaap.dev.

A lot of options are available when it comes to static site generators, I decided upon Hugo because of it’s speed, the fact that it doesn’t use some kind of JavaScript framework and finally, because it is one of the most used generators out there.

I have no regrets choosing Hugo, but my main pain was the lack of guidance on how to get started. Most tutorials start with pre-built themes, which is a great feature for people who want to create a site quickly and only worry about the content. I wanted to create a website from scratch and have full control over what the website looks like and how it behaves. Although, almost all features are documented, it’s difficult to get started with Hugo from scratch because you have to understand the concepts first and how it all works together.

For this reason I have created this tutorial. I’ll walk you through building my personal blog from start to finish without using pre-built themes. This includes everything from creating and configuring a Hugo site, adding structure, content and templates, to deploying and hosting the website. The end result of this tutorial is hosted at https://building-robinvanderknaap-dev.pages.dev/, the source code is hosted at GitHub.

For the styling of the website Tailwind CSS is used. In the past, Hugo and Tailwind were a bit awkward to combine during development. Mainly due to the fact that during development they both monitor the file system and rebuild on changes, leading to race conditions and caching issues, resulting in an overall frustrating developer experience. Luckily, since Hugo version 0.112.0 these problems have been dealt with. In this tutorial I’ll show you how to get Tailwind to play nice with Hugo.

Prerequisites

  • You should have Node.js installed on your system.
  • Basic knowledge of HTML, CSS and JavaScript.
  • Git, if you want to put your site under version control
  • Knowledge of Tailwind CSS is preferred but not required, you can just code along and get a feel for the framework. When building your own site, you can choose something entirely different.
  • You will need to have a code editor. If you are using Visual Studio Code like me, it’s highly recommended to have the following extensions installed:
  • If you are using another editor, try to find alternatives for these extensions.

1. Get up and running

In this section you will install Hugo and Tailwind CSS, add your first page and setup version control. At the end of this section you will have a fully functional, be it basic, website.

1.1 Create Hugo website

Most tutorials about Hugo start with installing the Hugo CLI globally. Personally I don’t like installing dependencies globally, because you might need different versions for different projects. Luckily, we don’t need to install the CLI globally. We can use the hugo-bin npm package, which is a binary wrapper for Hugo, and is able to execute all CLI commands.

We will be using npx , which is a package runner and already installed with Node.js, to execute hugo-bin without installing it and use the Hugo CLI to create the initial website. After this, we can install hugo-bin within the project as a local dependency and use npm script commands to execute Hugo commands.

Create a new site using npx and hugo-bin. The latest version of hugo-bin will be automatically used if no version is specified:

npx hugo-bin new site building.robinvanderknaap.dev

This command creates the website called building.robinvanderknaap.dev. You’ll see a new folder is created with the name of the site. Let’s look inside.

1.2 Folder structure

Inside the website folder you’ll find a bunch of folders which are all empty, except for archetypes. Open up your code editor and let me introduce you to the folder structure of Hugo. The following folders are created:

  • Archetypes: Hugo archetypes enable you to create a template for each type of content you want to add to your site. Whenever you add a piece of content to your site using the Hugo CLI, Hugo will search for a matching archetype to create the initial content. This feature is mostly used for adding default front matter to content. Front matter is the meta data of each piece of content you create, it is located in the top of each content file and contains things like title and date, but also custom parameters like keywords or description.
  • Content: This folder contains all your content usually markdown files, but also plain text or HTML files are supported. Assets, like images, that are related to a specific content file, can also be placed here.
  • Layouts:  Contains HTML templates in which the content is embedded during the build. You can have templates for each type of content you define, or you can use general layouts, or a little bit of both if you want. You can also drop partial layouts here, which can be used to unclutter your templates, typical examples for partial templates are head, header and footer templates.
  • Themes: Themes are not used in this tutorial, but generally speaking, you could add your layouts, config and other logic here in a theme folder. You can also use pre-built themes from third parties to render your content. Multiple themes are allowed here, and you can switch themes by changing the configuration.
  • Data: Hugo’s data folder is where you can store supplemental data (in JSON, YAML, or TOML format) that can be used to generate (some of) your site’s pages.
  • Static: The static folder is typically where Hugo users store files that don’t need additional processing, like fonts or DNS verification files. When a Hugo site is generated, all files in the static folder are copied as-is.
  • Assets: Contains assets that require additional processing, like minifying CSS and JS files or resizing images. Assets are available in all content pages or layouts, unlike page specific resources in the content folder.

1.3 Setup package manager

In this tutorial we will be using npm as the package manager for installing dependencies needed for the website. If you want, you can use pnpm or Yarn instead of npm.

You don’t need NodeJS or npm at your production server. Only HTML, CSS and JS files are ultimately deployed to the server.

Change the current working directory to the newly created website folder and initiate a package.json:

cd building.robinvanderknaap.dev
npm init -y

1.4 Install Hugo-bin

We used npx to execute hugo-bin and create the website, but it would become quite tedious to use npx all the time to access the CLI. Let’s install hugo-bin locally now, so you can execute CLI commands from the scripts section of your package.json and always use the same local version of the CLI within this project.

Hugo comes in 2 versions, standard and extended version. The main difference is that the extended version can encode to the WebP format when processing images. It’s recommended by the Hugo team to use the extended version of Hugo.

To make sure the extended version of Hugo is used by hugo-bin, you need to add the following config to your package.json:

"hugo-bin": {
  "buildTags": "extended"
}

A bit counterintuitive perhaps, but you need to add this config before installing hugo-bin, otherwise the standard version is installed.

With the config in place, you can install hugo-bin now and save it as a dependency:

npm install hugo-bin -S

If you forgot to add the hugo-bin config to the package.json, the standard version is installed. You will need to uninstall and install hugo-bin again after you change the config to install the extended version. See GitHub - fenneclab/hugo-bin: Binary wrapper for Hugo.

In order to access the Hugo CLI, replace the scripts section in package.json with these commands:

"scripts": {
  "start": "hugo server --buildDrafts --cleanDestinationDir",
  "hugo": "hugo",
  "hugo:build": "hugo --minify"
},

The Hugo CLI is now available via the npm run hugo command from the command line. CLI parameters can be added behind the command in the terminal. For example, if you want to view the version of Hugo run:

npm run hugo version

The resulting output should be something like this:

hugo v0.136.2-ad985550a4faff6bc40ce4c88b11215a9330a74e+extended linux/amd64 BuildDate=2024-10-17T14:30:05Z VendorInfo=gohugoio

If you call the npm run hugo command without any parameters, the Hugo CLI will build the site and store the output into the ./public folder. A full list of CLI commands can be found here: https://gohugo.io/commands/.

The npm start command is what you will be using the most. It calls the hugo server command which spins up a server that serves the site at http://localhost:1313 . The server comes with live reload capabilities, Hugo watches for changes and immediately refreshes the site if one occurs.

The server is started with two build flags:

  • --buildDrafts: This means that content items marked as drafts will also be included in the build. This is what you usually want during development, but not in production.
  • --cleanDestinationDir: Cleans the ./public folder before the build. Important to remove lingering files which are normally not automatically removed. This can give some surprising results sometimes.

Time to make the first test run:

npm start

If you open your browser at http://localhost:1313 this should be the result:

Page not found, that makes sense, we didn’t add any content yet.

1.5 Adding your first page

Before you add your first page, I need to explain how content is organized within Hugo, this isn’t always obvious.

In Hugo, your content should be organized in a manner that reflects the rendered website. Here’s an example of how a content folder could look like with the associated URLs:

.
└── content
    └── about
    |   └── index.md  // <- https://example.com/about/
    ├── posts
    |   ├── firstpost.md   // <- https://example.com/posts/firstpost/
    |   ├── happy
    |   |   └── ness.md  // <- https://example.com/posts/happy/ness/
    |   └── secondpost.md  // <- https://example.com/posts/secondpost/
    └── quote
        ├── first.md       // <- https://example.com/quote/first/
        └── second.md      // <- https://example.com/quote/second/

1.5.1 Sections

Hugo’s organizes content into sections, a section is a top-level directory, or any content directory which contains a _index file (mind the underscore). The index file can be a .html or .md file.

So the root folder /content is a section, but also the /content/about or /content/posts folders are sections, because they are top-level directories.

/content/about/me for example, is not a section, unless it contains a _index file.

To be clear, top level directories don’t need an _index to be regarded as sections. They do need some kind of content, though, empty folders will be ignored.

Sections are important because they are used to determine the content type, which is used to select the template for rendering the content.

1.5.2 Page bundles

Hugo supports page bundles, which are basically folders that bundle content and page specific resources like images or other pieces of content. Page bundles always have a index or _index file in its root folder. The advantage of page bundles is the fact that content and related resources are grouped together.

As an example, this site has an about page and a privacy page:

content/
  ├── about/
  │   ├── index.md     // <- https://example.com/about/
  │   └── welcome.jpg  // <- https://example.com/about/welcome.jpg
  └── privacy.md       // <- https://example.com/about/

The about page is a page bundle. The privacy page is a regular page. Resources within a page bundle are called page resources and are only available to the page inside the bundle. Two types of page bundles exists: Leaf bundles and Branch bundles.

A leaf bundle is a directory that contains an index file and zero or more resources. Analogous to a physical leaf, a leaf bundle is at the end of a branch. It has no descendants, no child pages.

A branch bundle is a directory that contains an _index file and zero or more resources. Analogous to a physical branch, a branch bundle may have descendants including leaf bundles and other branch bundles.

Top level directories with or without _index.md files are also branch bundles. This includes the home page.

1.5.3 Templates

A template is a HTML file in the layoutsfolder that transforms your content into a web page. Without a template, your content will not be rendered by Hugo.

We will start with three default templates, which will be used to render content when no other template matches the content type (section). Since we don’t have any templates yet, the defaults will be used for every piece of content for now.

The default templates are placed in a folder called _default inside the layouts folder:

mkdir layouts/_default

Create the following default templates:

  • baseof.html (contains the html skeleton)
  • single.html (for single content items/pages)
  • list.html (for index/list pages)
touch layouts/_default/baseof.html
touch layouts/_default/single.html
touch layouts/_default/list.html

The baseof.html file contains the HTML skeleton of every page and is used by the other two default templates to construct the page:

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta author="Robin van der Knaap">
  </head>
  <body>
    <div>
      {{- block "main" . -}}{{- end -}}
    </div>
  </body>
</html>

The main block is defined between the curly braces inside the <body> element, which can be targeted by other templates to inject content. You can give any name you want instead of main. It’s also possible to define multiple blocks, but for this site we only need one.

The hyphens next to the curly braces are used to remove any white space(spaces, tabs, carriage returns and new lines). If used at the start of the curly braces, it will remove all white space before the braces. Used at the end it will remove all white space after the braces. In the base template both are used.

Removing white spaces is usually not really important, since all white space is removed in production anyway, due to minification. But, it can come in handy during development or in some special cases.

Now let’s create the other two templates, one for showing single pages and one for showing list pages:

{{ define "main" }}
<p>This is a 'single' page<p>
{{- .Content -}}
{{ end }}

The single.html file is used for single pieces of content that need to be rendered as a page, for example a blog post. As you can see the main block from the baseof.html file is referenced to inject the content. Notice the {{ .Content }} notation. The . represents the current context. A property of the context is Content, which is used to inject the content at the correct position.

{{ define "main" }}
<p>This is a 'list' page</p>
{{ .Content -}}
<ul>
  {{ range .Pages -}}
  <li>
    <a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
  </li>
  {{- end }}
</ul>
{{ end }}

The list.html template is used for pages listing multiple pieces of content, for example a list of blog posts. You can see that a list page can have content of its own and is injected using the same .Context property as in the single.html page.

Besides content, also the list with child pages is injected using the range operator. Pages is another property that exists on the current context.

When the range operator is used, the current context switches to the Page object. RelPermalink is a property that is available on the Page object, which is used to render the link to the page.

I’ve added the phrases ‘This is a list page’ and ‘This is single’ page. This is to make clear how Hugo renders content, you can remove these phrases later after you finish the following content demonstration intermezzo.

1.5.4 Demonstration

To demonstrate the concepts of sections, page bundles and templates, create the following folder structure in the content folder, and add one actual content file:

mkdir -p content/about/me
touch content/about/me/again.html

Add the following front matter and content to the again.html file:

---
title: "About Me Again"
---
<p>About me again</p>

Now, run Hugo with npm start and see the results:

Although no file was added to the root directory, you still see a list page as home page. This is because the root directory is a section and is considered a branch bundle by default. If you do not see it, restart Hugo.

You also see a link to the ‘Abouts’ page (plural of content type about, same as folder/section name) as the title. Follow the link:

Again you see a list page, because the about folder is a top level directory and considered a branch bundle as well. You see the about me again page here. Again, follow the link:

Now, you are sent directly to /about/me/again. Which is a regular single page, not a bundle. There was no link to /about/me, because this is not a section or a branch bundle. It is also is not a page, because it does not contain any content file. To be sure, visit http://localhost:1313/about/me and see what’s there:

This is correct. The folder does not contain any files and it is not a list page, because it is not considered to be a section or branch bundle.

Although it’s not necessary, it’s recommended to add _index files to the root folder and the top level directories. This gives you the opportunity to add content to the list pages and front matter for things like the title and search engine information.

One last thing. If you look at the public folder in your website. You will see that again.html does not exists. Instead a folder name again is created with a index.html in it. To end this demonstration, you can remove the about folder.

rm -rf content/about

1.5.5 Homepage

Now you can finally add your first page. Add a basic home page in the root of the content folder:

touch content/_index.html

Add the following front matter and content:

---
title: 'Home'
---
<h1>This is the home page</h1>

If you visit http://localhost:1313 again, you should see the content you just created in your browser, otherwise restart Hugo:

Remove the ‘This is a list page’ and ‘This is a single page’ from the list.html and single.html templates before you move on.

1.6 Config

For configuration, HUGO creates a config file (hugo.toml) in the root folder upon creation of the site. The default format is TOML, but you can also use YAML or JSON. In this tutorial we’ll be using YAML to format the config file. start with renaming the config file:

mv hugo.toml hugo.yaml

And change the content of hugo.yaml to:

baseURL: https://building-robinvanderknaap-dev.pages.dev/
languageCode: en-us
title: building.robinvanderknaap.dev
module:
   hugoVersion:
    extended: true
    min: "0.112.0"

The baseUrl is used by Hugo to generate absolute URLs, like the ones that are used in a RSS feed. The baseURL should contain the absolute URL (protocol, host, path, and trailing slash) of your published site. I’ve used the URL that was generated by Cloudflare, which hosts my blog. Deployment to Cloudflare is covered in the last chapter of this guide. If you don’t have a URL yet, don’t worry, set it to http://localhost:1313/ for now and change it later it when you deploy your site.

The languageCode is a language tag as defined by RFC 5646. We will use the value to populate the lang attribute of the <html> element. It is also used to set the <language> element in RSS feeds generated by Hugo.

Title specifies the name of the site, you can use the value to construct the title of your pages.

The minimum version of Hugo is deliberately set to 0.112.0, because with this version cache busters have been introduced, which makes it possible to integrate Tailwind CSS in the build process and have a pleasant developer experience instead of a horrible one.

All possible configuration settings are listed here: https://gohugo.io/getting-started/configuration/#all-configuration-settings. For now, the above settings will suffice.

We will start with updating the base template and use the language code and title from the config file:

<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta author="Robin van der Knaap">
    <title>{{ .Site.Title }}</title>
  </head>
  <body>
    <div>
      {{- block "main" . -}}{{- end -}}
    </div>
  </body>
</html>

As said, In templates the . before Site is a reference to the current context object. Initially the context is the object representing the page currently being rendered. Site is a property of the Page object, added for convenience. Via the .Site property you are able to access the site configuration. So, in this case .Site.LanguageCode and .Site.Title represent the values from the config file.

Besides .Site a lot of properties exist on the Page object, you can view all available properties here: https://gohugo.io/quick-reference/methods/#page. A lot of these properties are set via front matter in content files.

Nice to know: you can add custom parameters to the config file like this:

baseURL: https://building.robinvanderknaap.dev/
...
params:
  contact:
    email: [email protected]
    phone: +1 202-555-1212

Which can be accessed like this in your templates (not in content files!):

<div>
  {{ .Site.Params.contact.email }}
  {{ .Site.Params.contact.phone }}
</div>

1.7 Add Tailwind CSS

Tailwind CSS is a utility-first CSS framework, meaning you no longer need to declare classes in CSS files, but all styling is performed inside the HTML. You are able to create styling on the fly using class names. The following example sets a white background, but turns to black when hovered:

<div class="bg-white hover:bg-black">Some content</div>

Tailwind CSS comes with a CSS reset called Preflight which smooths over cross-browser inconsistencies.

Tailwind produces the smallest CSS file possible by generating only the classes you actually use by scanning your content. Together with Preflight it usually keeps the file under 10kb.

To use Tailwind CSS in a maintainable manner, it’s important that you extract components and partials for re-usability. Hugo offers templates and partials which are a good match with this way of thinking, making Hugo and Tailwind CSS a good combo.

You have to experience Tailwind CSS before liking it. It takes some getting used to, especially when you come from a Bootstrap background, where entire components are at your disposal. Tailwind CSS does not have components, but has a lot of component examples, which are just HTML markup with CSS utility classes. You can copy-paste and alter them as needed.

Tailwind’s official site offers a lot of example components to get started, you can also take a look at Flowbite, where a lot of other example components created with Tailwind are available at no cost.

The Tailwind CSS Visual Studio Code extension is indispensable when working with Tailwind classes. Just hover over the class, and it will display the actual CSS that is generated. It helped me find my way very quickly.

Make sure you are working on Hugo v0.112.0, which includes some changes to work well with Tailwinds JIT compiler see Release v0.112.0 · gohugoio/hugo · GitHub.

To use Tailwind CSS with Hugo you will be using the PostCSS Hugo Pipe. Hugo pipes are used to process assets, such as images, and in this case CSS files, during the build of the site.

First install Tailwind CSS as a dependency. Also add postcss, postcss-cli and autoprefixer packages:

npm install tailwindcss postcss postcss-cli autoprefixer

After installing Tailwind CSS, you need to create an initial configuration file:

npx tailwindcss init

The Tailwind config file determines which files and folders are scanned for Tailwind utility classes. Based on the scan, a CSS file is generated with only the needed CSS rules.

Normally you would add the HTML and markdown files from the content folder and the HTML files from the layouts folder to Tailwind’s config file, but this approach doesn’t work well with Hugo during development due to caching issues.

The trick is to let Tailwind watch a file called hugo_stats.json instead. This file, which is generated by Hugo automatically, contains all the CSS classes used in your content and templates. One exception, hugo_stats.json does not contain classes used in JavaScript files. Later on we’ll be using some CSS classes in JavaScript for things like toggling dark and light mode, so we also need to watch the JavaScript folder for Tailwind classes. This is what the Tailwind config file should look like:

/** @type {import('tailwindcss').Config} */
module.exports = {
   content: [
    './hugo_stats.json',
    './assets/js/*.js'
  ],

  theme: {
    extend: {},
  },
  plugins: [],
}

You will need an initial CSS file to reference Tailwind’s base, component and utility classes. You can add your own custom CSS here as well, if you need to. The file needs to be added to the assets folder so it can be processed by Hugo. Create styles.css inside the assets/css folder:

mkdir assets/css
touch assets/css/styles.css

Add the following content to reference Tailwind’s base, component and utility classes:

@tailwind base;
@tailwind components;
@tailwind utilities;

The assets folder contain all files (CSS, images, JavaScript, etc..) that need to be processed by HUGO. Assets that are not referenced in content or layout files, will not end up in production.

Now you need to configure PostCSS which contains a plugin to handle Tailwind CSS. Also use the Autoprefixer plugin, this ensures the generated CSS works consistently across different browsers by adding necessary vendor prefixes. Autoprefixer is only used during production builds.

touch postcss.config.js

Add the following configuration:

const tailwindConfig = './tailwind.config.js';
const tailwind = require('tailwindcss')(tailwindConfig);
const autoprefixer = require('autoprefixer');

module.exports = {
    plugins: [tailwind,  ...(process.env.HUGO_ENVIRONMENT === 'production' ? [autoprefixer] : [])],
};

To make it all work, you need to update Hugo configuration file like this:

baseURL: https://building.robinvanderknaap.dev/
languageCode: en-us
title: building.robinvanderknaap.dev
module:
   hugoVersion:
    extended: true
    min: "0.112.0"
   mounts:
     - source: assets
       target: assets
     - source: hugo_stats.json
       target: assets/watching/hugo_stats.json
build:
  writestats: true
  cachebusters:
  - source: assets/watching/hugo_stats\.json
    target: styles\.css
  - source: (postcss|tailwind)\.config\.js
    target: css
  - source: assets/.*\.(js|ts|jsx|tsx)
    target: js
  - source: assets/.*\.(.*)$
    target: $1

To be honest, I’m not exactly sure how this works, but this is what is recommended by the Hugo team to make everything work with Tailwind CSS. In general, this config makes sure the hugo_stats.json is generated so it can be watched by Tailwind. The cachebusters make sure the build is started on changes in hugo_stats.json, config files from PostCSS and Tailwind, JavaScript files and all other assets.

With all that in place, you can set up a Hugo pipe to generate the style sheet. Update the head section of the baseof.html template:

  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta author="Robin van der Knaap">
    <title>{{ .Site.Title }}</title>
    {{ $options := dict "inlineImports" true }}
    {{ $styles := resources.Get "css/styles.css" }}
    {{ $styles = $styles  | css.PostCSS $options }}
    {{ if hugo.IsProduction }}
      {{ $styles = $styles | minify | fingerprint | resources.PostProcess }}
    {{ end }}
    <link href="{{ $styles.RelPermalink }}" rel="stylesheet" >
  </head>

What’s happening here? First a resource is retrieved, css/styles.css. Then the asset is piped through PostCSS which will start the configured Tailwind plugin generating a complete CSS file.

The output of the PostCSS pipe is piped again during production builds: minify | fingerprint | PostProcess.

The output from all these pipes is than referenced as a stylesheet using the RelPermalink property, which is available on all resources.

All we need to do now is set the start script in package.json with all the recommended parameters for Hugo to work properly with TailwindCSS:

"start": "hugo server --disableFastRender --ignoreCache --noHTTPCache --buildDrafts --cleanDestinationDir",

Now let’s start the site again:

npm start

The header (H1) text on the homepage should be relatively small now, because Tailwind’s Preflight performed it’s CSS reset.

Tailwind is working now and fully integrated into the Hugo build pipeline.

I want to add a few more things before continuing. First, I want to setup an accent color, which is useful for highlighting stuff, mainly hyperlinks. Also, I want to add a font for headers, the one I like is a Google font called [Righteous](Righteous - Google Fonts).

To make both work, we need to update the theme section of the Tailwind config, so Tailwind can generate utility classes specifically for this color and font:

const colors = require('tailwindcss/colors')

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
   './hugo_stats.json',
   './assets/js/*.js'
 ],
 theme: {
  extend: {
    colors: {
      accent: colors.pink,
    },
    fontFamily: {
      'righteous': ['Righteous']
    }
  },
},
 plugins: [],
}

I’ve picked one of the existing colors of Tailwind to act as the accent color. For this to work the colors lib from Tailwind is loaded in the top of the script.

Load the font in the <head> element in the baseof.html template.

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Righteous&display=swap" rel="stylesheet">

The font can be utilized by adding the utility class font-righteous and use the accent color by adding the class text-accent-500. Let’s update the homepage to see the font and accent color in action:

<h1 class="text-5xl font-righteous text-accent-500">This is the home page</h1>

The result in your browser should look like this.

Tailwind’s Preflight provides a CSS reset, which is great, but not for styling markdown. The official Tailwind CSS Typography plugin provides a set of prose classes you can use to add beautiful typographic defaults. Which are needed, among others, for the blog articles.

npm install @tailwindcss/typography

Enable the plugin inside the Tailwind config file by adding it to the plugins array:

plugins: [
  require('@tailwindcss/typography'),
],

I’ll demonstrate the use of prose classes when the blog section is added to the website. We are also going to use a prose class for the about page.

1.8 Setup version control (GIT)

Before you move on, this is a good time to setup version control, if you don’t want to, you can skip this part.

If you want to be able to share your project it’s wise to setup a remote repository before initializing the local repository. GitHub is used in this example, but you can also chose to use GitLabs or any other remotely hosted Git repository.

First, create a repository on https://github.com. Create a public or private repository. Don’t create a .gitignore file, we will create that ourselves in our local repository. You can add a README and/or license if you want:

As you can see the default branch is called main on the GitHub repository. The name of the default branch in the local repository should be the same. Git uses master by default, but you can override this default. Initialize the local GIT repository with the following command to use main as the default branch name:

git init --initial-branch=main

Now, you can add the remote repository to your local repository. You can use VS code for this or use git from the command line:

git remote add origin [email protected]:robinvanderknaap/building.robinvanderknaap.dev.git

Make sure to use the correct git URL of your repository, you can’t use mine :) Pull the files from the remote repository:

git pull origin main

Before you push your changes to the remote repository, you need to setup a .gitignore file to exclude all Hugo generated files:

touch .gitignore
node_modules
.DS_STORE

# Generated files by hugo
/public/
/resources/_gen/
/assets/jsconfig.json
hugo_stats.json
.hugo_build.lock

Now commit the changes to the local repository:

git add .
git commit -m "Now we have a fully functional website"

And push the changes to the remote repository and set the upstream branch:

git push --set-upstream origin main

You now have your website up and running and everything under (version) control. As of this point, you decide for yourself when you want to commit and push the changes to your remote repository.

2. Create templates

In this chapter we will focus on the templates that will render our content. We’ve already created the baseof template which defines the skeleton of the website and we already created basic templates for single and list pages.

2.1 Base template

We will add three main sections to the body element of the baseof.html template:

  • Header, which will hold our logo and navigation menu.
  • Main section which will hold all our content.
  • Footer, which will hold some social media links and a copyright notice.

Replace the <body> section:

<body class="bg-white text-black antialiased dark:bg-gray-950 dark:text-white flex">
  <section class="xl:max-w-5xl min-h-dvh mx-auto px-4 xl:px-0 flex flex-col grow">
    <header class="py-10 bg-red-500">This is the header</header>
    <main class="grow">
      {{- block "main" . -}}{{- end -}}
    </main>
	<footer class="py-10 bg-red-500">This is the footer</footer>
  </section>
</body>

Let me explain what I did.

First we added some Tailwind classes to the body tag itself: A white background, black anti-aliased text, and we use the dark selector to specify a near black background color and white text for dark mode. The dark:selector is a nice example how Tailwind helps you design your page.

The body contains one <section> element which has a specified maximum width on large screens with the xl: selector. The minimal height of the section equals the height of the current view (min-h-dvh class). The section also functions as a flex container so we can position our three main elements: header, main and footer.

The header is on top with some vertical padding and a red background for now so we can clearly distinguish it.

We use the mainelement to hold our content, which is set to grow, so it will occupy the entire space between header and footer.

The footer is placed below the content. If the content does not fill the screen, the footer will still be placed on the bottom of the screen because of the grow class we added to the main element.

If you run the website it should look like this:

As you can see, the footer is placed on the bottom of the page.

2.2 Partials

It is usually a good practice to define things like head, header and footer in a partial template in a separate file to unclutter the baseof.html file. We will do that now. We can create files for each section and reference them in the base template so it doesn’t get crowded there.

Partial templates are placed inside the layouts/partials folder.

mkdir layouts/partials

Create three partials:

touch layouts/partials/head.html
touch layouts/partials/header.html
touch layouts/partials/footer.html

Now copy the relevant content of the baseof.html file into the partials:

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta author="Robin van der Knaap">
  <title>{{ .Site.Title }}</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Righteous&display=swap" rel="stylesheet">
  {{ $options := dict "inlineImports" true }}
  {{ $styles := resources.Get "css/styles.css" }}
  {{ $styles = $styles  | css.PostCSS $options }}
  {{ if hugo.IsProduction }}
      {{ $styles = $styles | minify | fingerprint | resources.PostProcess }}
  {{ end }}
  <link href="{{ $styles.RelPermalink }}" rel="stylesheet" >
</head>

<header class="py-10 bg-red-500">This is the header</header>

<footer class="py-10 bg-red-500">This is the footer</footer>

Replace the content in baseof.html with references to the partials like this:

<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
  {{ partial "head.html" . }}
  <body class="bg-white text-black antialiased dark:bg-gray-950 dark:text-white flex">
    <section class="xl:max-w-5xl min-h-dvh mx-auto px-4 xl:px-0 flex flex-col grow">
      {{ partial "header.html" . }}
      <main class="grow">
        {{- block "main" . -}}{{- end -}}
      </main>
      {{ partial "footer.html" . }}
    </section>
  </body>
</html>

If you start the site now, you will see exactly the same result as earlier.

I want to add one more partial, a scripts partial. This partial will be used to add JavaScript files to the website.

touch layouts/partials/scripts.html

Hugo contains a pipe for building JavaScript files with esbuild. Any JavaScript file can be transpiled and tree shaken using this pipe.

We’ll be using one entry file, called index.js which will hold references to all the other Javascript files we want to include.

mkdir assets/js
touch assets/js/index.js

We configure the build pipeline in our partial:

{{- $options := dict "targetPath" "js/bundle.js" -}}
{{- $jsBundle := resources.Get "js/index.js" | js.Build $options | resources.Minify | fingerprint -}}
<script src="{{ $jsBundle.RelPermalink }}" integrity="{{ $jsBundle.Data.Integrity }}" defer></script>

What is happening here? First we define the build options, specifying bundle.js as the output file for the build pipeline. Second, our file in the assets folder, index.js, is retrieved, build with the specified options, minified and fingerprinted. The resulting bundle is referenced with the <script> element.

Now we need to update our baseof.html template to include the scripts.html partial. The complete base template should look like this now. Notice that the scripts partial is placed exactly before the closing tag of the body. This is best place to reference scripts, to optimize page loads.

<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
  {{ partial "head.html" . }}
  <body class="bg-white text-black antialiased dark:bg-gray-950 dark:text-white flex">
    <section class="xl:max-w-5xl min-h-dvh mx-auto px-4 xl:px-0 flex flex-col grow">
      {{ partial "header.html" . }}
      <main class="grow">
        {{- block "main" . -}}{{- end -}}
      </main>
      {{ partial "footer.html" . }}
    </section>
    {{ partial "scripts.html" . }}
  </body>
</html>

Let’s implement the other partials.

2.3 Head

The <head> element contains important information about your web page. Web browsers use the information in the head to correctly render the HTML page.

We’ve already set some information in the <head> element:

We’ve set the language attribute inside the <html> element based on the site’s config file. This attribute defines the language that editable and non-editable elements are written in.

We’ve set three meta tags in the <head> element: charset, viewport and author.

  • charset: utf-8 is the only valid option for HTML5 documents and must be located in the first 1024 bytes of the document, so it’s a good idea to put this meta tag on top.
  • viewport: The browser’s viewport is the area of the window in which web content can be seen. The one we use is typical for mobile-optimized sites. Scalability is enabled by default to enhance accessibility.
  • author: Author of the website

We also set the <title> element, this sets the title of the document and is shown in the browser’s tab and when a page is bookmarked. Search engines typically display about the first 55–60 characters of a page title. The title should not be confused with the H1 element, which is the top level heading of your body’s content, it’s not the document’s title.

You often see other (proprietary) meta tags as well in web pages, such as Facebook’s Open Graph Data. We will add those meta tags in the SEO chapter of this guide, just as we will learn how to generate custom icons (favicons) for our website and add it to the head.

Htmlhead.dev is a nice resource, it lists almost all possible information you can add to the head element.

2.4 Header

We need a basic navigation bar containing the logo and name of the site, some navigation options to the left, a search option and a theme toggle (dark or light). Also we want the navigation to work when viewed on mobile devices by showing a mobile menu.

Copy the following HTML to update the header partial:

<header class="flex items-center justify-between py-3 sm:py-10">
  <div>
    <a aria-label="building.robinvanderknaap.dev" href="/">
      <div class="flex items-center justify-between">
        <div class="mr-3">
          <svg xmlns="http://www.w3.org/2000/svg" class="w-8 md:w-10 rounded-lg dark:bg-transparent" viewBox="0 0 128 128"><path fill="currentColor" d="M110 26.1L73.3 4.8c-6.6-3.8-14.7-3.7-21.2.3L19.2 25.4c-6.5 4.1-10.4 11-10.4 18.7v41.4c0 7.6 4.1 14.7 10.7 18.5L53 123.3c3.1 1.8 6.5 2.7 10 2.7c3.4 0 6.8-.9 9.8-2.6l36.4-20.5c6.2-3.5 10-10.1 10-17.2V42c0-6.5-3.5-12.6-9.2-15.9M52.5 67.9v29H38.6V30.5h13.9v24.2h23V30.5h13.9v66.4H75.5v-29z"/></svg>
        </div>
        <div class="hidden text-xl font-bold sm:block font-mono">building.robinvanderknaap.dev</div>
      </div>
    </a>
  </div>
  <nav class="flex items-center space-x-4 leading-5 sm:space-x-6">
    <a class="hidden font-medium text-gray-900 dark:text-gray-100 hover:text-accent-500 sm:block" href="/blog">Blog</a>
    <a class="hidden font-medium text-gray-900 dark:text-gray-100 hover:text-accent-500 sm:block" href="/tools">Tools</a>
    <a class="hidden font-medium text-gray-900 dark:text-gray-100 hover:text-accent-500 sm:block" href="/about">About</a>
    <button class="hover:text-accent-500" aria-label="Search">
      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6">
        <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"></path>
      </svg>
    </button>
    <button class="hover:text-accent-500" aria-label="Toggle Theme" data-theme-toggle>
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-6 w-6">
        <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
      </svg>
    </button>
    <button data-menu-toggle aria-label="Toggle Menu" class="sm:hidden">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-8 w-8 text-gray-900 dark:text-gray-100">
        <path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path>
      </svg>
    </button>
    <div id="mobileMenu" class="fixed left-0 top-0 z-10 h-full w-full transform bg-white opacity-95 duration-300 ease-in-out dark:bg-gray-950 dark:opacity-[0.98] translate-x-full">
      <div class="flex justify-end">
        <button class="mr-8 mt-6 h-8 w-8" data-menu-toggle aria-label="Toggle Menu">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="text-gray-900 dark:text-gray-100">
            <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
          </svg>
        </button>
      </div>
      <nav class="fixed h-full">
        <div class="px-12 py-4">
          <a class="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100" href="/blog">Blog</a>
        </div>
        <div class="px-12 py-4">
          <a class="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100" href="/tools">Tools</a>
        </div>
        <div class="px-12 py-4">
          <a class="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100" href="/about">About</a>
        </div>
      </nav>
    </div>
  </nav>
</header>

I’m not going to explain every class that I added. You can hover over them in Visual Studio Code and when the Tailwind extension is installed you can see what they do.

The first part of the header is reserved for the logo and name of the site. You can find the logo here: hugo icon from Devicon Plain - Iconify. I’ve pasted the SVG for convenience, we can easily change the width and specify the color in light and dark mode using Tailwind’s utility classes.

The second part is the navigation bar (<nav> element). Two menu’s have been defined. One for mobile and one for a wider view. Also three navigation links, a dark-mode toggle, search button and a hamburger for opening the mobile menu are added.

The mobile menu is hidden in mobile view. A toggle to open the menu is declared but not yet working. We need add a little JavaScript to make this happen.

Create a new JavaScript file in the assets folder:

touch assets/js/toggle-menu.js

Add the following content to the toggle-menu.js file:

(function() {
  const mobileMenu = document.getElementById('mobileMenu');

  document.querySelectorAll('[data-menu-toggle]').forEach(function(menuToggle) {
    menuToggle.addEventListener('click', function() {
      mobileMenu.classList.toggle('translate-x-full');
      mobileMenu.classList.toggle('translate-x-0');
    });
  });
})();

Reference the JavaScript file in index.js so it is included in the generated JavaScript bundle:

import './toggle-menu.js';

View your site in mobile view in your browser, and the menu should work now.

We will show some social links and a copyright message in the footer, notice the use of the text-accent-500 class for the social hyper links:

<footer>
  <div class="mt-16 flex flex-col items-center">
    <nav class="mb-3 flex space-x-4">
      <a class="text-sm transition" target="_blank" rel="noopener noreferrer" href="https://github.com/robinvanderknaap">
        <span class="sr-only">GitHub</span>
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="fill-current hover:text-accent-500 dark:text-gray-200 h-6 w-6">
          <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
        </svg>
      </a>
      <a class="text-sm transition" target="_blank" rel="noopener noreferrer" href="https://www.linkedin.com/in/robinvdknaap/">
        <span class="sr-only">LinkedIn</span>
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="fill-current hover:text-accent-500 dark:text-gray-200 h-6 w-6">
          <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path>
        </svg>
      </a>
      <a class="text-sm transition" target="_blank" rel="noopener noreferrer" href="https://twitter.com/robinvdknaap">
        <span class="sr-only">Twitter</span>
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="fill-current hover:text-accent-500 dark:text-gray-200 h-6 w-6">
          <path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"></path>
        </svg>
      </a>
      <a class="text-sm transition w-6" target="_blank" rel="noopener noreferrer" href="https://dev.to/robinvanderknaap">
        <span class="sr-only">dev.to</span>
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="fill-current hover:text-accent-500 dark:text-gray-200 h-6 w-6">
          <path d="M120.12 208.29c-3.88-2.9-7.77-4.35-11.65-4.35H91.03v104.47h17.45c3.88 0 7.77-1.45 11.65-4.35 3.88-2.9 5.82-7.25 5.82-13.06v-69.65c-.01-5.8-1.96-10.16-5.83-13.06zM404.1 32H43.9C19.7 32 .06 51.59 0 75.8v360.4C.06 460.41 19.7 480 43.9 480h360.2c24.21 0 43.84-19.59 43.9-43.8V75.8c-.06-24.21-19.7-43.8-43.9-43.8zM154.2 291.19c0 18.81-11.61 47.31-48.36 47.25h-46.4V172.98h47.38c35.44 0 47.36 28.46 47.37 47.28l.01 70.93zm100.68-88.66H201.6v38.42h32.57v29.57H201.6v38.41h53.29v29.57h-62.18c-11.16.29-20.44-8.53-20.72-19.69V193.7c-.27-11.15 8.56-20.41 19.71-20.69h63.19l-.01 29.52zm103.64 115.29c-13.2 30.75-36.85 24.63-47.44 0l-38.53-144.8h32.57l29.71 113.72 29.57-113.72h32.58l-38.46 144.8z"/>
        </svg>
      </a>
    </nav>
    <div class="mb-3 text-sm text-gray-500 dark:text-gray-400">
      <div>Robin van der Knaap © now.Year</div>
    </div>
  </div>
</footer>

2.6 Dark mode

The header already contains a toggle for the theme. Now we need to add some JavaScript to make it work. By default, Tailwind CSS uses the system setting to determine the theme. You can test this by setting your OS or browser into dark mode.

Chrome’s developer tools (F12) allows you to set the preferred color scheme:

The site should look like this in dark mode:

This is exactly what we want, but we also want the user to be able to select a theme manually and remember the user’s choice.

For this to work, you need to instruct Tailwind CSS to open up the ability to use a selector instead of reading the system settings. This can be done in the Tailwind config file:

/** @type {import('tailwindcss').Config} */
module.exports = {
  ...
  darkMode: 'selector',
  ...
}

Now, we can toggle the theme by adding a ‘dark’ class to the <html> element.

We need to attach a click handler to the theme selector in the header that adds/removes the dark class to the <html> element. We want to remember the user’s decision when the toggle is used and store the selected theme in local storage so it can be retrieved on the user’s next visit.

When the theme is not set in local storage, we want to use the user’s preferred color scheme, which is set at OS or browser level. Whenever the preferred color scheme changes, we want the theme to automatically update, as long as the toggle has not been used.

Create the following JavaScript asset:

touch assets/js/toggle-theme.js

Add the following script:

(function() {
    const setTheme = (isDarkMode) => {
      if (isDarkMode) {
        document.documentElement.classList.add('dark')
      } else {
        document.documentElement.classList.remove('dark');
      }
    }

    // Define windows media event listener separately, so we can remove it when the toggle is being used
    const windowsMediaEventListener = (event) => {
      setTheme(event.matches);
    }

    // Check if the theme is already set
    if(localStorage.getItem('theme')) {
      setTheme(localStorage.getItem('theme') === 'dark');
    } else {
      // When theme is not already set, check the user's preference
      // Check if the browser supports matchMedia first
      if (window.matchMedia) {
        // Set the theme based on the user's preference
        setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
        // Add event listener to the windows media, when OS theme changes, the website theme will change
        // We will remove this event listener when the theme is changed using the toggle
        window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', windowsMediaEventListener);
      }
    }

    const themeToggle = document.querySelector('[data-theme-toggle]');

    themeToggle.addEventListener('click', function() {
      if(document.documentElement.classList.contains('dark')) {
        setTheme(false);
        localStorage.theme = 'light';
      } else {
        setTheme(true);
        localStorage.theme = 'dark';
      }
      // Remove the event listener when the theme is changed using the toggle
      window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', windowsMediaEventListener);
    });
  })();

Reference the JavaScript file in index.js so it is included in the generated JavaScript bundle:

import './toggle-menu.js';
import './toggle-theme.js';

Reload the site and toggle the theme and see what happens. You can remove the theme key from local storage and play with the system settings to make sure everything works as expected.

2.7 404 Page not found

You probably noticed the navigation links in the header don’t work, because we didn’t provide any content for those pages. This brings us to our last template, the 404 Page not found template.

You can place the 404.html template directly in the root of the layouts folder:

touch layouts/404.html
{{ define "main" }}
<div class="flex flex-col items-center justify-center h-full">
  <h1 class="font-semibold text-8xl">404</h1>
  <p class="text-5xl">page not found</p>
  <blockquote class="text-xl italic font-semibold text-gray-900 dark:text-white/[0.8] mt-32 text-center">
    <p>"Don't let perfection stand in the way of progress."</p>
    <footer>— <a href="https://arnoldspumpclub.com/" target="_blank" class="text-accent-500">Arnold Schwarzenegger</a></footer>
  </blockquote>
</div>
{{ end }}

This is a basic 404 Page not found template, with some unsolicited advice from Arnold.

More 404 inspiration can be found all over the internet. I was tempted to use this one with Lionel Richie:

3. Create content

Now that we have the structure of our website ready, we can begin to add content. The content folder of Hugo can contain content in the form of HTML or markdown files, but can also contain images or other files. We will use HTML pages for the home and tools page. For the blog posts and the about page we will use markdown files.

3.1 Homepage

Let’s setup a proper homepage. I like to keep it simple:

---
title: 'Home'
description: 'Personal website of Robin van der Knaap'
---
<div class="flex flex-col justify-center h-full">
  <h1 class="text-5xl font-righteous">Robin van der Knaap</h1>
  <h2 class="text-xl mt-5">Lead developer and co-owner @ <a class="underline text-accent-500 dark:text-accent-500 font-semibold" href="https://communicatie-cockpit.nl">Communicatie Cockpit</a></h2>
</div>

I’ve added a description in the front matter, later on we will use this to set the description meta tag in the <head> element of the web page.

The main content in the baseof.html file is marked with the grow class, meaning the <main> element takes all the space between the header and footer. For this reason, we are now able to create a homepage which takes the full available height and center the content.

3.2 Tools I use

The last static HTML page is the Tools page. This includes all the tools I use and love on a daily basis. Create your own list, for your own blog.

Create the content file

mkdir content/tools
touch content/tools/index.html

Add the following content, and update the list to your own liking:

---
title: 'Tools I use'
description: 'Tools I use as a developer'
---
<div>
  <h1 class="text-3xl sm:text-5xl font-righteous mt-2 sm:mt-8 mb-8">Tools I use</h1>
  <hr class="mb-8">
  <div class="prose dark:prose-invert lg:prose-xl">
    <p>I'm always curious what tools other developers are using, this is my list:</p>
  </div>
  <h2 class="text-xl font-semibold my-3 dark:text-gray-100">Hardware</h2>
  <ul class="pl-8 list-disc">
    <li><a class="text-accent-500 font-semibold" href="https://www.apple.com/nl/macbook-pro/">Macbook Pro</a><span class="dark:text-gray-400"> - Fast & reliable</span></li>
    <li><a class="text-accent-500 font-semibold" href="https://www.razer.com/eu-en/gaming-mice/razer-deathadder-v2">Razer DeathAdder Chroma</a><span class="dark:text-gray-400"> - No more RSI complaints</span></li>
  </ul>
  <h2 class="text-xl font-semibold my-3 dark:text-gray-100">IDE's</h2>
  <ul class="pl-8 list-disc">
    <li><a class="text-accent-500 font-semibold underline" href="https://code.visualstudio.com/" target="_blank">Visual Studio Code</a><span class="dark:text-gray-400"> - My main IDE, mostly for frontend and NodeJS development</span></li>
    <li><a class="text-accent-500 font-semibold underline" href="https://www.jetbrains.com/datagrip" target="_blank">Datagrip - Jetbrains</a><span class="dark:text-gray-400"> - Cross-platform tool for relational and NoSQL databases</span></li>
  </ul>
  <h2 class="text-xl font-semibold my-3">Visual Studio Code extensions</h2>
  <ul class="pl-8 list-disc">
    <li><a class="text-accent-500 font-semibold underline" href="https://marketplace.visualstudio.com/items?itemName=dracula-theme.theme-dracula">Dracula Theme</a><span class="dark:text-gray-400"> - Lovely dark theme, I'm using the PRO version myself</span></li>
  </ul>
</div>

Now that we have the tools page, you might have noticed a navigation link is added to the left bottom corner of the home page pointing to the tools page. (restart Hugo if you don’t see it, the home page is probably cached). This happens because the homepage is considered a list page, and the default list layout is used. To prevent this, we can add a specific layout to the front matter of the homepage:

---
title: 'Home'
description: 'Personal website of Robin van der Knaap'
layout: 'single'
---

This will solve our problem, we also could have created a layout file specifically for the home page, but this is simpler and we don’t need any additional styling of the home page.

3.3 About

For the about page the content is formatted as markdown. Create a new content file for the about page.

mkdir content/about
touch content/about/index.md

At the front matter to the markdown file, and add your life story:

---
title: 'About'
description: 'About me'
type: 'about'
---
### TL;DR
I'm a full stack software developer with almost 20 years of experience. I'm the lead developer and co-owner of the [Communicatie Cockpit](https://communicatie-cockpit.nl). My current stack is Typescript, Node.js, Angular, PostgreSQL and Kubernetes.

### Long version
My name is Robin van der Knaap. I'm a software developer from the Netherlands. I started programming at a young age on my Commodore 64. My big dream back then was to make a game about Transformers, unfortunately it never turned into reality, but the seeds of my current career were planted during that time...

Notice the ’type’ property in the front matter, this is used to match the correct layout file later on.

If you visit the about page, it will look a little bleak:

The default single template is used as layout file. We need to create a layout file, specifically for the about page to make it look good.

Before we add the layout, we need to take a little detour. I want the about page to display the social links that are also shown in the footer. We don’t want to re-hash the same HTML and classes, so we should create a partial which can be used in the footer and in our about page:

touch layouts/partials/socials.html

Copy the socials, including <nav> element, from the footer partial, or copy from here:

<nav class="mb-3 flex space-x-4">
  <a class="text-sm transition" target="_blank" rel="noopener noreferrer" href="https://github.com/robinvanderknaap">
    <span class="sr-only">GitHub</span>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="fill-current hover:text-accent-500 dark:text-gray-200 h-6 w-6">
      <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
    </svg>
  </a>
  <a class="text-sm transition" target="_blank" rel="noopener noreferrer" href="https://www.linkedin.com/in/robinvdknaap/">
    <span class="sr-only">LinkedIn</span>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="fill-current hover:text-accent-500 dark:text-gray-200 h-6 w-6">
      <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path>
    </svg>
  </a>
  <a class="text-sm transition" target="_blank" rel="noopener noreferrer" href="https://twitter.com/robinvdknaap">
    <span class="sr-only">Twitter</span>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="fill-current hover:text-accent-500 dark:text-gray-200 h-6 w-6">
      <path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"></path>
    </svg>
  </a>
  <a class="text-sm transition w-6" target="_blank" rel="noopener noreferrer" href="https://dev.to/robinvanderknaap">
    <span class="sr-only">dev.to</span>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="fill-current hover:text-accent-500 dark:text-gray-200 h-6 w-6">
      <path d="M120.12 208.29c-3.88-2.9-7.77-4.35-11.65-4.35H91.03v104.47h17.45c3.88 0 7.77-1.45 11.65-4.35 3.88-2.9 5.82-7.25 5.82-13.06v-69.65c-.01-5.8-1.96-10.16-5.83-13.06zM404.1 32H43.9C19.7 32 .06 51.59 0 75.8v360.4C.06 460.41 19.7 480 43.9 480h360.2c24.21 0 43.84-19.59 43.9-43.8V75.8c-.06-24.21-19.7-43.8-43.9-43.8zM154.2 291.19c0 18.81-11.61 47.31-48.36 47.25h-46.4V172.98h47.38c35.44 0 47.36 28.46 47.37 47.28l.01 70.93zm100.68-88.66H201.6v38.42h32.57v29.57H201.6v38.41h53.29v29.57h-62.18c-11.16.29-20.44-8.53-20.72-19.69V193.7c-.27-11.15 8.56-20.41 19.71-20.69h63.19l-.01 29.52zm103.64 115.29c-13.2 30.75-36.85 24.63-47.44 0l-38.53-144.8h32.57l29.71 113.72 29.57-113.72h32.58l-38.46 144.8z"/>
    </svg>
  </a>
</nav>

Adjust the footer to use the partial:

<footer>
  <div class="mt-16 flex flex-col items-center">
    {{ partial "socials.html" . }}
    <div class="mb-3 text-sm text-gray-500 dark:text-gray-400">
      <div>Robin van der Knaap © {{ now.Year }}</div>
    </div>
  </div>
</footer>

Now we can create the layout file for the about page.

mkdir layouts/about
touch layouts/about/single.html

We will layout the about page with a flex container: we create a 2 column layout for larger screens, and a 1 column layout for smaller screens:

{{ define "main" }}
<div>
  <h1 class="text-3xl sm:text-5xl font-righteous mt-2 sm:mt-8 mb-8">About me</h1>
  <hr class="mb-8">
  <div class="flex flex-col items-center md:flex-row md:items-start md:justify-between">
    <aside class="flex flex-col items-center flex-none w-60">
      <img src="me.jpg" alt="Robin van der Knaap" class="w-56 rounded-full"/>
      <h2 class="text-2xl leading-8 tracking-tight font-righteous mt-8">Robin van der Knaap</h2>
      <div class="mt-5 flex flex-col items-center">
        <nav class="mb-3 flex space-x-4">
          {{ partial "socials.html" . }}
        </nav>
      </div>
    </aside>
    <article class="flex-grow md:pl-8 prose dark:prose-invert lg:prose-xl prose-a:text-accent-500">
      {{- .Content -}}
    </article>
  </div>
</div>
{{ end }}

Notice the partial we injected in the content file: {{ partial "socials.html" . }}.

Add a nice picture of yourself, call it me.jpg, and put it in the content/about folder. The rounded-full class will make sure the picture is rendered as a circle.

The article is marked with the prose class, this triggers the typography utilities that we installed with Tailwind and transforms our markdown in nicely crafted content.

The about page should look like this now:

4. Add Blog

Now, we arrived at the most important part of the website, the blog. We will use markdown files for all our blog articles and create specific layouts for blog articles and list pages.

4.1 Blog archetype

As explained earlier, archetypes are a convenient way to generate content according to a pre-defined template. This especially comes in handy for generating the front matter of our articles, so we don’t accidentally forget to set any of the properties. Let’s create a blog archetype which we will use to generate new blog articles.

mkdir -p archetypes/blog
touch archetypes/blog/index.md

Add the following front matter:

---
title: '{{ replace .File.ContentBaseName "-" " " | title }}'
date: {{ .Date }}
draft: true
tags:
description:
image:
---

Now let’s generate a blog article using this archetype

npm run hugo new content blog/lorem-ipsum

You should see a blog item appear in your content folder:

---
title: 'Lorem Ipsum'
date: 2024-09-02T16:09:45+02:00
draft: true
tags:
description:
image:
---

The following properties are used in the front matter of each blog article:

  • Title: Title of the article which will be displayed in the list and single page, also used by search engines.
  • Date: Date the post is created, you could also set the date in the future which ensures the post is only shown after this date (after rebuild).
  • Draft: Default true, which protects you from prematurely publishing the article in production.
  • Tags: You can add any tag you want to this page, it’s called a taxonomy. We can create special tag pages, listing only articles with a specific tag.
  • Description: Used in the list page to show a quick summary, also used by search engines
  • Image: This image is used in both the list and single page.

4.2 Content

Let’s add some content to our blog and see how it is rendered when we don’t have specific layout files for blog articles. We will create two blog articles. One containing content generated by a Lorem Ipsum generator and the other one contains code samples so we can test syntax highlighting for which Hugo has out-of-the-box support.

Add the following content to the lorem-ipsum.md we just created:

---
title: 'Lorem Ipsum'
date: 2024-09-02T16:09:45+02:00
draft: true
tags:
description: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse dictum magna eu ornare luctus
image:
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse dictum magna eu ornare luctus. Morbi in hendrerit lacus, ut malesuada ligula. Vivamus id rhoncus nibh, eget tempus felis. Morbi vel eros a nibh viverra mollis. Donec euismod vehicula nulla quis luctus. Nullam non cursus est. Quisque eu nibh in est lacinia blandit. Cras feugiat et mi at iaculis. Aliquam sagittis sapien ac purus vestibulum, vel porttitor dolor aliquam. Suspendisse consequat metus at risus varius, faucibus ultrices erat accumsan.

Vestibulum dictum mollis feugiat. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Fusce ornare, risus quis luctus faucibus, est metus aliquam diam, vitae vehicula nulla turpis id dui. Sed pulvinar semper lectus et rutrum. Nulla interdum nibh quis turpis congue, eget commodo magna iaculis. In semper, metus in fermentum mattis, nisl lorem condimentum tellus, non ullamcorper nunc ex ac enim. Integer eget eros non arcu rutrum pulvinar. Nullam varius erat mi. Suspendisse consectetur ultrices libero, sit amet placerat justo laoreet at. Mauris dictum rutrum nulla a faucibus. Proin interdum urna ac ornare vehicula. Nullam a tristique lectus. Proin sodales ut metus ac suscipit. Etiam molestie, elit sed ullamcorper placerat, odio nunc posuere tellus, scelerisque mollis quam orci non nunc.

Cras gravida egestas enim, nec elementum leo consectetur at. Nullam gravida mauris nec diam vulputate, ut dignissim enim vestibulum. Nullam lobortis est magna, egestas aliquam nulla consectetur a. Curabitur varius semper nisi non euismod. Donec a dui libero. Vivamus in tortor sed arcu fermentum aliquet. Quisque elementum erat ut laoreet vulputate. In venenatis, ante eget ultricies elementum, lorem lacus vulputate dolor, ut scelerisque sapien nibh non odio.

Now generate the syntax highlighting example using our archetype:

npm run hugo new content blog/syntax-highlighting

And add the following content to syntax-highlighting.md:

---
title: 'Syntax Highlighting'
date: 2024-09-02T16:13:30+02:00
draft: true
tags:
description: Demonstration of syntax highlighting with Hugo
image:
---
This is a shell command
```shell
npm install
```

This is some HTML
```html
<div class="main">
</div>
```

View the results at http://localhost:1313/blog

Remember, top level directories are branch bundles by default, they don’t need an _index.html file. So, when visiting /blog, we’ll see the default list.html template is used to show the underlying pages, which are our two blog articles.

The list page should look like this:

When you click the links to the articles they should look like this:

And

Nothing fancy, but we can see syntax highlighting is already working.

4.3 Templates

We will start with a template for the blog articles. We will use Tailwind’s prose classes again just like we did with the about page. This template will override the default single page template.

mkdir layouts/blog
touch layouts/blog/single.html

Add the following content to the single template:

{{ define "main" }}
{{ if .Params.image }}
  {{ with .Resources.Get .Params.image }}
    <div class="mt-16 mb-20">
      <img src="{{ .RelPermalink}}" alt="Header image" class="mx-auto max-w-96 max-h-56"/>
    </div>
  {{ end }}
{{ end }}
<h1 class="text-3xl lg:text-5xl font-righteous mb-8 text-center break-anywhere">{{ .Page.Title }}</h1>
<h2 class="text-center lg:text-lg font-mono font-medium text-gray-500 dark:text-gray-400 mb-8">{{ time.Format "January 02, 2006" .Page.Date }}</h2>
<div>
  <article class="prose dark:prose-invert lg:prose-xl break-anywhere prose-a:text-accent-500 mx-auto [&_code]:text-wrap">
    {{ .Content }}
  </article>
</div>
{{ end }}

If you look at the blog articles, much has been improved, readability is excellent due to Tailwind’s prose classes and the syntax highlighting looks pretty decent. Restart Hugo if you don’t see any changes.

Notice how the date of the article is displayed. Formatting dates and times in Hugo, or actually Go templates, is a bit weird at first. Go uses a reference time for formatting dates and times. The reference time is 2 January 2006 03:04:05 PM UTC -7. You need to use this exact date in the format string. So, in our blog article we use “January 02, 2006” as our format string. If, for example, our blog post is dated June 6 in the year 2024, the output from the time.Format function will be: “June 06, 2024”.

One noticeable Tailwind trick: On the article element, the following class is defined: [&_code]:text-wrap. This is neat way of styling (deep) nested child elements. In this case, all code elements are set to wrap, because we don’t want to scroll source code from left to right. This technique is called arbitrary-variants.

We also set a class called break-anywhere on the <h1> element which holds the title. The class is also applied on the <article> element. It is meant to help with wrapping titles and text, especially on small screens, it instructs the browser to break inside words if needed. This is not a utility-class from Tailwind, but a custom utility class. You have to add it to styles.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

@supports (overflow-wrap: anywhere) {
  .break-anywhere {
    overflow-wrap: anywhere;
  }
}
@supports not (overflow-wrap: anywhere) {
  .break-anywhere {
    word-break: break-word;
  }
}

If a header image is set, it is loaded as resource and added at the top of the article. Let’s add an image to the lorem ipsum article’s front matter:

---
title: 'Lorem Ipsum'
date: 2024-09-03T16:09:45+02:00
draft: true
tags:
description: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse dictum magna eu ornare luctus
image: lorem-ipsum-banner.png
---

I used the following image from www.freeimages.co.ukas banner but you can add any image you like. You need to store the image in the ./content/blog/lorem-ipsum folder:

If you open the site now, it will show an image above the lorem ipsum article. We won’t add an image to the syntax highlight article.

We also want to improve our blog list page. Start with creating a list template specifically for blogs:

touch layouts/blog/list.html

Add the following content:

{{ define "main" }}
<ul class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
  {{ range .Pages }}
    <li class="">
      <a href="{{ .RelPermalink }}">
        <div class="bg-white border rounded-lg dark:bg-gray-800 dark:border-gray-700" style="box-shadow: 0px 0px 40px rgba(29, 58, 83, 0.15);">
          <div class="p-5 h-56 flex align-middle justify-center rounded-t-lg">
            {{- $imgParams := .Params.image }}
            {{- $image := resources.Get "images/shrug.svg" }}
            {{ if $imgParams }}
              {{- $image = .Resources.Get $imgParams }}
            {{ end }}
            {{ with $image }}
              <img class="rounded-t-lg" src="{{ .RelPermalink }}">
            {{ end }}
          </div>
          <div class="p-5">
            <div class="h-56 overflow-clip">
              <h2 class="mb-2 text-2xl text-gray-900 dark:text-white font-righteous">{{ .LinkTitle }}</h2>
              <p class="mb-3 font-normal text-gray-700 dark:text-gray-400">{{ .Description }}</p>
            </div>
          </div>
        </div>
      </a>
    </li>
  {{ end }}
</ul>
{{ end }}

The images from the front matter of the articles also appear on the list page. We implemented a fall-back image in the list template: shrug.svg from : Shrug Vector SVG Icon - SVG Repo when no image is specified, like in the syntax highlight article. You need to add the shrug.svg to the ./assets/images folder, create the folder when it does not exist.

mkdir ./assets/images

The assets folder contains resources that are generally available in all pages.

The result should look like this:

blog-list.png

Notice the title and description are also used for creating the cards. Test dark mode to see how that looks like.

4.4 Syntax highlighting

Hugo already gives us syntax highlighting out-of-the-box. I’m not a big fan of the default theme, but luckily we have some options. Hugo uses Chroma highlighting, you can find a list of styles here: Chroma Style Gallery and pick one you like.

I’m very fond of the Dracula theme, I even bought the PRO version for my desktop apps. It’s easy to change the theme for syntax highlighting, it only takes one setting in the Hugo config file:

markup:
  highlight:
    style: dracula

For tutorials like this one, I like to display the filenames on top of the highlighted code. Also, to make it easier for the reader to follow along, I like to add a button to copy the content of the highlighted code. To achieve this we first need to find a way to add the filename in the markup of the source code, than we need a little JavaScript to make it visible.

We can add a filename by setting a title attribute to the markdown fences in the blog article:

---
title: 'Syntax Highlighting'
date: 2024-09-02T16:13:30+02:00
draft: true
tags:
description: Demonstration of syntax highlighting with Hugo
image:
---
This is a shell command
```shell {title="shell"}
npm install
```

This is some HTML
```html {title="./index.html"}
<div class="main">
</div>
```

Both code blocks have the title attributes added to the code fences. First is called shell, which isn’t a filename of course. The second is ./index.html.

The title attribute will be added to the generated HTML. Inspect the code block in your browser with the developer tools. You should see something like this:

<div class="highlight" title="shell">

This doesn’t display a filename yet, but now the title of the HTML element is set, we can add some JavaScript to fetch the title attribute and add a HTML element to display the value of the title.

First create the JavaScript file:

touch assets/js/show-code-titles.js

Add the following content:

(function(){
  const codeElements = document.getElementsByClassName("highlight");

  for (var i = 0; i < codeElements.length; i++) {
    if (codeElements[i].title.length) {
      codeElements[i].firstChild.style.margin = 0;
      codeElements[i].firstChild.style['border-radius'] = 0;
      codeElements[i].firstChild.style['border-bottom-left-radius'] = '8px';
      codeElements[i].firstChild.style['border-bottom-right-radius'] = '8px';

      const titleNode = document.createTextNode(codeElements[i].title);
      const copyButtonNode = document.createElement('button');

      copyButtonNode.innerHTML = `copy`;
      copyButtonNode.style['float'] = 'right';
      copyButtonNode.title = 'Copy code to clipboard';

      const code = codeElements[i].childNodes[0].textContent;

      copyButtonNode.addEventListener("click", () => {
        navigator.clipboard.writeText(code.trim()).then(() => {
          copyButtonNode.innerHTML = `copied`;
          setTimeout(() => {
            copyButtonNode.innerHTML = `copy`;
          }, 1000);
        });
      });

      const newNode = document.createElement("div");

      newNode.appendChild(titleNode);
      newNode.appendChild(copyButtonNode);
      newNode.classList.add("bg-gray-700", "text-gray-200", "text-sm", "font-mono", "py-2", "px-4", "lg:py-3", "lg:px-6");
      newNode.style['border-top-left-radius'] = '8px';
      newNode.style['border-top-right-radius'] = '8px';
      newNode.style['font-weight'] = '700';

      codeElements[i].parentNode.insertBefore(newNode, codeElements[i]);
    }
  }
})();

What this script basically does is looking for elements with the class highlight, which are the highlighted code blocks. When this class is found AND a title attribute is found, it will add an extra node on top of the code block. The new node contains the value of the title attribute and a copy button which adds the content of the code to the clipboard.

Add our script to index.js:

import './show-code-titles.js';

To see the results, you usually need to restart Hugo:

Try the copy button and make sure iit works as expected.

4.5 Table of contents

For a long article, like the one you are currently reading, it is nice to have a table of contents for quick navigation. Hugo is able to create a table of contents of the current page based on the headers found inside your content.

The table of contents is generated by calling a method on the Page object, like this

{{ .TableOfContents }}

We will add this to our baseof.html file. We will show the table of contents as a side panel in the far right of the screen, only visible when enough screen real estate is available. The complete baseof.html should look like this:

<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
  {{ partial "head.html" . }}
  <body class="bg-white text-black antialiased dark:bg-gray-950 dark:text-white flex">
    <section class="xl:max-w-5xl min-h-dvh mx-auto px-4 xl:px-0 flex flex-col grow">
      {{ partial "header.html" . }}
      <main class="grow">
        {{- block "main" . -}}{{- end -}}
      </main>
      {{ partial "footer.html" . }}
    </section>
    {{ if .Params.showTOC }}
    <aside class="text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-900 hidden xl:block">
      <nav class="p-8 overflow-y-scroll hide-scrollbar max-h-screen [&_a]:text-primary-500 [&_nav]:leading-loose [&_li_li]:ps-4 [&_li]:truncate hover:[&_a]:text-accent-500 sticky top-0">
      <h2 class="text-xl font-semibold">Contents</h2>
      {{ .TableOfContents }}
      </nav>
    </aside>
    {{ end }}
    {{ partial "scripts.html" . }}
  </body>
</html>

Notice the call to .TableOfContents on the page object, which will generate an unordered list containing all your headings.

The TOC is placed next to the article, the <nav> element is marked with the sticky class, so it will always be visible when the content is scrolled down.

The table of contents is only rendered when the page has a param showTOC and is set to true.

I’ve used a hide-scrollbar class, to make sure the TOC does not have a visible scrollbar. This class is not part of Tailwind, we need to add the following custom CSS to our styles.css class to make it work:

/* Hide scrollbar for Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar {
  display: none;
}

/* Hide scrollbar for IE, Edge and Firefox */
.hide-scrollbar {
  -ms-overflow-style: none;  /* IE and Edge */
  scrollbar-width: none;  /* Firefox */
}

Add the showTOC param to our lorem ipsum article, and update the content with some headers which will show up in the table of contents:

---
title: 'Lorem Ipsum'
date: 2024-09-03T16:09:45+02:00
draft: true
tags:
description: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse dictum magna eu ornare luctus
image: lorem-ipsum-banner.png
showTOC: true
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse dictum magna eu ornare luctus. Morbi in hendrerit lacus, ut malesuada ligula. Vivamus id rhoncus nibh, eget tempus felis. Morbi vel eros a nibh viverra mollis. Donec euismod vehicula nulla quis luctus. Nullam non cursus est. Quisque eu nibh in est lacinia blandit. Cras feugiat et mi at iaculis. Aliquam sagittis sapien ac purus vestibulum, vel porttitor dolor aliquam. Suspendisse consequat metus at risus varius, faucibus ultrices erat accumsan.

## Vestibulum
Vestibulum dictum mollis feugiat. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Fusce ornare, risus quis luctus faucibus, est metus aliquam diam, vitae vehicula nulla turpis id dui. Sed pulvinar semper lectus et rutrum. Nulla interdum nibh quis turpis congue, eget commodo magna iaculis. In semper, metus in fermentum mattis, nisl lorem condimentum tellus, non ullamcorper nunc ex ac enim. Integer eget eros non arcu rutrum pulvinar. Nullam varius erat mi. Suspendisse consectetur ultrices libero, sit amet placerat justo laoreet at. Mauris dictum rutrum nulla a faucibus. Proin interdum urna ac ornare vehicula. Nullam a tristique lectus. Proin sodales ut metus ac suscipit. Etiam molestie, elit sed ullamcorper placerat, odio nunc posuere tellus, scelerisque mollis quam orci non nunc.

## Cras
Cras gravida egestas enim, nec elementum leo consectetur at. Nullam gravida mauris nec diam vulputate, ut dignissim enim vestibulum. Nullam lobortis est magna, egestas aliquam nulla consectetur a. Curabitur varius semper nisi non euismod. Donec a dui libero. Vivamus in tortor sed arcu fermentum aliquet. Quisque elementum erat ut laoreet vulputate. In venenatis, ante eget ultricies elementum, lorem lacus vulputate dolor, ut scelerisque sapien nibh non odio.

Now if you rebuild the website the lorem ipsum article should look like this:

If you click the links, the content should jump to the correct position.

The address in your browser is also updated once you click the navigation links.

You can configure the table of contents in the Hugo configuration file. add the following to the markup section:

markup:
  highlight:
    style: dracula
  tableOfContents:
    startLevel: 2
    endLevel: 6
    ordered: false

Startlevel and EndLevel indicate which headings should be included in the table of contents. Hugo only includes H2 and H3 headings in the TOC by default. Above settings make sure headings H2-H6 are included.

By default Hugo creates an unordered list of all navigation items, resulting in a <ul> element. When the ordered setting is set to true it will render an ordered list, <ol>. We will leave it at unordered in our settings.

Now that we have a nice TOC, which helps us with handling large articles, we want one more thing: We want to show the active link, also when content is scrolled manually.

Add the following JavaScript to your assets folder:

touch ./assets/js/nav.js
window.addEventListener('DOMContentLoaded', () => {
	const observer = new IntersectionObserver(entries => {
		entries.forEach(entry => {
			const id = entry.target.getAttribute('id');

			if (entry.intersectionRatio > 0) {
        document.querySelectorAll(`#TableOfContents li a`)?.forEach((section) => section.classList.remove('text-accent-500', 'font-medium'));
				document.querySelector(`#TableOfContents li a[href="#${id}"]`)?.classList.add('text-accent-500', 'font-medium');
			}
		});
	});

	// Track all headings that have an `id` applied
	document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]').forEach((section) => {
		observer.observe(section);
	});
});

This script will use the IntersectionObserver browser API, and will observe all heading elements which have an id set. When a heading appears in the current view of the browser, some classes are added to the related item in the table of contents which highlights the item.

To include the JavaScript, add it to the index.js file:

import './nav.js';

When you rebuild the website, the active link should be highlighted now in the table of contents.

Now that we have our content, we want to offer visitors a search function to be able to retrieve the content they are interested in. We don’t have a backend, so we need a solution which is able to handle static content.

Several tools exist to provide search functionality, both open-source and commercial offerings. We’ll be using Pagefind for our website, which is open-source. Pagefind is able to create a search index from static content, provides a search API, a search UI and is relatively easy to implement.

5.1 Pagefind

For Pagefind to index our content, it requires a folder with all the built static content of our website. With Hugo this is easy. We only need to build the site, and point Pagefind to the ./public folder containing the output of the build.

After indexing, Pagefind will add a static search bundle to the built files, containing a JavaScript search API which can be utilized in your site. Pagefind also provides a UI out-of-the-box, which is easily integrated in your site.

To get started, install Pagefind as a dependency:

npm i pagefind

Alter the scripts section in your package.json to include Pagefind.

"scripts": {
  "start": "hugo server --disableFastRender --ignoreCache --noHTTPCache --buildDrafts --cleanDestinationDir",
  "hugo": "hugo",
  "hugo:build": "hugo --minify && pagefind --site public",
  "pagefind": "pagefind --site public --output-subdir ../static/pagefind"
},

The hugo:build command has been extended with the pagefind command, due to the && it will index the ./public folder after Hugo’s build is finished. By default, Pagefind will add a folder called pagefind to the public folder containing the index, the JavaScript API and the UI. This is perfect for production builds.

During development, however, the public folder is often emptied, and the pagefind folder will be gone. To solve this problem, a pagefind command is also added to the scripts section, which instructs Pagefind to output the results to the ./static/pagefind folder. Whenever Hugo is build, the pagefind folder is copied to the root of the public folder, just like any other static file. To be clear, we only use this command during development.

Add /static/pagefind to your .gitignore file to make sure the output of Pagefind is not polluting the repository and possibly the production build.

# Generated files by pagefind
/static/pagefind

As said, Pagefind is only able to index our site after it has been built by Hugo, this way it indexes the actual HTML as in production. So, before we run Pagefind, we need to let Hugo run the build. Delete the output folder to be sure we only index relevant content:

rm -rf public
npm run hugo

Now we can run Pagefind locally:

npm run pagefind

Pagefind will index our public folder and stores the results in our static folder. Check the ./static folder and make sure it includes the pagefind folder.

5.2 Add search UI

To enable the search UI you need to add pagefind-ui.js and pagefind-ui.css to our site, both files are generated when the Pagefind command is fired and are placed in the pagefind folder in the static folder.

Include a reference to the pagefind-ui.js in our scripts partial:

<script src="/pagefind/pagefind-ui.js" type="text/javascript"></script>

Add a reference to the pagefind-ui.css file to the <head> element of your site.

<link rel="stylesheet" href="/pagefind/pagefind-ui.css">

I want the search UI to appear in a modal. Partial layouts are perfect for modals, so let’s create one for the search UI:

touch ./layouts/partials/search.html

Add the following markup to the partial:

<div id="search-modal" aria-hidden="true" class="hidden">
  <div tabindex="-1" data-micromodal-close class="absolute inset-0 bg-opacity-60 bg-black flex items-center justify-center">
    <div role="dialog" aria-modal="true" aria-labelledby="search-title" class="w-full sm:w-3/4 xl:w-1/3 bg-white dark:bg-slate-800 p-5 rounded-lg max-h-[80vh] overflow-y-auto" >
      <header class="flex justify-between mb-6">
        <h2 id="search-title" class="text-2xl font-righteous">
          Search
        </h2>
        <button aria-label="Close modal" data-micromodal-close class="font-bold before:content-['\2715']"></button>
      </header>
      <div id="search"></div>
    </div>
  </div>
</div>

Notice the <div id="search"></div>. This is were the Pagefind UI will be inserted.

Reference the search modal in baseof.html just above the scripts partial::

{{- partial "search.html" . -}}

For reference, the complete baseof.html now looks like this:

<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
  {{ partial "head.html" . }}
  <body class="bg-white text-black antialiased dark:bg-gray-950 dark:text-white flex">
    <section class="xl:max-w-5xl min-h-dvh mx-auto px-4 xl:px-0 flex flex-col grow">
      {{ partial "header.html" . }}
      <main class="grow">
        {{- block "main" . -}}{{- end -}}
      </main>
      {{ partial "footer.html" . }}
    </section>
    {{ if .Params.showTOC }}
    <aside class="text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-900 hidden xl:block">
      <nav class="p-8 overflow-y-scroll hide-scrollbar max-h-screen [&_a]:text-primary-500 [&_nav]:leading-loose [&_li_li]:ps-4 [&_li]:truncate hover:[&_a]:text-accent-500 sticky top-0">
      <h2 class="text-xl font-semibold">Contents</h2>
      {{ .TableOfContents }}
      </nav>
    </aside>
    {{ end }}
    {{- partial "search.html" . -}}
    {{ partial "scripts.html" . }}
  </body>
</html>

The modal is hidden initially. We need to add some CSS to our styles.css to make sure the modal is visible when it is opened:

#search-modal.is-open {
  display: block;
}

We will be using the micromodal library to hide and show the search modal. Install the library as a dependency:

npm i micromodal

Now we need to initialize the Pagefind UI, and instruct it to use the #search element in our modal. Create a file called pagefind.js in the ./assets/js folder:

touch ./assets/js/pagefind.js

Add the following content:

import MicroModal from 'micromodal';

window.addEventListener('DOMContentLoaded', (event) => {
  new PagefindUI({
    element: "#search",
    showSubResults: true,
    showImages: false,
    resetStyles: false
  });
  MicroModal.init();
});

Notice the import of the Micromodal library.

Add a reference to pagefind.js in index.js:

import './pagefind.js'

We already created a search button in our header. To enable the modal, we need to add a trigger to the search button: data-micromodal-trigger="search-modal". The micromodal library will add the class is-open which will make the modal visible.

Find the button in header.html, and add the trigger:

<button class="hover:text-accent-500" aria-label="Search" data-micromodal-trigger="search-modal">

Now, rebuild the site, and make sure the search works. Search for ’lorem’, it should find our article. It even shows the sections on the page were ’lorem’ is found.

5.3 Configure search results

By default, Pagefind starts indexing from the <body> element. To narrow this down, we can tag the main content area with the data-pagefind-body attribute. If the data-pagefind-body attribute is found anywhere on your site, any page without this attribute will NOT be indexed.

We will include the data-pagefind-body attribute to the content of our blog articles, the about page and the tools page. We don’t want to index the home page or blog list page. Also, we don’t want to index automatically generated taxonomy pages, which we will talk about in the next chapter.

First add the data-pagefind-body attribute to the content area of our blog articles:

<article class="prose dark:prose-invert lg:prose-xl break-anywhere prose-a:text-accent-500 mx-auto [&_code]:text-wrap" data-pagefind-body>
  {{ .Content }}
</article>

Do the same for the about page:

<article class="flex-grow md:pl-8 prose dark:prose-invert lg:prose-xl prose-a:text-accent-500" data-pagefind-body>
      {{- .Content -}}
</article>

We need to to add the data-pagefind-body attribute to the <div> element wrapping the tools page:

<div data-pagefind-body>
  <h1 class="text-3xl sm:text-5xl font-righteous mt-2 sm:mt-8 mb-8">Tools I use</h1>
  ...
</div>

Now remove the public folder again and build the site:

rm -rf public
npm run hugo

And run Pagefind:

npm run pagefind

Now only the designated area’s are indexed.

Pagefind has built-in elements that are not indexed. These are organizational elements such as <nav> and <footer>, or more programmatic elements such as <script> and <form>. These elements will be skipped over automatically.

If you have other elements that you don’t want to include in your search index, you can tag them with the data-pagefind-ignore attribute.

5.4 Styling

As you might have noticed, search results in dark mode are hardly readable. Luckily we are able to style the search UI.

To enable dark mode, you need to add the following CSS to the styles.css file:

html.dark {
  --pagefind-ui-primary: #eeeeee;
  --pagefind-ui-text: #eeeeee;
  --pagefind-ui-background: #152028;
  --pagefind-ui-border: #152028;
  --pagefind-ui-tag: #152028;
}

Search results should look a lot better now in dark mode. More configuration options for the search UI of Pagefind, can be found here: https://pagefind.app/docs/ui/.

6. Generated outputs

When you examine the ./public output folder, you might notice Hugo renders a lot more pages than we’ve created. The following pages are created by default:

  • Tags section
  • Categories section
  • Index.xml files for each list, including one for tags and categories
  • sitemap.xml

Tags and categories are taxonomies used for grouping content, the index.xml files are generated RSS feeds. We will take a look at them in this chapter. We will handle the sitemap in the chapter about SEO.

6.1 Taxonomies

Hugo supports the grouping of content with so-called taxonomies. Taxonomies are classifications of content, like tags or categories. You can create as much taxonomies as you want and Hugo will happily generate the pages per taxonomy for you. It will also generate a page for each unit of a taxonomy with all related content, this is called a term page.

For example, when the tags taxonomy is enabled, the following pages are generated:

  • A list page with all tags
  • List page per tag, listing all content with the specific tag (term page)
  • RSS feed for all tags
  • RSS feed per tag

Category and tag taxonomies are enabled by default. For my blog I only want to use tags. We can configure this in hugo.config. We need to specify the singular form of a taxonomy as key and the plural form as value. Again, you can name the taxonomy yourself, it doesn’t need to be called tags or categories. Specify a singular and plural and it will work.

taxonomies:
  tag: tags

Now we can add tags to our content in the front matter and Hugo will render the pages. Let add some tags to the front matter of our lorem ipsum article:

---
title: 'Lorem Ipsum'
date: 2024-09-03T16:09:45+02:00
draft: true
tags:
  - lorem
  - ipsum
description: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse dictum magna eu ornare luctus
image: lorem-ipsum-banner.png
showTOC: true
---

If you look inside the ./public folder you’ll see a folder called tags containing an index.html and index.xml and two term folders lorem and ipsum.

Navigate to the tags page with http://localhost:1313/tags. Restart Hugo is you don’t see anything.

The default list page is used to display the tags. We have to create a specific template to list all tags and a term template which will list all content related with that term.

Let’s start with the list template for all tags. The name of the folder in the layouts folder equals the plural form of the taxonomy we defined in the Hugo configuration file, in this case tags:

mkdir ./layouts/tags
touch ./layouts/tags/list.html

Add the template:

{{ define "main" }}
<h1 class="text-5xl font-righteous mt-2 sm:mt-8 mb-8">Tags</h1>
<hr class="mb-8">
<ul class="pl-8 list-disc" data-pagefind-ignore>
  {{ range .Site.Taxonomies.tags }}
    <li class="">
      <a href="{{ .Page.RelPermalink }}" class="text-accent-500 font-semibold">
        {{ .Page.Title }} ({{ .Count }})
      </a>
    </li>
  {{ end }}
</ul>
{{ end }}

We loop through the tags, which are exposed at .Site.Taxonomies.tags. (again, the tags plural form is set in the Hugo configuration file). On the tag context, a reference to the Page object is available with which we can create the hyperlink. Also a count property exists, indicating how many items are tagged with that specific tag.

Navigate to http://localhost:1313/tags again, you might need to restart Hugo for the changes to appear. You should see something like this:

Both tags are displayed. If you click either one of them, you will see a list of articles tagged with that specific tag. In our case, only one article is shown, the lorem ipsum article.

Now setup the term’s list page:

touch ./layouts/tags/term.html

Add the template:

{{ define "main" }}
<h1 class="text-5xl font-righteous mt-2 sm:mt-8 mb-8">{{ .LinkTitle }}</h1>
<hr class="mb-8">
<ul class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4" data-pagefind-ignore>
  {{ range .Pages }}
    <li class="">
      <a href="{{ .RelPermalink }}">
        <div class="bg-white border rounded-lg dark:bg-gray-800 dark:border-gray-700" style="box-shadow: 0px 0px 40px rgba(29, 58, 83, 0.15);">
          <div class="p-5 h-56 flex align-middle justify-center rounded-t-lg">
            {{- $imgParams := .Params.image }}
            {{- $image := resources.Get "images/shrug.svg" }}
            {{ if $imgParams }}
              {{- $image = .Resources.Get $imgParams }}
            {{ end }}
            {{ with $image }}
              <img class="rounded-t-lg" src="{{ .RelPermalink }}">
            {{ end }}
          </div>
          <div class="p-5">
            <div class="h-56 overflow-clip">
              <h2 class="mb-2 text-2xl text-gray-900 dark:text-white font-righteous">{{ .LinkTitle }}</h2>
              <p class="mb-3 font-normal text-gray-700 dark:text-gray-400">{{ .Description }}</p>
            </div>
          </div>
        </div>
      </a>
    </li>
  {{ end }}
</ul>
{{ end }}

Now visit http://localhost:1313/tags/lorem, you might need to restart Hugo for the changes to appear. It should look like this:

A similar layout is used as our blog list page, but now with a header which displays the term.

I haven’t included a link to the tags page on my website yet, but now you can, if you want. You can find more information on taxonomies here: https://gohugo.io/content-management/taxonomies/.

6.2 RSS Feeds

By default, when you build your site, Hugo generates RSS feeds for home, section, taxonomy, and term pages. You can control feed generation in your site configuration. We only want to generate a RSS stream for our blog section:

outputs:
  home:
  - html
  section:
  - html
  - rss
  taxonomy:
  - html
  term:
  - html

Only RSS feeds for sections are generated now. Since we have one section, the blog section, only a RSS feed for our blog articles is generated.

Remove the ./public folder and run the build again.

rm -rf public
npm start

Check the ./public folder if everything is configured correctly, only the ./public/blog folder should contain a index.xml file now.

To view the RSS feed visit http://localhost:1313/blog/index.xml.

If you want to the alter the default values in the RSS feed generated by Hugo, you can override the default rss template. I use the following template, which is a copy of the default with some alterations, mostly to simplify the template. The template should be placed in the blog section:

touch ./layouts/blog/rss.xml
{{- $pctx := . }}
{{- if .IsHome }}{{ $pctx = .Site }}{{ end }}
{{- $pages := slice }}
{{- if or $.IsHome $.IsSection }}
{{- $pages = $pctx.RegularPages }}
{{- else }}
{{- $pages = $pctx.Pages }}
{{- end }}
{{- $limit := .Site.Config.Services.RSS.Limit }}
{{- if ge $limit 1 }}
{{- $pages = $pages | first $limit }}
{{- end }}
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>{{ .Site.Title }}</title>
    <link>{{ .Permalink }}</link>
    <description>Recent content on {{ .Site.Title }}</description>
    <generator>Hugo</generator>
    <language>{{ site.Language.LanguageCode }}</language>
    <copyright>Robin van der Knaap @ {{ time.Now | time.Format "2006" }}</copyright>{{ if not .Date.IsZero }}
    <lastBuildDate>{{ (index $pages.ByLastmod.Reverse 0).Lastmod.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>{{ end }}
    {{- with .OutputFormats.Get "RSS" }}
    {{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }}
    {{- end }}
    {{- range $pages }}
    <item>
      <title>{{ .Title }}</title>
      <link>{{ .Permalink }}</link>
      <pubDate>{{ .PublishDate.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate>
      <guid>{{ .Permalink }}</guid>
      <description>{{ .Summary | transform.XMLEscape | safeHTML }}</description>
    </item>
    {{- end }}
  </channel>
</rss>

The changes I made to the default template:

  • Only display site title, not name of the section
  • Simplified description
  • Removed managingEditor and webMaster
  • Removed author
  • Simplified copyright with current year

If you visit the RSS feed again, you should see the changes: http://localhost:1313/blog/index.xml.

To notify browsers an RSS feed is available, a feed reference must be placed inside the <head> element of the website. Include the following code inside the head.ts partial we created earlier to render the feed reference:

{{ with .OutputFormats.Get "rss" -}}
  {{ printf `<link rel=%q type=%q href=%q title=%q>` .Rel .MediaType.Type .Permalink site.Title | safeHTML }}
{{ end }}

Hugo will render this to:

<link rel="alternate" type="application/rss+xml" href="http://localhost:1313/blog/index.xml" title="building.robinvanderknaap.dev">

This link will only be added to the head in the blog section, because we disabled RSS feeds for the other outputs.

Now you can add a RSS feed icon to your socials to make it easier for your readers to find the RSS feed:

<a class="text-sm transition w-6" target="_blank" rel="noopener noreferrer" href="/blog/index.xml">
  <span class="sr-only">RSS</span>
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="-192 -192 2304.00 2304.00" class="fill-current hover:text-accent-500 dark:text-gray-200 h-6 w-6">
    <path d="M53.333 628.96c682.454 0 1237.76 555.2 1237.76 1237.76v53.333H882.24v-53.333c0-457.067-371.84-828.907-828.907-828.907H0V628.96Zm0-628.96C1082.56 0 1920 837.44 1920 1866.667V1920h-408.853v-53.333c0-803.84-653.974-1457.814-1457.814-1457.814H0V0ZM267.19 1386.667c146.774 0 266.134 119.36 266.134 266.133 0 146.773-119.36 266.24-266.134 266.24S.95 1799.573.95 1652.8c0-146.773 119.467-266.133 266.24-266.133Z" fill-rule="evenodd"/>
  </svg>
</a>

7. SEO

Search engine optimization (SEO) is a huge topic. In short:

  • Content is king, search engines will prioritize good content
  • Make sure your HTML is well structured and semantically correct
  • Optimize your site for performance.

If you have been following the guide, you will have noticed attention has been paid to structuring the HTML. Things like headers, sections, articles, main and nav elements are identified. This makes it easier for crawlers to index your site. Below, we will discuss the things we haven’t implemented yet, but are important for search engines.

Many meta tags aren’t used anymore. For example, the keywords meta tag — which is supposed to provide keywords for search engines to determine the relevance of that page for different search terms — is ignored by search engines, because spammers were just filling the keyword list with hundreds of keywords, biasing results.

7.1 Performance

Many tools exist to assess the performance of your website, both free and commercial. I like to use Google’s Lighthouse, which is already available to use in the developer tools in your Google Chrome Browser. If you are not using Chrome, you can use PageSpeed Insights which is basically the same, but can’t be used locally.

With Google’s Lighthouse you can generate a report of your webpage, and it will give hints how to improve performance, accessibility, optimize for SEO and other best practices.

When I ran the report for this blog article, I noticed performance was bogged down due to the many screenshots that were included in this article. Let’s fix that first.

7.1.1 Lazy load images

Large blog articles like this one can contain a lot of images. By default, browsers will load all these images when the page is requested, also the images which are not in the browser’s viewport and are not visible to the visitor at all. This can cripple the performance of the page, not to mention the bandwidth that is wasted on never seen images. A simple solution is to lazy load images with the loading attribute set to lazy. We can override the default markup Hugo uses to render images by adding a image render hook:

mkdir layouts/_default/_markup
touch layouts/_default/_markup/render-image.html

Add the following markup, notice the loading attribute, which is set to lazy:

<img src="{{ .Destination | safeURL }}" alt="{{ .Text }}" loading="lazy" />

Now all images are lazy loaded, giving a boost to the Lighthouse performance score for pages containing a lot of images.

7.2 Description

Specifying a description that includes keywords related to the content of your page is useful as it has the potential to make your page appear higher in relevant searches performed in search engines. It will also show up in search results as description of your page.

You can add the description in the head.html partial:

<meta name="description" content="{{ .Description | default "Personsal website of Robin van der Knaap" }}">

Description is always available on the Page object, and will contain the value that is set in the front matter of the page. A default value is used when the description is lacking from the front matter.

7.3 Sitemap

A sitemap is a file where you provide information about all the pages on your site. Search engines like Google read this file to crawl your site more efficiently. Hugo automatically generates a sitemap for your site when it is built. You can locate the sitemap in the ./public folder called sitemap.xml.

For our site the generated sitemap is fine. If you want to adjust the sitemap, you can override the default sitemap template.

7.4 Favicon

A favicon is a pictogram associated with your website, which is shown in browser tabs, but also in other places, such as browser favorites or as icon on mobile phones when the website is saved as an app.

You can create a favicon set with https://realfavicongenerator.net . You can upload an image, and after following the instructions, a downloadable set of icons is generated which is suitable for most situations. You are also provided with instructions to update the head of your HTML document to include all icons.

To spare you the work, I’ve already created the favicons for this site, download here.

The icons should be placed in the root of your website, we can achieve this by adding the favicons to the ./static folder.

You should add the links to the icons inside the <head> element. Copy the exact instructions that are provided by the generator. If you downloaded the icons I generated you can use the following:

<!-- favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">

If you rebuild the site, you should have a nice icon in your browser tab. When the site is in production, you should try to add the website to your mobile phone as app, and see how the icon looks like. You may need to tweak the settings in the generator to get the best results.

7.5 Robots.txt

A robots.txt file tells search engine crawlers which URLs the crawler can access on your site. It is not a mechanism for keeping a web page out of Google or any other search engine, other websites can still link to your site, and these links can be indexed. Use noindex if you want to keep a page out of search indexes.

Hugo can automatically generate a robots.txt file based on an internal template. You have to enable robots.txt from the config:

enableRobotsTXT: true

The default template Hugo uses looks like this:

User-agent: *

You can override the template if you want, by adding robots.txt to the layouts folder. For example, the following robots.txt will disallow crawling of all pages:

User-agent: *
{{ range .Pages }}
Disallow: {{ .RelPermalink }}
{{ end }}

For our blog, we allow the entire site to be crawled, we wouldn’t want anyone to miss out on great content, right?. So, we’ll be using the default internal template.

7.6 Social media meta data

Open Graph meta tags are snippets of code that control how URLs are displayed when shared on social media. They’re part of Facebook’s Open Graph protocol and are also used by other social media sites, including LinkedIn and Twitter (if Twitter Cards are absent). You can find them in the <head> section of a webpage. Any tags with og: before a property name are Open Graph tags.

To include open graph meta data, we can use an embedded template from Hugo. Inside the <head> element include:

{{ template "_internal/opengraph.html" . }}

Twitter also uses Twitter cards, we can activate these as well using a embedded template in the <head> element:

{{ template "_internal/twitter_cards.html" . }}

A perfect tool for determining how your URLs will be shown on social media is https://opengraph.dev.

7.7 Security.txt

“When security risks in web services are discovered by independent security researchers who understand the severity of the risk, they often lack the channels to disclose them properly. As a result, security issues may be left unreported. security.txt defines a standard to help organizations define the process for security researchers to disclose security vulnerabilities securely.”

securitytxt.org

We will create a security.txt file for our site. You must place this file inside the static folder, in a folder called .well-known. As mentioned before, content added to the static folder is not processed by Hugo but directly copied to the output, in the ./public folder.

mkdir ./static/.well-known
touch ./static/.well-known/security.txt
Contact: mailto:[email protected]
Expires: 2025-04-01T10:00:00.000Z
Preferred-Languages: en, nl

You can also create a security.txt on securitytxt.org if you want.

8. Build & Deploy

For deploying a HUGO website a lot of options exists, Vercel and Netlify are two well-known players in the field of hosting Jamstack sites. In the official HUGO documentation you can find a list of options, and of course you’re able to deploy the generated HTML, CSS and JavaScript to any webserver you want just by copying the output of the build. You could do this manually, or better yet, automate it with a tool like GitHub Actions. Someone wrote a nice blog article about that.

8.1 Cloudflare

I like Cloudflare Pages, which is totally free for relatively small sites. Cloudflare Pages is a Jamstack platform for frontend developers to collaborate and deploy websites and supports IPv6 which is currently not the case with Vercel and Netlify.

You also have the possibility to spin of workers for backend tasks, or integrate ready-to-use AI models from their developer platform, although we won’t be needing that, it’s nice to know we can level up our game if we want.

Sign up for a free account if you don’t already have one and login.

When you’re logged into the dashboard, go to the ‘Workers & Pages’ section. Don’t confuse the Websites section with the Workers & Pages section in the management console, the Websites section is related to domain registrations.

To deploy a website with Pages, we need to connect our Git repository. First click the ‘Create’ button in the overview page, go to the ‘Pages’ tab and select Connect to Git to create a new website.

Optionally you can upload all your HTML, JavaScript and CSS from the ./public folder to Pages if you don’t want to connect your git repository, but I wouldn’t recommend it. In this tutorial we’ll connect the git repository to enable automatic deployment on push. To adhere to the least privilege principle, connect to a single repository, and don’t give Cloudflare access to all your repositories. Follow the steps and select your website’s git repository.

Now click ‘Begin setup’ so we can configure the build.

As build command use: npm run hugo:build. This command will produce a production ready version of the website in the ./public folder.

The build output directory will be the ./public folder with the build output from Hugo.

Cloudflare pages will automatically install npm packages, you don’t need to do this in your build command.

Run Save and deploy.

Visit the URL Cloudflare has generated for you, and your site should show up. It could take a few minutes before DNS changes are properly propagated.

Make sure the URL generated for you by Cloudflare equals the one that is set in Hugo’s configuration file, hugo.yaml. Otherwise URLs in the RSS feed for example won’t be generated correctly.

If the blog articles don’t appear on your site, the articles are probably marked as drafts. Set the draft flag to false in the front matter of your articles, and push the changes to your remote repository. Cloudflare will automatically build your changes, and after 1 or 2 minutes your website should be updated, and the articles should appear.

8.2 Configure response headers

Cloudflare gives you the opportunity to attach headers to responses from your website. For this to work you need to add a ./_headers plain text file to the root of your output folder. The ./static folder is the place where you want to place this file in Hugo’s case.

touch ./static/_headers

There are several headers you might want to add, like a referrer policy. Or you might want to add noindex headers to prevent search engines from crawling certain pages. I’m using these headers for my site:

/*
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Content-Security-Policy: default-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com; base-uri 'none'; frame-ancestors 'none'; form-action none;
  Strict-Transport-Security: max-age=31536000
HeaderDescription
X-Frame-Options headerIndicates if a browser is allowed to render a page in a frame. By denying, you are sure you content is not embedded in other sites and project visitors from click-jacking attacks.Instead of this header you should use the frame-ancestors attribute in the Content-Security-Policy.
X-Content-Type-Options headerInstructs browsers to use the provided MIME type in the ContentType headers if set to nosniffing. This avoids MIME type sniffing.
Referrer-PolicyAllows a server to identify referring pages that people are visiting from or where requested resources are being used.
Content-Security-PolicyAllows website administrators to control resources the user agent is allowed to load for a given page.
Strict-Transport-SecurityInforms browsers that the site should only be accessed using HTTPS, and that any future attempts to access it using HTTP should automatically be converted to HTTPS.

8.3 Check security settings with Internet.nl

You can use internet.nl to check security settings. It’s a Dutch site, but can be viewed in English. You only have to specify the URL you want to check and in a few seconds you get a rapport with all security-related findings.

8.4 Custom domain

Cloudflare offers the option to setup a custom domain for your website. You can either register a new domain with Cloudflare or let them manage the DNS.

An advantage of using a custom domain is that Cloudflare is able to capture all visits to your site and provide you with analytics.

8.5 Done

That’s it, you have a working website hosted on Cloudflare. Now, go ahead and make it your own. I would appreciate it, if you send me a link when you got something published. Also, let me know if you find any inconsistencies or bugs in this article, or perhaps suggest some improvements, this would be very much appreciated.

Good luck on your endeavors!

Kudos

Zeno Rocha - His personal website inspired me to make one for myself. Zeno is also the creator of the excellent Dracula theme, which I use in all my IDE’s.

Rob Conery - I really like the style of Rob’s blogs and videos. I’ve been following him since his Tekpub days, very personal and thoughtfully crafted videos and blogs. I ‘borrowed’ the idea to use the font (Righteous) for headers from his site.

Tailwind Nextjs Starter Blog v2.0 - For inspiration of the overall design of my website.

LoveIt theme - More design inspiration.

NOORIX - For explaining how to add Tailwind CSS to Hugo, which wasn’t easy to figure out.

Joe Mooring - For explaining how to add filenames to code blocks in Hugo.

Bryce Wray - For pointing me in the right direction with integrating Tailwind and Hugo, and for introducing Pagefind.

Mad season and Mark Lanegan - For accompanying me during my writing.