The first thing every Hugo tutorial tells you to do is pick a theme. Drop it into themes/, point hugo.yaml at it, and you have a blog in five minutes.
I did that once. Then I wanted to change how the tag filter worked on the home page, and spent an hour reading someone else’s partials trying to figure out which override hook I was supposed to use. That’s when I ripped the theme out and wrote every template myself.
This site — hoanghiep.dev — has zero theme, zero JS framework, zero bundler. Just Hugo, hand-written templates, and hugo --minify. Here’s how it’s laid out and how it ships.
The four folders that matter
content/posts/ # one markdown file per post
layouts/ # every template, hand-written
static/ # fonts, JS, images — served as-is
archetypes/ # scaffolding for `hugo new`
content/ holds nothing but Markdown and front matter. No logic, no partial includes — that’s deliberate. If a decision needs to live somewhere, it lives in a template, not in a post file.
layouts/ is the whole site. There’s no themes/ directory to fall back to, so every page type has an explicit template:
layouts/index.html # home page
layouts/single.html # posts + standalone pages like /about/
layouts/posts/list.html # the archive
layouts/taxonomy.html # /tags/
layouts/term.html # /tags/<tag>/
layouts/404.html # GitHub Pages not-found page
Shared pieces — the topbar, the search modal, font preloads, the CSS design tokens — live in layouts/partials/ and get included by whichever top-level template needs them. Nothing gets copy-pasted between templates; if I need to change the topbar, there’s exactly one file to edit.
No config beyond what’s used
hugo.yaml has five entries under params, and all five are read by a template somewhere:
params:
env: production # gates GA4 in analytics.html
googleAnalytics: 'G-XXXXXXX'
description: "..."
author: 'Hiep Nguyen'
I’ve been tempted to add more — a params.showAuthorBox toggle, a params.postsPerPage — and stopped myself each time. A config key that only one template reads, once, is a worse abstraction than just hardcoding the value in that template. Config is for things that actually vary.
Writing a post
hugo new posts/2026-08-21-slug.md
pulls from archetypes/posts.md, which sets the front matter shape every post needs — title, date, draft, description, tags, showToc — and nothing else. draft: true by default, so nothing goes live by accident.
Shipping it
There’s no server. master is the only branch that matters, and pushing to it runs the whole pipeline:
on:
push:
branches: [master]
Two jobs, in order. First, a secret scan:
security:
steps:
- run: gitleaks detect --source . --redact --verbose
then the build:
build:
needs: security
steps:
- run: hugo --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
- uses: actions/upload-pages-artifact@...
If the secret scan fails, the build never runs. A third job deploys the artifact to GitHub Pages. That’s the entire release process — no staging environment, no manual approval step, no rollback script. git revert is the rollback script.
One detail I care about more than I expected to: every third-party action is pinned to a commit SHA, not a version tag, and both the Gitleaks and Hugo binaries the workflow downloads are verified against a published sha256 checksum before they run. A tag can move. A SHA can’t. It’s a few extra lines in the workflow and it means a compromised upstream release can’t silently execute in my pipeline.
Where this breaks down
None of this scales to a site with editors who aren’t comfortable with git and Markdown, or content that needs a real workflow — drafts routed for review, scheduled publishing, more than one author stepping on the same files. For that, you want a CMS with a UI, not a folder of .md files.
For a personal blog, a docs site, or anything one person maintains, it’s hard to find a reason to add the complexity back in.
A theme gets you a blog in five minutes. Writing the templates yourself gets you a blog you actually understand.