Migrating from Hugo to Astro: What Broke and What I Learned
My personal site has been running on Hugo since 2019. Back then it was a landing page: links to my socials, a couple of blog posts I never followed up on, and a dark theme that looked nice. It served its purpose: a place on the internet that said “I exist, here’s how to find me.”
After a few years of not touching it, I decided I actually wanted to write. About AI agents, test automation, the things I was building. That meant the site needed to be more than a landing page. That’s when I discovered the whole thing was broken. Hugo 0.69 couldn’t build anymore, the theme fork had drifted, and Netlify was serving a stale version of the site.
Instead of fixing a 6-year-old Hugo setup to make it something it was never meant to be, I decided to migrate.
TL;DR
- Migrated from Hugo 0.69 (broken, 2019) to Astro 6 in a weekend
- Site ships zero JavaScript by default — Lighthouse performance score is 100
- Content migration was trivial (minutes); building the component library was the real work
- If you want interactivity in the future, migrate now rather than patching Hugo
Why not just fix Hugo?
The problem was accumulated rot, not Hugo itself. Hugo 0.69 shipped in 2020, and six years of ecosystem drift left my pinned version unable to build against modern tooling.
The ecosystem had moved on. Template syntax changed, theme APIs evolved, and my pinned version couldn’t keep up. The theme (hello-friend-ng) was a fork of a fork; updating it meant resolving conflicts across SCSS, layouts, and JavaScript I didn’t write. And even if I’d fixed all that, there was still no path to interactivity. I wanted code playgrounds, client-side search, comments. Hugo’s model is pure static. Any interactivity means bolting on separate JavaScript with no framework support.
I could have fixed it in a weekend, but I’d still be on a platform that doesn’t grow with me.
Why Astro over the alternatives?
I looked at three options: fixing Hugo, moving to Eleventy, or going with Astro. Astro won because it gives you static-by-default performance with opt-in interactivity. Few frameworks offer that combination cleanly.
- Islands architecture: static by default, interactive where I need it. If I want to add a Kotlin code playground later, I just add a component. No full client-side framework required.
- Content Collections with a typed schema: if I mess up the frontmatter, the build tells me. No more silent failures where a post renders wrong because of a YAML typo.
- Built-in Shiki for syntax highlighting at build time with zero client JavaScript. The old site used Prism, which loaded a full JS bundle just to color code blocks.
- File-based routing:
src/pages/about.astrobecomes/about/. Simple, predictable. - Scoped styles: each component gets its own
<style>block that doesn’t leak. No more fighting with global SCSS.
What changed in the migration?
The migration touched almost everything except the blog content itself.
Content: Hugo page bundles (content/posts/my-post/post.en.md) became Astro content collections (src/content/posts/my-post/index.md). Images stayed co-located with their posts, just referenced with ./image.png instead of absolute paths. Frontmatter dates simplified from ISO with timezone offsets to plain YYYY-MM-DD.
Styling: I dropped SCSS for plain CSS with custom properties. CSS has native variables and nesting now, so SCSS added no value. Syntax highlighting moved from Prism (client-side) to Shiki (build-time), and dual theme support (light/dark) came free. The old theme fork became about 150 lines of global styles plus scoped component styles, written from scratch.
Deployment: Moved from Netlify to Cloudflare Pages. Global CDN, automatic SSL, Cloudflare Web Analytics (no cookies, GDPR-friendly). The build command changed from hugo to astro build, but the output is still static HTML in a dist/ folder.
Tooling: I added mise to pin Node 24 and define project tasks (mise run dev, mise run build), wrangler.jsonc for Cloudflare Pages config, and Prettier with the Astro plugin for consistent formatting.
What stayed the same?
These survived intact:
- The
$ cd ~branding. The terminal-prompt logo and monospace aesthetic carried over. - Dark/light toggle. Same concept, reimplemented with
data-themeon<html>and localStorage. - Existing content migrated intact. Just frontmatter and image path adjustments.
- The URL structure.
/posts/slug/didn’t change, so no redirects needed.
What did I learn?
The biggest lesson: strict schemas catch bugs that permissive systems hide for years. When I first set up the content schema, two posts failed validation because of inconsistent frontmatter. Hugo had silently tolerated the inconsistency for years. Astro caught it immediately. Content Collections are strict, and that’s a feature.
The site ships zero JavaScript except the theme toggle (~15 lines) and the code block copy button. Lighthouse performance is 100. Not “essentially 100,” literally 100. Zero-JS really means zero.
Scoped styles change how you think about CSS. Instead of one massive stylesheet with BEM naming, each component owns its styles. The global stylesheet is just design tokens and resets, about 150 lines total.
The migration itself was smaller than expected. Content migration took minutes. The real work was building the component library: layout, header, footer, post cards, timeline. But I would have done that work anyway. Now each component is small, isolated, and testable.
What’s next?
Since the initial migration, I’ve already added:
- Client-side search with Pagefind, indexed at build time, accessible via Cmd+K.
- RSS feed with
@astrojs/rssfor subscribers. - View transitions for smooth page navigation.
- Per-post OG image generation with satori and sharp.
What’s still on the list: more content (AI enablement, testing, automation) and interactive components using Astro’s islands architecture.
If you’re running an old Hugo site and wondering whether to fix it or migrate: migrate. Astro’s tooling stayed out of my way the entire time, and the component library I ended up with is easier to maintain than the theme fork I left behind.