Developer12 min read

Rebuilding a 19-year-old blog: the technical version

So back in May I wrote about dragging this site out of WordPress and rebuilding it in Astro in about five working hours. That post was the story: nearly shutting the site down three times, the plugin updates I’d been ignoring since roughly 2019, what it felt like coming back after a decade away.

This one is the receipts.

Nobody has asked me for it, to be honest. But the story version skated over the bits that actually ate the time, and if you’re thinking about doing the same thing to your own site, those are the bits you want. So here they are. The decisions I’d make again, the one that cost me an evening, and the URL contract that quietly shaped everything else.

Fair warning: there is more Pagefind in here than anyone strictly needs!

Why Astro, and why Cloudflare Pages

I covered the “why not WordPress” part last time, so I won’t drag you through it again. Short version: I can write code, and a personal blog is a folder of text files doing an impression of a database.

What I didn’t cover is why Astro specifically won, and the honest answer is content collections. Not the component model, not the islands architecture, not any of the things Astro puts on its homepage. It’s that I can define a schema for my posts and have the build refuse to run when I get it wrong.

src/content.config.ts, the bit that matters:

const blog = defineCollection({
  loader: glob({
    pattern: '**/index.md',
    base: './src/content/blog',
    generateId: ({ entry }) => entry.replace(/\/index\.md$/, '')
  }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      date: z.coerce.date(),
      category: z.enum(['ai', 'developer', 'geek']),
      excerpt: z.string()
      // ... optional fields
    })
})

That z.enum is the whole argument. Fat-finger a category and the build dies with a real error message before anything reaches Cloudflare. Miss an excerpt and the same thing happens. After fifteen years of WordPress cheerfully publishing whatever nonsense I typed into a textarea, having the tooling tell me off is a genuine relief!

If raw build speed is your bottleneck, go and take Hugo, it will serve you well. For 130 pages it was never going to be mine. Eleventy is good and I nearly went with it, but you write a fair amount of configuration JavaScript before you get anything at all on screen. Astro gave me a schema, Markdown with co-located images, and a build step that outputs flat HTML with nothing left running afterwards.

Cloudflare Pages was less of a decision than it looks. A couple of years ago I’d have said Netlify or Vercel, and honestly either would serve you fine here. But my DNS was already on Cloudflare, the free tier comes with no build-minute anxiety attached, and public/_redirects is honoured automatically without a config file or a dashboard form to fill in. Given how much redirect work this migration turned out to need (more on that in a second), that last one mattered far more than I expected. It builds in about forty seconds from a GitHub push and leaves absolutely nothing running at the end of it. For a personal blog, that is a wonderful thing to be able to say!

The URL contract

If you take one thing away from this post, take this one. Sort out your URLs before you sort out anything else, because every other decision you make will bend around them.

The WordPress site had been collecting inbound links since 2007. Some of those old posts still pull steady search traffic: a CentOS setup guide, a couple of articles about obscure PHP errors that apparently a handful of other people also hit. Those URLs looked like /<category>/<slug>/, and breaking them would have thrown away the only thing the old site had genuinely built up over nineteen years.

So the rule was set before a line of code got written: every old URL keeps working, or 301s somewhere sensible. No exceptions, and absolutely no “I’ll sort the redirects out later.”

Is that overkill for a personal blog nobody’s been reading? Probably, yes. I did it anyway, because those inbound links are the one asset nineteen years of intermittent effort actually produced, and throwing them away to save myself an afternoon would have been a daft trade.

In Astro that shape falls straight out of the loader, which is neat. Your posts live at src/content/blog/<category>/<slug>/index.md, one folder per post so your images sit right next to the Markdown that references them. The generateId callback above strips the /index.md, so the entry ID becomes developer/some-post, which doubles as the URL. Pair that with trailingSlash: 'always' in your Astro config (WordPress URLs had trailing slashes, and making people follow a 301 chain just to add one back is a waste of everybody’s time) and the contract more or less holds itself up.

The interesting bit is what happens to the posts that don’t fit.

I had three retired categories. my-plugins folded into developer, sixteen posts, all straight one-to-one 301s. Four reviews posts went to geek. Neither was hard.

