4 min read

How I built this site (technical author cut)

Static sites have a reputation for being the boring choice. They’re also the correct one for a personal blog. No database to maintain, no runtime to patch, no server to babysit. A push to main and Cloudflare has the new version live in under a minute.

The stack is Hugo, Tailwind CSS v3, and Cloudflare Pages. Hugo compiles markdown into HTML. Tailwind handles styling through a design token system I can reason about. Cloudflare serves it at the edge for free.

Picking Hugo over the alternatives

The other static site generators I considered all add opinions about JavaScript. Astro wants components. Next.js wants a deployment target. Hugo wants markdown files and templates, which is exactly what a writing-focused site needs. It’s also fast: full build in under a second, which means the feedback loop during writing stays tight.

The content model follows the filesystem. A file in content/posts/ is a post. A file in content/docs/ is a doc. Hugo handles the routing, list pages, and tag taxonomies without any config beyond telling it where to look.

Two content types, different defaults

Posts and docs serve different purposes, so they have different defaults.

Posts start as draft: true. The workflow is write, leave it, come back, finish it when it’s ready. Publishing means the post is good enough that I’d be comfortable if someone found it, not that it’s perfect. The draft flag is the only gate.

Docs start published. They’re reference material: command flags, setup steps, things I’ll search for at 11pm when something breaks. If I’m writing a doc, I already need it. Waiting to publish would be self-defeating.

The docs index groups entries by category. That’s a hard constraint from the layout template: omit the category field in frontmatter and the entry disappears from the index. It’s the kind of silent failure that’s annoying to debug, so it’s worth knowing about upfront.

Dark mode without the flash

Dark mode is the default, not the alternative. Most of what I read is code and text on screens at odd hours. A white background by default would be wrong.

The implementation is class-based: the dark class on <html> controls which colour tokens apply. Toggling it writes to localStorage so the preference persists. The tricky part is preventing the flash on first load, where the page renders in the wrong theme for a fraction of a second before JavaScript runs.

The fix is a small inline script in <head>, before any CSS loads. It reads localStorage and applies the correct class immediately, before the first paint. Because it’s inline, it blocks rendering just long enough to set the right class. The flash never happens.

The colour tokens are in tailwind.config.js: void.* for dark surfaces, page.* for light, and glow.* for the amber accent (#f59e0b). The names are deliberate and the config treats them as the full design vocabulary. Tailwind’s defaults are overridden rather than extended, which is appropriate for a site with a fully custom design but worth noting if you’re adapting this for something that relies on Tailwind’s built-in colours.

Search without a backend

Search runs entirely in the browser. Hugo generates a JSON index of every non-draft page at build time. When you visit /search/, the page fetches that index and initialises Fuse.js for fuzzy matching. Results appear as you type, with a 150ms debounce.

Fuse.js loads from a CDN only on the search page. Every other page is unaffected. The JSON index is generated at build time, so search is always current with the last deploy. The only moving part is the client-side script.

Results show a section badge, date, title, and the summary field from frontmatter. Summaries are written manually, not extracted automatically. An auto-generated excerpt from the first paragraph is rarely what you’d want someone to read when deciding whether to click.

Deployment

Cloudflare Pages connects to the GitHub repository and deploys on push to main. The build command is npm run build from the site/ subdirectory, which Cloudflare Pages needs as an explicit root directory setting. The output goes to site/public/. That’s the entire deployment configuration.

The wrangler.toml at repo root holds the Cloudflare config. Hugo knows nothing about it.

The /docs/* path can be gated via Cloudflare Zero Trust with an email one-time PIN. I haven’t activated it, but the infrastructure is already in place. Setting it up through the Cloudflare dashboard takes about five minutes when I need it.

Prepared with the assistance of Claude. The concepts and ideas are my own.