Why I Migrated from VitePress to Hugo for This Blog
I shipped JavaScript to render paragraphs. For a year. Let that sink in.
VitePress ran this blog for fourteen months, and it ran it well. This is not a hit piece. VitePress is genuinely excellent software, and if you’re building docs with interactive examples, stop reading and go use it. But at some point I looked at my site — a pile of articles, no state, no interactivity, nothing that needs a framework — and asked a question I should have asked sooner: why is there a JavaScript runtime between my words and the browser?
So I moved to Hugo. Built a custom theme. The whole thing took a weekend. Here’s what happened.
What VitePress gets right
Let’s talk about the good parts, because they’re real.
Vite’s hot reload is the gold standard for dev loops. It’s fast enough that you forget it exists, which is the entire point. Writing technical posts felt frictionless — syntax highlighting, containers, Markdown niceties all there out of the box. And when you need a little interactivity, dropping a Vue component directly into Markdown is a genuinely nice escape hatch.
The tooling is polished. The ecosystem is deep. None of that changed.
Where it started to grind
The problems weren’t dramatic. They were the kind that accumulate quietly until one day you realize you’re annoyed every time you open the project.
JavaScript overhead for static text
This site has no user accounts. No dashboards. No client-side state that needs to survive a page transition. It’s text. But every page still shipped Vue runtime, routing code, and theme JavaScript. Not a catastrophic amount — this is the modern web, after all — but genuinely wasteful for content that hasn’t changed since the last deploy.
What was that JavaScript doing for me, day to day? Nothing. It was doing nothing.
Theme customization felt like application development
VitePress themes are flexible. But “just change the layout a bit” meant navigating Vue component hierarchies, tracing props through theme internals, mentally simulating a component tree to predict what HTML you’d get.
Look, some people love that. I wanted to open a template and see the markup I’m producing. Directly. Without a framework mediating the conversation between me and my own HTML.
Builds got noticeable
As the post count grew, builds went from instant to… noticeable. Not painful. Noticeable. And “noticeable” is already too slow for a site that changes once a week.
VitePress is doing real work — bundling, compilation, module resolution. All of it designed for applications. I was running a full frontend build pipeline to produce pages that end up as static files anyway. That’s a roundabout way to make HTML.
The hydration tax
VitePress outputs static files, sure. But the experience still leans on client-side hydration. The initial render had that subtle “web app loading” feel — a flicker of framework bootstrapping before the content settles. For a blog post, that’s wrong. A blog post should be instant. You click, you read.
And while modern crawlers handle JavaScript fine most of the time, I prefer not to gamble on rendering paths for indexing when the alternative is just… serving HTML.
Why Hugo
Hugo lined up with what I actually wanted. Not what I thought I wanted when I started the blog. What I want now, after maintaining it.
Zero JavaScript by default
Hugo outputs plain HTML and CSS. Period. If JavaScript shows up, it’s because you put it there on purpose.
That alone changes everything. Pages load immediately. There is no hydration step. The mental model is dead simple: browser gets HTML, browser renders HTML. No framework boot sequence. No client-side routing. Just a document.
Simplicity is a superpower.
Templates you can read
Go templates are not glamorous. They’re clunky, honestly. But they are direct. When I write:
<h1>{{ .Title }}</h1>
<time>{{ .Date.Format "Jan 2, 2006" }}</time>I know exactly what the output will be. No props to trace. No lifecycle to reason about. No virtual DOM diffing. The template is the truth. That predictability was the whole appeal.
Builds measured in milliseconds
Hugo’s reputation for speed is earned. My site builds in under 200ms. Not “fast enough.” Under 200 milliseconds.
That changes your workflow in a way you don’t expect. You stop thinking about builds entirely. There’s nothing to optimize because there’s nothing to wait for.
Content-first by default
RSS, sitemaps, taxonomies, content organization — Hugo treats these as first-class features, not plugins you bolt on. You can build a normal blog without assembling anything. It just works, out of the box, the way a blog tool should.
The migration
I expected this to be painful. It wasn’t.
Content
Frontmatter was close enough that most files moved over with minimal edits. A few formatting tweaks. Nothing dramatic.
Theme
Instead of adopting an existing theme, I built one from scratch. The site should be exactly what I want and nothing more. The theme — Plain Tech — is intentionally boring in the best sense:
- semantic HTML5
- system fonts (no web font loading)
- CSS custom properties for theming
- progressive enhancement
- no framework dependencies
Features that were easier than expected
Hugo’s template system made several things straightforward that would have felt heavier in a JS-first setup:
- Dynamic OpenGraph images using Hugo image processing, so each post gets a title card automatically.
- Code block render hooks for copy-to-clipboard, while keeping the underlying markup clean.
- Image render hooks to normalize relative paths, which helps when moving content around.
Performance
This was the part I cared about most. It delivered.
- Lighthouse: 100/100 across most metrics
- First Contentful Paint: under 500ms
- Largest Contentful Paint: under 800ms
- Bundle size: about 10KB CSS + about 2KB JavaScript (versus 150KB+ with VitePress)
- Build time: about 200ms (versus about 3 to 5 seconds with VitePress)
Read those bundle numbers again. From 150KB to 12KB. For the same content. The old setup was shipping an order of magnitude more code to display the same paragraphs.
What I miss
Vite’s HMR. Genuinely. Hugo’s live reload is fast, but it’s not the same seamless, surgical update that Vite does. That’s still the best dev experience in the ecosystem.
Some of VitePress’s Markdown niceties are also hard to give up. Hugo’s Markdown is solid but more basic, so I recreated a few conveniences with shortcodes and render hooks. More deliberate, less magic. That’s a trade-off I’m fine with, but it is a trade-off.
The thesis
Here’s the thing. I had built a blog on top of a web-app stack, and I didn’t actually want a web app. The moment I said that out loud, the decision was already made.
VitePress is the right tool for docs sites, component-driven content, anything where interactivity is the point. For a straightforward technical blog? Hugo’s approach is calmer. Fewer dependencies. Fewer runtime costs. Templates you can read without a framework mental model.
Choose VitePress if you need:
- Vue components embedded in content
- interactive examples and UI-heavy documentation
- a team already deep in Vue and JS tooling
Choose Hugo if you want:
- minimal JavaScript by default
- fast builds and a simple deploy pipeline
- traditional templating where the output is obvious
- a content-focused site that stays out of your way
Both are good tools. This move was never about “better.” It was about what I want this blog to be: readable pages that load instantly, built from a codebase that feels like a website — not an application.
And honestly? It’s nice to just write again.
