← Back to blog

Setting up a Hugo site - Part 2: The theme

Setting up a Hugo site - Part 2: The theme

In part 1 I set up the development environment. Now I continue with the theme — how I converted an existing HTML template into a working Hugo theme.

Creating a new theme

hugo new theme portfolio

This generates themes/portfolio/ with the following structure:

themes/portfolio/
├── assets/
├── layouts/
│   ├── _default/
│   │   ├── baseof.html
│   │   ├── list.html
│   │   └── single.html
│   └── index.html
├── static/
└── theme.toml

Activate it in hugo.yaml:

theme: portfolio

Start the dev server — you’ll see a blank page. That’s expected: there’s no HTML in it yet.

From HTML to layouts

My starting point was an existing HTML/CSS/JS template: a single index.html, a stylesheet, and some JavaScript. The conversion happens in three steps:

  1. Split the shared HTML into baseof.html and partials
  2. Replace static content with Hugo template variables
  3. Move the assets to the right locations

baseof.html

baseof.html is the skeleton of every page — everything that always repeats: <html>, <head>, navigation, footer. The central element is the block slot:

<!DOCTYPE html>
<html lang="{{ .Site.Language.Lang }}">
  <head>{{ partial "head.html" . }}</head>
  <body>
    {{ partial "header.html" . }}
    <main>{{ block "main" . }}{{ end }}</main>
    {{ partial "footer.html" . }}
  </body>
</html>

Other templates fill that slot with {{ define "main" }}...{{ end }}. This way every page inherits the skeleton without repeating the HTML.

index.html and partials

The homepage only fills in the main block:

{{ define "main" }}
  <section id="hero">
    <h1>{{ .Params.greeting | default .Site.Title }}</h1>
  </section>
  <section id="about">{{ .Content }}</section>
{{ end }}

Anything that appears on multiple pages — the <head> with CSS links, the navigation, the footer — gets split into partials. You include them with:

{{ partial "header.html" . }}

The dot (.) is the context you pass along. Without it the partial has no access to page data like .Site.Params or .Translations.

Hugo Pipes: processing assets

Putting CSS and JS in static/ serves them unprocessed. For production builds you want minification and fingerprinting (a unique hash in the filename to bust caching). That’s what Hugo Pipes is for.

Move your CSS from static/css/ to assets/css/. Then in your head.html partial:

{{ $css := resources.Get "css/main.css" }}
{{ if hugo.IsProduction }}
  {{ $css = $css | minify | fingerprint }}
{{ end }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}">

During development Hugo serves the original, readable files. In production (hugo without flags) the CSS is automatically minified and the filename gets a unique hash, so browsers always fetch the latest version.

The same pattern works for JavaScript:

{{ $js := resources.Get "js/main.js" }}
{{ if hugo.IsProduction }}
  {{ $js = $js | minify | fingerprint }}
{{ end }}
<script src="{{ $js.RelPermalink }}"></script>

Always use .RelPermalink — never a hardcoded path. The fingerprinted file gets a name like main.min.17dddb29e510cff4144ac1eb3708c4d0.css.

Connecting content

Hugo has two types of content files:

  • _index.md (with underscore) — a section page. Can contain child pages and serves as the root of a section.
  • index.md (without underscore) — a page bundle. A standalone page, like a blog post.

The homepage uses content/_index.md because the site root is a section that can have child pages later.

The content for the homepage comes from that file:

---
greeting: "Hi, I'm Sander"
---

Text that appears in the about section.

Front matter parameters are available via {{ .Params.greeting }}, the Markdown content via {{ .Content }}.

Next to page-specific parameters there are also site-wide parameters. You define these in hugo.yaml under params::

params:
  nameOfPerson: "Sander Dam"
  profession: "IT Consultant"

In templates they’re available via {{ .Site.Params.nameOfPerson }} — useful for data that appears across multiple pages, like your name or social links.

Next step

In the next part I’ll show how I made the site multilingual: Dutch and English content side by side, a language switcher in the navigation, and translation strings via the i18n/ directory.

Back to top