Recovering Two Years of Dead URLs

This is the third version of this site. That is not a boast.

The first ran on Wix from 2023 to 2024 and had 18 posts. I moved it to Hashnode and did not bring them with me. Every one of those prahari.net/post/<slug> URLs has returned a 404 ever since — including every link I had shared on LinkedIn during that period.

I did not notice for two years.

What that actually cost

Three things, and I had only thought about the first.

The posts. Recoverable, as it turned out. I had scraped them before decommissioning Wix and committed the markdown to a repo. I had also, at some point, deleted that from the repo as “old junk” — it survived only because git does not forget.

The links. Every share, every reference, every bookmark from that period pointed at a page that no longer existed. Nobody tells you about this. There is no error report for a link someone else stopped being able to follow.

The search authority. This is the one I had not considered. Those pages had accumulated two years of ranking. When the URLs died, that went with them — and unlike the content, it does not sit in a git object waiting to be recovered.

The thing I got wrong the first time

I treated the migration as a content problem. Get the posts across, done.

It is a URL problem. The content is the easy part; the addresses are the contract you made with everyone who ever linked to you.

So this time the redirect map came first — 23 mappings from old URL to new — and it is enforced by a test that runs on every build:

it.each(LEGACY_URLS)('$to resolves to a real built page', ({ to }) => {
  const page = join(DIST, to.replace(/^\//, ''), 'index.html');
  expect(existsSync(page), `redirect target ${to} has no built page`).toBe(true);
});

That test does not check the redirect exists. It checks the redirect’s destination exists. A 301 pointing at a missing page is worse than the 404 it replaced, because it looks fixed.

What the migration turned up

Working through it surfaced a run of things I would not have found by reading the code.

The scraper had silently dropped every image. The markdown it produced referenced exactly one image per post — the author avatar. Its own log claimed 153 images downloaded, but the archive held four. The originals were still on Wix’s CDN, so I pulled 28 real content images back before that account lapsed. Of the 122 files I downloaded, five appeared in all 18 posts — site chrome, including a 2.2 MB header banner — and one was a 604 KB tracking pixel.

A “fix” changed what I had written. Hashnode’s editor had turned two lone tildes into a <del> element. Reconstructing that as angle brackets turned “near readings (~17cm)” into ”(<17cm)” — approximation into comparison. The log output quoted directly above that line reads 17.1cm, which supports the tildes. I only caught it because I had the original draft to compare against.

Alt text was missing from ten of fifteen images. An accessibility regression, and indexed content, on the pages I was specifically trying to recover.

My subscribe button was unreadable in dark mode. White text on the dark-mode accent measured 2.48:1 against a 4.5:1 minimum — on the one element whose entire job is converting readers.

What I would tell myself in 2024

Own the URLs before you own the content. Redirects are not a migration afterthought; they are the migration.

Put the archive in git, not in a platform. The only reason those 18 posts exist is that markdown once went into a repository. The platform copy vanished with the account.

Verify more than the obvious thing. My cutover checklist said “diff the MX records.” Reading the zone showed mail also depended on SPF, DKIM, an MX on a subdomain I had forgotten, and three Workspace CNAMEs. SPF or DKIM breaking does not bounce mail — it silently routes it to spam, so “I received the test email” can pass while delivery is already broken for everyone else.

Make the silent failures loud. Tag slug collisions now fail the build. An unresolvable cover image throws. A post that skips a heading level fails a test. Every one of those was a real defect found once, converted into something that cannot happen quietly again.

Where it stands

Twenty-three posts, twenty-three redirects, all verified on every build. Publishing is now npm run new, write, git push.

This post is the first one through that pipeline. If you are reading it, the thing worked.


This is a hobby project and I am learning by building. If you have suggestions, corrections, or criticism — I would genuinely like to hear it.

Co-authored with Claude Code (Anthropic) — my AI pair-programming partner for this rebuild.

#DevOps#SEO#Astro#Cloudflare

Get new posts by email

No spam. Unsubscribe whenever.