The music posts were messier, and this is where a tidy plan meets reality. I have a separate site for music now, so the obvious answer was to send them all to willwilsonmusic.com. Except three of them weren’t really music posts at all. They were gear reviews (an Alesis drum kit, an audio interface, a Toontrack plugin) that happened to live under musician because that was the closest category WordPress offered me in 2013. Sending a drum kit review to a music-project homepage would have been useless for anyone landing on it from search. So those three went to /geek/ instead, and only the genuinely music-shaped posts went off-domain.

Then there were three posts I simply didn’t want any more. An old portfolio update, two car reviews. Rather than let them 404, they 301 to the relevant category listing:

public/_redirects, the awkward cases:

/developer/new-websites-added-to-the-portfolio/  /blog/developer/  301
/geek/mazda-mx5-sporttech-roadster/  /blog/geek/  301
/musician/alesis-dm6-usb-kit-review/  /geek/alesis-dm6-usb-kit-review/  301
/musician/noodling-on-the-piano/  https://willwilsonmusic.com/  301

Four lines, four different destinations, and that is roughly the point. The tidy version of this story is “I mapped the old categories onto the new ones.” The true version is that about a fifth of the redirects needed a judgement call about where a real person, arriving from a ten-year-old Google result, would actually want to end up.

So when you do this on your own site, budget for that. Not every old URL deserves a new home. Some of them just deserve a decent nearby one.

Pagefind: the bits the docs don’t tell you

Search is where I lost the evening, so this section is longer than the others. If you’re wiring Pagefind into an Astro site, this is the bit worth your time.

Pagefind itself is a lovely piece of work. It builds a search index out of your already-built HTML, ships it as WASM, and runs entirely in your reader’s browser. No API key, no third party, no monthly bill, nothing to keep secure. For a static site with 130 pages it is close to perfect!

Getting it to behave, though, took four false starts.

Load it with a classic script tag, not a dynamic import(). This is the one that cost me the evening, so please learn it on my time rather than yours. The shipped bundle is an IIFE that registers window.PagefindUI as a global. It is not an ES module with named exports. If you reach for import(), which is the modern instinct and was certainly mine, your import resolves, because the file genuinely does exist, and then the destructure quietly fails. No error in the console. No thrown exception. Nothing. Your page just shows the dev-mode fallback message, in production, looking for all the world like the index never got built.

You will debug your build for a good while before you think to debug your loader. I certainly did:

src/pages/search.astro, the loader that actually works:

<script
  is:inline
  src="/pagefind/pagefind-ui.js"
  onload="new window.PagefindUI({ element: '#search', showSubResults: true });"
  onerror="document.getElementById('search-fallback')?.classList.remove('hidden');"
></script>

is:inline keeps Astro’s bundler away from it, which is exactly what you want here, because the file doesn’t exist in src/ at build time. It gets generated afterwards. The onerror handler earns its keep too: in npm run dev there’s no index at all, so the script 404s and your fallback message appears telling you to run a production build instead. Failing honestly beats failing mysteriously every single time.

Use the Default UI, not the Component UI. Pagefind 1.5 prints a notice nudging you toward the Component UI, and on paper it is the better choice: search modal, better accessibility, more customisation. I tried it. The problem is that the internal element structure isn’t documented well enough for you to restyle it confidently, and this site has fairly strong opinions about how it should look. The Default UI gives you a documented CSS custom property API (--pagefind-ui-primary and friends) plus stable .pagefind-ui__* class hooks, which makes the theming tractable. So should you ignore the notice too? Only if you care what your search page looks like. If you want a Cmd+K modal and you’re relaxed about the styling, take the Component UI with my blessing.

Expect to write !important, and don’t fight it. Pagefind’s stylesheet loads after yours. The <link> sits in the body of my search page for code locality, whilst Astro hoists <style is:global> up into the head, so Pagefind wins on source order every time. It’s also built with Svelte, so it ships scoped rules like .pagefind-ui__result-link.svelte-j9e30:hover at a specificity you are simply not going to beat by being clever about your selectors. I spent a while trying to re-architect the load order before accepting that !important on the layout properties was the right answer all along. Sometimes the pragmatic fix just is the fix.

The magnifying glass icon is a colour-baked SVG. It ships as a background-image: url(data:...) in a blue-purple that looks perfectly nice on Pagefind’s own site and quite wrong on mine. You can’t recolour it with color and you can’t get at it with a CSS variable. filter: invert() gets you something, but it won’t get you rust, and it’s fragile. What you want is to kill the background image entirely and paint through a mask instead:

