Rewriting working software is a trap every programmer knows. I fell into it anyway, and I picked a soft target: this blog, four years old, with nine published posts, one abandoned draft, and a feature set nobody has ever requested. So a rewrite was the only responsible option.
If you’ve been here before, most of what’s changed is cosmetic: the posts are the same, the URLs are the same, and the design is new. The blog ran on Hugo, the Go static site generator I picked four years ago and walked through in How to create a blog, Part 1: Hugo static site generator. In that post I wrote, with a straight face: “The only downside I found is that Go templating is a bit clunky, but it does the job nevertheless.” It does do the job. This post is about why I rebuilt it on Astro anyway, what changed, what didn’t, and how much of that shows up in the numbers.
And it’s much better! Wait, it is better, right?
No.
Well, yes, but not meaningfully. The old blog was never slow. It was already a static site served through Cloudflare, and its numbers showed it:
| PageSpeed, mobile | Hugo | Optimized Astro |
|---|---|---|
| Performance | 99 | 100 |
| First contentful paint | 1.5 s | 0.9 s |
| Largest contentful paint | 1.5 s | 1.4 s |
| Speed Index | 2.4 s | 0.9 s |
| SEO audit | 92 | 100 |
Two things to keep straight while reading it. The runs don’t isolate Astro’s effect - the page design changed together with the generator. And the numbers never get far enough apart to claim any meaningful difference for a reader; they’re diagnostics from simulated lab runs, not data from actual visitors, which is the distinction Google draws between the two. What the table does support is the modest claim I set out to make: I redesigned the site into something a bit more complex stylistically, kept the same feature set, and it stayed fast, with zero total blocking time and zero layout shift in both versions’ runs.
Two other numbers didn’t make the table, because they’re single readings rather than a battery. In WebPageTest’s desktop run, largest contentful paint came down from 0.627 s to 0.470 s. And there’s one clear improvement in CI - the build step on Cloudflare is down by… checks notes …an entire 9 seconds, from 82 to 73. I’ll take it.
If there is a ‘better’ in this story, it’s not in the table. The new site has things the old one didn’t: Mermaid-style diagrams rendered to SVG at build time, a table of contents, a single archive that filters by topic and searches at once, a generated sharing image per article, and design details like theme-aware code blocks and page transitions. None of that needed Astro - Hugo can do every item on that list with enough template work and plugins, so this is a list of things I wanted to build, not a list of things Hugo couldn’t do. The difference is that extending the old theme was exactly the work I avoided, and doing it on Astro is the work I’ve been doing for the pleasure of it. So: fast before, fast after, and more of the work I enjoy - the rest of this post is about that last part.
Not the performance, then
Hugo got me from zero to a finished blog in an afternoon, and I still think that’s its best feature. What Part 1 didn’t cover, because I hadn’t run into it yet, is what happens after you outgrow the theme.
Anything inside the theme’s happy path was a config option. Everything else meant finding the right partial in PaperMod’s directory tree, copying it into my own layout directory to override it, and editing it in Go template syntax - then hoping my copy didn’t drift from upstream. Dates, image handling, output metadata: every change was small and useful, and every one left me with less of the theme I still understood.
Compare the two ways this site can load its posts. This is the function the homepage, the archive, the RSS feed and the sharing images all use:
export async function getPublishedPosts() { return ( await getCollection( 'blog', ({ data }) => !data.draft && data.pubDate <= new Date(), ) ).sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());}The template version, as I would have written it on the old blog (Hugo drops drafts and future-dated posts on its own, unless told otherwise, so there’s no filter to see for that):
{{ $posts := where site.RegularPages "Type" "posts" }}
{{ range $posts.ByDate.Reverse }} <a href="{{ .RelPermalink }}">{{ .Title }}</a>{{ end }}The template version is short and it works, and I don’t want to dress this up as an objective complexity ranking - on any technical axis, the syntax isn’t worse, it’s just not natural to me. The first version is already mine: functions, predicates, a type checker looking over the filter - a style I’ve written for years, so reading it takes no effort. The second is one I had to fight with: parts of the query are quoted strings that nothing verifies for me, .ByDate.Reverse was a method I kept looking up, and the variable scoping rules bite quietly when you get them wrong. That’s the kind of clunky I meant in 2022: tolerable when it lives inside someone else’s theme, tiring once it’s yours to maintain.
And the effect of that, plainly: nothing about the old site was broken. The posts kept coming - it’s the site code I stopped touching. A project you don’t enjoy opening is maintenance with a public URL.
Why Astro?
I wanted to learn it. I write TypeScript and the modern JS frontend ecosystem at my day job, it’s the part of the field I keep current in, and I wanted my own codebase to train the same muscles. Keeping side projects in the ecosystem that pays my bills is a sensible reason on its own. I’d also been meaning to get serious about Astro for a while, and a blog I have opinions about seemed like the right place to do it.
In practice that meant: TypeScript across the codebase, an HTML-first component model that ships no client framework by default, and a typed content layer. Working in it is also simply pleasant - pleasant enough for changes the old setup never got around to: a single archive page that filters by topic and searches at once, and a generated sharing image for every article. Even the dev server is an everyday difference: npm run dev, which my fingers know, instead of hugo server -D -p 3000, which I could never remember.
Controlling the reading experience
This is where owning the code shows up in what readers get. Three examples from this page:
Mermaid-style diagrams, rendered to SVG at build time. A small plugin runs before the code highlighter, so I can write plain Mermaid in a post and get a static SVG in the output - no client scripts, no CDN. For example, the story of this migration:
Syntax highlighting that follows the theme selector. The old theme shipped code samples with one fixed palette. Now the highlighter renders a light and a dark theme in step with the site’s, line markers included - it even carried over the hl_lines hints from the old posts.
Page transitions, where owning the code turned out to mean owning very little of it. The core of what this site uses is one rule:
@media (prefers-reduced-motion: no-preference) { @view-transition { navigation: auto; }}The browser fades the old page out as the new one arrives, the header keeps working through the swap, and focus, history and scroll behave like real navigation, because it is real navigation. When a visitor prefers reduced motion, there’s no animation at all. Astro’s optional client router takes this much further with shared-element animations, and the docs explain when that’s worth it, but I didn’t need it. There’s no framework JavaScript running between clicks on this site. It’s genuinely just a website.
The writing workflow, such as it is
Writing stayed the same all along. In part 2 of the tutorial series I described setting up TinaCMS on the Hugo blog, but I never ended up writing posts through it - my workflow these four years has been: clone the repo, open an editor, write Markdown, commit, push. The new site runs exactly the same one. That’s not a grudge against Tina, by the way: my wife’s blog still runs on it, we’re both happy with how it plays out, and I just never happened to need it for my own writing.
The changes that did land are small. The frontmatter of every post is now validated by a schema at build time, so a typo in a field fails the build instead of shipping quietly. And some things survive any migration: the old URLs, the RSS addresses, and the draft about the operation result pattern, still unpublished - it has now outlived an entire framework.
What didn’t break
- Article URLs are unchanged,
/posts/<the-exact-filename>/, underscored oddities and all, so old links, feed GUIDs and the search engines’ memory still resolve. - The RSS feed kept both of its old addresses, including the
/index.xmlfeed readers may have cached for years - same feed, same articles, same order. - The
sitemap.xmlis alive at its old URL, and the uploaded images still live at their old paths.
The archive’s search and filters and the sharing images give the site more ways to be found rather than changing any of that.
Should you do the same?
If you’re happy with your current setup, I don’t think this is a reason to change it. If you’re not a developer, or you’re a developer whose blog needs to be a blog and not a codebase, Hugo is a perfectly fine, performant, easy-to-use choice - it’s fast, free and simple to get started with (the four-year-old tutorial above still holds up, with version-number caveats), and it will never ask you to think about something you didn’t want to think about. A theme that ships as-is is a perfectly valid end state for a personal site.
The reason for this rewrite was never going to show up in a benchmark. What Hugo couldn’t fix was that opening its repo on a weeknight was never something I wanted to do. I just wanted a stack I enjoyed working with: a site that trains skills I use at work and code I can change without reading someone else’s theme first.
The result: the site is a touch faster in some runs, the build is nine seconds shorter, the article URLs and feed addresses are all preserved, the design is completely different and I like how it’s built. And if you need a one-line summary of everything above: I just wanted to use Astro so bad.