DEV Community

Rain9
Rain9

Posted on

Building a Hugo + Tailwind technical blog from scratch: Taking INFINI Labs Blog as an example

Project Overview

This blog is a typical static-site blog project: content is written in Markdown, then Hugo (a Go-based static site generator) renders it into HTML during the build phase, and TailwindCSS + PostCSS bundle the final CSS during the build phase as well. After deployment, there is no backend required—any static hosting (GitHub Pages / Netlify / Vercel / S3+CDN) can serve it.

Two key aspects of this project are especially worth noting:

  1. It uses Hugo Modules to reuse theme capabilities (such as image processing, PWA, SEO, and UI components), so CI must install Go (to fetch/manage module dependencies).
  2. The homepage additionally generates a search index index.json, and the pages inject a prebuilt search UI (/static/assets/*), enabling offline/local search in the browser.

The rest of this article explains both “how to set it up” and “how it works” in detail.


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:

  • title / description: used for 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.toml

    • theme = "hugoplate"
    • outputs.home includes JSON to generate public/index.json
    • build.buildStats.enable = true for accurate Tailwind scanning (explained later)
  • Language config: config/_default/languages.toml

    • English language with contentDir = "content/english"
  • Site parameters: config/_default/params.toml

    • logo / favicon, theme colors, announcement bar, cookie banner, sidebar widgets, etc.
  • Hugo Modules: config/_default/module.toml

    • Imports modules like github.com/gethugothemes/hugo-modules/images, pwa, seo-tools, etc. (this is where the Go dependency comes from)

3) Theme Layer: themes/hugoplate/

The theme defines the page structure and Hugo Pipes:

  • 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 no partials/image.html inside the theme directory. That partial comes from Hugo Modules (the hugo-modules/images module imported in config/_default/module.toml). This is a common pattern: “theme + modular capabilities.”

4) Asset Layer: assets/ vs static/

These two directories behave differently in Hugo:

  • assets/: resources 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 also live under themes/hugoplate/assets/* and are bundled via Hugo Pipes
  • static/: copied to public/ as-is

    • This project includes static/assets/index-*.css/js and pizza_wasm_bg-*.wasm
    • baseof.html hardcodes 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

The key scripts are:

  • dev: hugo server
  • build: hugo --gc --minify --templateMetrics --templateMetricsHints --forceSyncStatic

So Hugo drives the whole build. Node is mainly here for PostCSS/Tailwind.

Also, CI/hosting often runs this before build:

  • project-setup: node ./scripts/projectSetup.js

This script comes from the Hugoplate template project and is used to “move” the exampleSite/ layout into the real project layout. Since this repository already has a themes/ directory, the script typically prints Project already setup (i.e., it does nothing and is safe to keep in CI).

2) Hugo Modules: Why does CI install Go?

Because config/_default/module.toml uses Hugo Modules, such as:

  • github.com/gethugothemes/hugo-modules/images
  • github.com/gethugothemes/hugo-modules/pwa
  • github.com/gethugothemes/hugo-modules/seo-tools/basic-seo
  • github.com/hugomods/mermaid
  • etc.

These modules are fetched during build and participate in rendering. The repo’s go.mod locks module versions (you can treat it as the dependency manifest for Hugo Modules).

So the division of responsibilities is:

  • 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 configs are key:

  • hugo.toml enables build stats: [build.buildStats] enable = true
  • tailwind.config.js uses: content: ["./hugo_stats.json"]

Hugo emits hugo_stats.json while rendering templates, recording the classes/tokens used by the generated HTML. Tailwind reads this file and can very accurately generate only the required utility classes, avoiding scanning the entire template/content tree (which can lead to false positives and a much larger CSS bundle).

In addition, hugo.toml includes a module mount:

  • Mounts hugo_stats.json into assets/watching/hugo_stats.json
  • Combined with cache-busting config so CSS rebuilds trigger correctly

This is a well-established integration approach in Hugoplate-based setups.

4) How are CSS/JS bundled, minified, and fingerprinted?

In themes/hugoplate/layouts/partials/essentials/style.html, you can see the Hugo Pipes flow:

  1. Collect plugin CSS (from hugo.toml params.plugins.css)
  2. Compile scss/main.scss (requires Hugo extended)
  3. resources.Concat to merge
  4. css.PostCSS to run PostCSS (Tailwind + autoprefixer)
  5. In production: minify | fingerprint | resources.PostProcess
  6. Output <link href="...style.<hash>.css" integrity="...">

JS is handled similarly in themes/hugoplate/layouts/partials/essentials/script.html.

This also explains two important runtime facts:

  • After deployment, the site is pure static files (HTML/CSS/JS/images), with no runtime compilation.
  • Fingerprinting stabilizes 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". So the build generates:

  • public/index.json

The template that creates it is:

  • themes/hugoplate/layouts/index.json

It iterates over site pages and packs fields such as title, URL, tags, category, description, and the plain-text content into a JSON array.

This is a great data source for client-side search, especially on static sites.

2) Where does the search UI come from?

themes/hugoplate/layouts/_default/baseof.html hardcodes:

  • <link rel="stylesheet" href="/assets/index-*.css">
  • <script type="module" src="/assets/index-*.js"></script>

These files live in static/assets/ and are copied to public/assets/ by Hugo, so browsers can request them directly after deployment.

At runtime (when users open the site):

  1. The browser loads the static page
  2. It loads the search UI JS/CSS
  3. The search UI fetches index.json (and potentially WASM assets) and builds an index in the browser
  4. Searches run locally in the browser—no backend API required

This is a common approach to “enhance” static sites: generate data at build time, consume it with front-end code at runtime.


4. Building a Similar Project From Scratch (Minimum Viable Steps)

Here is a practical checklist if you want to replicate this repository’s approach.

1) Prepare the toolchain

  • Hugo extended (this project requires >= 0.139.2, see config/_default/module.toml)
  • Go (CI uses 1.23.3 for Hugo Modules)
  • Node (for PostCSS/Tailwind; CI uses Node 20, but a common LTS should work locally)

2) Install dependencies and start development

This repo declares pnpm, but the scripts are compatible with npm/yarn as well.

pnpm install
pnpm dev
# or: npm install && npm run dev
Enter fullscreen mode Exit fullscreen mode

This starts the Hugo dev server.

3) Add a new post

Create a Markdown file under content/english/posts/YYYY/, for example:

---
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.
Enter fullscreen mode Exit fullscreen mode

It’s recommended to put images under assets/images/posts/... and reference them as /images/posts/.... After building, they will be emitted under public/images/posts/....

4) Build for production

pnpm build
Enter fullscreen mode Exit fullscreen mode

The output directory is public/ (see netlify.toml: publish = "public").


5. Deployment & CI/CD: How Netlify / GitHub Pages / Vercel Build It

This repository supports multiple hosting platforms:

1) Netlify

netlify.toml:

  • build command: yarn project-setup; yarn build
  • publish: public
  • env: HUGO_VERSION=0.139.2, GO_VERSION=1.23.3

2) GitHub Pages (GitHub Actions)

.github/workflows/hugo.yml does:

  1. checkout
  2. install Node
  3. download Hugo extended
  4. install Go
  5. npm run project-setup
  6. npm install
  7. npm run build
  8. upload public as Pages artifact and deploy

3) Vercel

vercel-build.sh runs on the build machine:

  1. install Go
  2. install Hugo extended
  3. npm run project-setup
  4. npm install
  5. npm run build

All platforms do the same thing in the end: provision the build toolchain (Hugo + Go + Node) and produce public/.


Wrap-up: The Boundary Between Build-Time and Run-Time

The core idea of a Hugo-based blog is simple:

  • Build-time: Hugo compiles content, templates, modules, and assets into static files (and also performs minification, fingerprinting, and index generation).
  • Run-time: the CDN/static server only serves files; the browser runs a small amount of front-end JS (like the search UI). No backend API is required.

Once you understand this boundary, it becomes natural to extend the site: when adding new features, first ask “can we generate the data at build time?” and then “can the browser consume it at run time?”

Top comments (0)