.pagefind-ui .pagefind-ui__form::before {
  background-image: none !important;
  background-color: var(--color-ink-muted) !important;
  mask-image: url("data:image/svg+xml;utf8,<svg …/></svg>") !important;
  -webkit-mask-image: url("data:image/svg+xml;utf8,<svg …/></svg>") !important;
}

Now the icon takes its colour from the palette like everything else does.

One last oddity, and this one is pure Astro rather than Pagefind. I had a JSDoc comment sitting in the frontmatter of search.astro explaining the script-tag decision, and it contained backticks around `import()`. Astro’s esbuild-based dependency scanner extracted my comment, tried to parse it as JavaScript, and then choked trying to read the backticked text as a template literal. The production build was completely fine. The dev server died on its arse! So if you’re ever documenting script tags inside an .astro frontmatter block, use plain // comments and save yourself twenty extremely confused minutes.

Tailwind v4 without a config file

Tailwind v4 moves configuration into CSS. There is no tailwind.config.js on this site at all, which felt deeply wrong for about a day and then felt obviously correct!

Your design tokens live in an @theme block in your stylesheet, and Tailwind generates utilities from them:

src/styles/global.css, trimmed:

@import 'tailwindcss';

@theme {
  --color-bg: #131210;
  --color-ink: #ece4d0;
  --color-rust: #d06b35;
  --font-serif: 'Newsreader Variable', Georgia, serif;
}

Define --color-rust and you get text-rust, bg-rust, border-rust and all the rest of them, for free! The naming convention is load-bearing though, so watch it: the --color- prefix is what tells Tailwind to generate colour utilities, and a token you name --rust will give you precisely nothing.

Two things the migration docs skate over. The first is that these are real CSS custom properties rather than a build-time abstraction, so you can reference them from hand-written CSS in the same file. That mattered enormously for the Pagefind restyling above, where I’m mapping a third-party component’s variables onto my own palette. Under a JavaScript config that’s an awkward export dance. Here it’s just var(--color-rust) and you carry on with your day.

The second is the scanner, and this one will get you. Tailwind reads your source files looking for class names as plain text, so anything you assemble dynamically is completely invisible to it. Write class={`bg-${variant}-500`} and you get no CSS and a baffled half-hour. Write your class names out in full and pick between them, and it works. Same rule as v3, but it bites harder now there’s no config file to bolt a safelist onto.

Google Analytics is the reason this site still exists. I said as much in the comeback post: three separate times I went to shut it down, looked at the numbers, saw that a handful of ancient articles were still helping people, and left it alone.

So it was a slightly odd decision to deliberately make those numbers smaller.

The site runs GA4 behind Consent Mode v2. Consent defaults to denied, gtag.js doesn’t load at all until someone accepts the cookie banner, and their choice persists in localStorage. No consent, no script, no measurement. That’s the honest implementation, as opposed to the one where you load the tag and ask permission afterwards.

So the numbers went down. Of course they went down, and I fully expected them to. Plenty of visitors never touch the banner either way, and every one of those people is now invisible to me.

Was it worth it? Yes, and here’s why: it’s still useful. I can still see which of those fifteen-year-old posts are pulling people in from search, and that was the only question I ever genuinely asked the analytics in the first place. The absolute numbers are lower. The relative picture hasn’t moved. Lower and honest beats higher and inflated, and I would much rather have a smaller sample I can defend than a bigger one I can’t.

What earned its keep

The rest is unglamorous and worked first time. RSS via @astrojs/rss, a sitemap from @astrojs/sitemap, and an /llms.txt listing every page, because the AI crawlers are going to read the site regardless and they may as well read a clean index of it. robots.txt allows the lot of them. I know that’s a live argument and you may well disagree with me. My old posts helped people when those people arrived via Google, and I can’t talk myself into caring that the retrieval mechanism changed.

If I did the whole thing again I’d change one thing. I’d build the redirect map before the content migration instead of alongside it. Working out where 120 posts ought to live is a completely different job from moving them, and doing both at once is exactly how you end up 301ing a drum kit review to a music homepage and only catching it on the final read-through.

Everything else I’d keep. The schema that refuses to build. The folder-per-post layout. Pagefind, !important and all.

Have you moved an old site recently, and did you keep your URLs working? I’d genuinely like to know how many people still bother.


Filed under Developer. No comments, on purpose.