Changelog

SEO Overhaul

SEO Overhaul

Good SEO is one of those things that looks fine right up until you actually check. My site had meta tags on every page, structured data on every article, and a sitemap that dutifully listed everything. On paper it was all there. In practice, a fair bit of it wasn't doing what I assumed it was.

So I went through the site page by page, and this is what came out of it.


The things that were quietly broken

The most embarrassing one first: every unknown URL returned a 200. Next.js needs a bit of care here when you have more than one root layout, and my workaround rendered a "page not found" screen while telling the browser everything was fine. Search engines call that a soft 404, and it leaves them guessing at which pages are real. Article pages were worse: a missing post redirected to /404 and then answered with a 200.

The sitemap was advertising drafts. Sixteen of them — unfinished posts I've never linked from anywhere — alongside a couple of API endpoints and a page that only exists as a redirect. Every entry also claimed to have been modified today, on every single deploy, which is a fast way to teach a crawler to ignore your dates entirely.

And not one page on the site emitted a canonical URL.

Then there was the OG image pipeline, which was a subtler problem: it worked, but only in one place. The image URLs were assembled from an environment variable that lives in my Vercel project settings and nowhere else — not in .env-sample, not anywhere in the repository. Production was fine. Everywhere else, every social preview URL came out as undefined/og?…, which meant the one part of the site I couldn't inspect without deploying was also the part I had the least reason to trust. It now derives from the site metadata that already lives in the repo, with the environment variable kept as an optional override, so it resolves identically in every environment — and it's written down in .env-sample this time.

That turned out to matter, because once it was checkable I found something that hadn't shipped yet: absent values were being serialised into the query string as the literal string undefined, so the subtitle I'd just added to the cards would have rendered the word "undefined" across every article. Caught before it went out, which is the good ending.

What's new

  • Canonical URLs everywhere, generated from a single source of truth rather than hand-written per page.
  • A robots.txt, which the site simply didn't have. It points at the sitemap and keeps preview deployments out of the index.
  • An RSS feed at /rss.xml, covering writings, projects, changelogs and the Interstellar Marines series.
  • Real 404s, so a dead link is unambiguous instead of quietly successful.
  • Prerendered articles. Every post was being rendered on demand even though all the content is known at build time. They're built ahead of time now, which cuts TTFB — the number LCP is measured from.
  • Repaired structured data. Publisher and author were being emitted as plain strings where schema.org expects objects, wordCount was misspelled, and the WebSite node was duplicated onto every article. Listing pages now describe what they contain, and every article points back to one shared identity for me and for the site.

What I took away from it

Nearly all of this was invisible from inside the editor. The code read perfectly well — the bugs lived in the gap between what it said and what it actually produced. Reading the function that builds a meta tag tells you what it intends; only the served page tells you what it emits. So that's how I checked all of it in the end: run the production build, request every page, read the HTML that comes back. Slower than skimming a diff, and the only way to catch the difference between metadata that exists and metadata that works.

The other lesson was about configuration. Anything that lives in exactly one environment is effectively invisible everywhere else, and code you can't run outside production is code you can't really review. Moving the origin back into the repository — with the environment variable demoted to an override — cost almost nothing and made the whole subsystem inspectable on a laptop. That's the change I expect to get the most quiet mileage out of.

And if you'd rather not check back here manually, the feed will let you know when there's something new. ✨