Building a Hugo + Tailwind technical blog from scratch: Taking INFINI Labs Blog as an example
Source: Dev.to
1. Directory Structure: Where Do Content, Theme, Config, and Assets Live?
You can think of this repository as four layers:
1) Content Layer – content/
content/english/posts/ # blog posts (Markdown + front matter)
content/english/authors/ # author pages (Markdown + front matter)
Each post starts with front matter (YAML/TOML/JSON are all supported; this repo uses YAML). Typical fields include:
| Field | Purpose |
|---|---|
title / description | Page title and SEO |
date | Publish time (affects ordering and display) |
categories / tags | Used for category and tag pages |
image | Cover image |
author | Author name (linked to the corresponding author page) |
Templates read these fields. For example, themes/hugoplate/layouts/posts/single.html displays the cover image, author, categories, publish date, the main content, and the table of contents (TOC).
2) Configuration Layer – hugo.toml + config/_default/*
Hugo supports splitting configuration under the config/ directory. This project uses:
-
Root config:
hugo.tomltheme = "hugoplate" [outputs] home = ["HTML", "JSON"] # generates public/index.json [build] [build.buildStats] enable = true # accurate Tailwind scanning (explained later) -
Language config:
config/_default/languages.toml[languages.en] languageName = "English" weight = 1 contentDir = "content/english" -
Site parameters:
config/_default/params.toml– defines logo, favicon, theme colors, announcement bar, cookie banner, sidebar widgets, etc. -
Hugo Modules:
config/_default/module.toml– imports modules such as[[imports]] path = "github.com/gethugothemes/hugo-modules/images" [[imports]] path = "github.com/gethugothemes/hugo-modules/pwa" [[imports]] path = "github.com/gethugothemes/hugo-modules/seo-tools/basic-seo" [[imports]] path = "github.com/hugomods/mermaid"(These imports are why the CI needs Go.)
3) Theme Layer – themes/hugoplate/
The theme defines the page structure and Hugo Pipes:
| File | Purpose |
|---|---|
themes/hugoplate/layouts/_default/baseof.html | Base layout skeleton |
themes/hugoplate/layouts/index.html | Homepage list |
themes/hugoplate/layouts/posts/single.html | Post detail page |
themes/hugoplate/layouts/index.json | Template that generates the JSON search index (important) |
Note: You’ll see
{{ partial "image" ... }}in templates, but there is nopartials/image.htmlinside the theme directory. That partial comes from the hugo‑modules/images module imported inmodule.toml. This is a common pattern: theme + modular capabilities.
4) Asset Layer – assets/ vs static/
These two directories behave differently in Hugo:
| Directory | How Hugo treats it |
|---|---|
assets/ | Processed by Hugo Pipes (compile, fingerprint, minify, etc.). * Images live under assets/images/... and are emitted to public/images/... during build.* Styles/scripts source files live under themes/hugoplate/assets/* and are bundled via Hugo Pipes. |
static/ | Copied to public/ as‑is.Contains static/assets/index-*.css, static/assets/index-*.js, and pizza_wasm_bg-*.wasm.baseof.html hard‑codes imports for /assets/index-*.css and /assets/index-*.js. |
2. Build Pipeline: What Happens From Markdown to Final HTML/CSS/JS?
1) Build entry points – package.json scripts
{
"scripts": {
"dev": "hugo server",
"build": "hugo --gc --minify --templateMetrics --templateMetricsHints --forceSyncStatic",
"project-setup": "node ./scripts/projectSetup.js"
}
}
devruns a local Hugo server.builddrives the whole production build; Node is mainly present for PostCSS/Tailwind.
The project-setup script comes from the Hugoplate template and moves the exampleSite/ layout into the real project layout. Since this repository already has a themes/ directory, the script usually prints “Project already setup” and does nothing—safe to keep in CI.
2) Hugo Modules – Why does CI install Go?
config/_default/module.toml imports several modules (images, pwa, seo‑tools, mermaid, etc.). These modules are fetched during the Hugo build and participate in rendering. The repository’s go.mod locks the module versions, acting as the dependency manifest for Hugo Modules.
Division of responsibilities
| Tool | Role |
|---|---|
| Hugo | Renders the site |
| Go | Pulls and manages Hugo Module dependencies |
That’s why CI installs both Hugo and Go (see netlify.toml and .github/workflows/hugo.yml).
3) How does Tailwind generate only the CSS you actually use?
Two configurations make this possible:
-
Hugo build stats – enabled in
hugo.toml[build.buildStats] enable = trueHugo emits
hugo_stats.jsonwhile rendering templates, recording every class/token used in the generated HTML. -
Tailwind config – reads the stats file
// tailwind.config.js module.exports = { content: ["./hugo_stats.json"], // …other Tailwind settings… };
Tailwind reads hugo_stats.json and can very accurately generate only the required utility classes, avoiding a full‑tree scan that often produces false positives and a much larger CSS bundle.
Additionally, hugo.toml mounts the stats file so it’s available to Tailwind:
[[module.mounts]]
source = "hugo_stats.json"
target = "assets/watching/hugo_stats.json"
Summary
- Content → Markdown (in
content/) → Hugo renders HTML. - Theme & Modules →
themes/hugoplate/+ Hugo Modules (imported viamodule.toml). - Assets → Processed by Hugo Pipes (
assets/) or copied verbatim (static/). - Tailwind → Uses
hugo_stats.jsonfor precise CSS generation. - Search →
index.jsongenerated from a dedicated template, consumed by a pre‑built UI instatic/assets/.
With this structure, the site can be built entirely with static tooling, deployed to any static host, and still enjoy powerful features like image processing, PWA support, SEO helpers, and offline search.
Cache‑busting config so CSS rebuilds trigger correctly
This is a well‑established integration approach in Hugoplate‑based setups.
1) How are CSS/JS bundled, minified, and fingerprinted?
In themes/hugoplate/layouts/partials/essentials/style.html you can see the Hugo Pipes flow:
- Collect plugin CSS – from
hugo.tomlparams.plugins.css. - Compile
scss/main.scss– requires Hugo Extended. resources.Concat– merge the files.css.PostCSS– run PostCSS (Tailwind + autoprefixer).
In production:minify | fingerprint | resources.PostProcess.
The output is rendered as:
<link href="/assets/css/main-<hash>.css" integrity="..." rel="stylesheet">
JS is handled similarly in themes/hugoplate/layouts/partials/essentials/script.html.
Runtime facts
- After deployment the site is pure static files (HTML/CSS/JS/images); no runtime compilation occurs.
- Fingerprinting stabilises caching: when content changes the asset URL changes, so browsers/CDNs won’t serve stale assets.
3. Search: How Do index.json and /static/assets/* Work Together?
1) Where does the index come from?
hugo.toml sets
outputs.home = ["HTML", "RSS", "WebAppManifest", "JSON"]
and configures JSON output with baseName = "index". During the build Hugo generates public/index.json from the template themes/hugoplate/layouts/index.json. The template iterates over site pages and packs fields such as title, URL, tags, category, description, and the plain‑text content into a JSON array. This file is a great data source for client‑side search on static sites.
2) Where does the search UI come from?
themes/hugoplate/layouts/_default/baseof.html hard‑codes imports for the assets in static/assets/. Those files are copied to public/assets/ by Hugo, so browsers can request them directly after deployment.
Runtime flow
- Browser loads the static page.
- It loads the search UI CSS/JS.
- The UI fetches
index.json(and possibly WASM assets) and builds an index in the browser. - Searches run locally—no backend API required.
This “generate at build time, consume at runtime” pattern is common for enhancing static sites.
4. Building a Similar Project From Scratch (Minimum Viable Steps)
1) Prepare the toolchain
- Hugo Extended (
>= 0.139.2) - Go (CI uses
1.23.3for Hugo Modules) - Node (for PostCSS/Tailwind; CI uses Node 20, any LTS version works locally)
2) Install dependencies and start development
The repo declares pnpm, but the scripts also work with npm/yarn.
pnpm install
pnpm dev # or: npm install && npm run dev
This starts the Hugo dev server.
3) Add a new post
Create a Markdown file under content/english/posts/YYYY/, e.g.:
---
title: "My First Post"
description: "A short summary"
date: "2025-12-20T09:00:00+08:00"
categories: ["Engineering"]
tags: ["Hugo"]
image: "/images/posts/2025/some-folder/cover.jpg"
author: "Rain9"
lang: "en"
category: "Technology"
subcategory: "Engineering"
draft: true
---
# Hello
Write something here.
Tip: Put images under
assets/images/posts/...and reference them as/images/posts/.... After building they will be emitted underpublic/images/posts/....
4) Build for production
pnpm build
The output directory is public/ (see netlify.toml: publish = "public").
5. Deployment & CI/CD: How Netlify / GitHub Pages / Vercel Build It
The repository supports multiple hosting platforms.
1) Netlify
netlify.toml
[build]
command = "yarn project-setup; yarn build"
publish = "public"
[build.environment]
HUGO_VERSION = "0.139.2"
GO_VERSION = "1.23.3"
2) GitHub Pages (GitHub Actions)
.github/workflows/hugo.yml performs:
- Checkout repository.
- Install Node.
- Download Hugo Extended.
- Install Go.
- Run
npm run project-setup. - Install dependencies (
npm install). - Build (
npm run build). - Upload
publicas a Pages artifact and deploy.
3) Vercel
vercel-build.sh runs on the build machine:
- Install Go.
- Install Hugo Extended.
- Run
npm run project-setup. - Install dependencies (
npm install). - Build (
npm run build).
All platforms ultimately:
- Provision the toolchain (Hugo + Go + Node).
- Produce the static
public/directory.
Wrap‑up: The Boundary Between Build‑time and Run‑time
The core idea of a Hugo‑based blog is simple:
| Phase | What Happens |
|---|---|
| Build‑time | Hugo compiles content, templates, modules, and assets into static files. It also performs minification, fingerprinting, and index generation. |
| Run‑time | The CDN/static server only serves those files. The browser runs a small amount of front‑end JS (e.g., the search UI). No backend API is required. |
Once you understand this boundary, extending the site becomes natural: when adding a new feature, first ask “Can we generate the data at build time?” and then “Can the browser consume it?”