21.1 Theme Directory Structure: layouts, static, assets, i18n
Right, let’s get our hands dirty. You’re building a theme from scratch, which means you’re about to become intimately familiar with a very specific directory structure. This isn’t arbitrary bureaucracy; it’s Hugo’s contract with you. You put things in the right place, and Hugo, being a deeply opinionated but brilliant curmudgeon, knows exactly what to do with them. Break the contract, and things just… stop working. Let’s look at the four key directories you’ll be living in.
The layouts Directory: Where the Magic Happens
This is the brain of your operation. Forget everything you know about other static site generators; Hugo’s layouts are its killer feature. This is where you define the structure of your content using HTML templates. The layouts directory is a mirror of your content directory, and its structure tells Hugo which template to use for which piece of content.
The most important subfolders here are:
_default: Your fallback templates. If Hugo can’t find a more specific template, it looks here. You’ll have at leastbaseof.html,list.html, andsingle.htmlin here.partials: For reusable chunks of code. Your header, footer, and navigation should absolutely be in here. Don’t repeat yourself; build it once and{{ partial "header.html" . }}everywhere.shortcodes: Little snippets of template code you can drop into your Markdown content. We’ll cover these later, but they’re how you avoid putting raw HTML in your content files.
Here’s the skeleton of your baseof.html, the root template that everything else extends. This is non-negotiable.
<!-- layouts/_default/baseof.html -->
<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
<head>
<meta charset="utf-8">
<title>{{ block "title" . }}{{ .Site.Title }}{{ end }}</title>
</head>
<body>
{{ partial "header.html" . }}
<main>
{{ block "main" . }}{{ end }}
</main>
{{ partial "footer.html" . }}
</body>
</html>
The {{ block "main" . }} is a placeholder. A more specific template (like single.html) will fill that block with the actual article content.
The static Directory: For Things That Just Sit There
Anything you drop into static gets copied directly into the final public site, exactly as-is, preserving the directory structure. This is for your images, fonts, PDFs, client-side JavaScript, and CSS if you’re not using a build pipeline (more on that in a second).
The path is crucial. If you have static/images/logo.png, you reference it in your templates as /images/logo.png. Hugo doesn’t process these files; it’s a straight copy. This is both its strength (it’s fast) and its weakness (you can’t process SCSS here).
The assets Directory: For Things That Need a Makeover
This is the static directory’s smarter, more sophisticated sibling. Introduced in Hugo Pipelines, this directory is for files you do want Hugo to process. Think Sass/SCSS, TypeScript, or minification of JS/CSS.
To use these files, you must reference them in your templates using Hugo’s resource functions. You don’t link to them directly. This is the most common “why isn’t my CSS working?!” pitfall for newcomers.
Here’s how you do it right:
<!-- In your head partial (e.g., layouts/partials/head.html) -->
{{ with resources.Get "sass/main.scss" | toCSS | minify | fingerprint }}
<link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" crossorigin="anonymous">
{{ end }}
This one line finds your assets/sass/main.scss file, compiles it to CSS, minifies it, and adds a fingerprint (a unique hash) to the filename for cache-busting. It’s incredibly powerful. The key takeaway: static is for final assets, assets is for source assets.
The i18n Directory: For Speaking the World’s Languages
If your site needs to be multilingual, this is your hub. The i18n (short for internationalization, because there are 18 letters between the ‘i’ and the ’n’) directory holds YAML or TOML files that map translation strings.
Each language gets its own file, e.g., en.yaml for English and es.yaml for Spanish. The structure is simple: a key and the translated text.
# i18n/en.yaml
homepage:
headline: "Welcome to My Brilliant Site"
button_text: "Learn More"
# i18n/es.yaml
homepage:
headline: "Bienvenido a Mi Sitio Brillante"
button_text: "Aprende Más"
You use these in your templates with the i18n function: <h1>{{ i18n "homepage.headline" }}</h1>. Hugo automatically serves the correct translation based on the site’s language configuration. It’s one of those features that seems complex until you use it, and then you wonder why every system isn’t this straightforward.
The biggest pitfall? Forgetting that Hugo’s default language is defined in your config file, not by the existence of an en.yaml file. If your config says defaultContentLanguage = "fr", it will look for fr.yaml first and throw errors if it can’t find it, even if you have a perfect en.yaml sitting right there. Always match your config to your i18n files.