IT

← All writing

Keeping fourteen years of URLs alive

  • seo
  • upsun
  • astro

When I rebuilt this site on Astro, the content moved and the URLs changed. That’s the moment most migrations quietly break the web: a post that lived at one address for a decade now returns a 404, and every link to it — from search results, other people’s blogrolls, a bookmark someone made in 2015 — dies with it.

Tim Berners-Lee wrote “Cool URIs don’t change” in 1998, and he was right then and now. So before I deleted anything, I worked out the contract: every URL the old WordPress site ever served would continue to resolve, either to its new home or to somewhere sensible. This post is how that contract is enforced — and, just as importantly, how I proved it holds.

The shape of the old URLs

WordPress here used dated permalinks. Every post lived at:

/some-post-slug/YYYY/MM/DD/

Fourteen years of them. Some I kept — the ten best went into a dated archive — and some I dropped, because a 2013 snippet about a Drupal hook_views_query alteration is not something anyone needs served as current advice. Both groups still had to resolve; they just resolve to different places.

Then there were the section pages: /company/, /projects/, /blog/, /feed/, category and author archives. All indexed, all linked, all about to disappear.

Redirects belong at the edge

A static site has no application at request time — there’s no PHP process to inspect the URL and issue a redirect. That turns out to be a feature, not a problem. On Upsun, redirects are declared in the router, in .upsun/config.yaml, and handled at the edge before any request reaches the app. They’re just configuration:

routes:
  "https://www.{default}/":
    redirects:
      expires: 1y
      paths:
        '^/company/?$':
          to: "https://www.{default}/about/"
          regexp: true
        '^/feed/?$':
          to: "https://www.{default}/rss.xml"
          regexp: true

The section pages are one-liners like these. The interesting one is the posts, because there are too many to list by hand.

One regex for the kept posts

Every kept post follows the same shape, so one rule maps all of them:

'^/([a-z0-9-]+)/[0-9]{4}/[0-9]{2}/[0-9]{2}/?$':
    to: "https://www.{default}/writing/archive/$1/"
    regexp: true

The capture group grabs the slug; the date is matched and discarded; $1 drops the slug into its new archive path. /coping-with-technical-debt/2012/04/03/ becomes /writing/archive/coping-with-technical-debt/, and so do the other nine, without an entry each.

The reason this rule is safe — that it can’t accidentally swallow a new URL — is the date. Every new route on the site is made of word segments: /writing/, /work/, /about/. None of them can match .../YYYY/MM/DD/, because none of them have four-then-two-then-two digits in them. The old URL shape is, by construction, disjoint from the new one.

Order matters: dropped posts first

The dropped posts are a problem, because they match that generic rule too — and it would send them to /writing/archive/their-slug/, which doesn’t exist. So they need an earlier, more specific rule that catches them first and sends them to the archive index instead:

'^/(curl-http-request|qualtrics-module|drush-multi-site-and-sites-php|…)/[0-9]{4}/[0-9]{2}/[0-9]{2}/?$':
    to: "https://www.{default}/writing/archive/"
    regexp: true

Redirect rules are evaluated in order, so this alternation sits above the catch-all. A dropped post matches here and stops; a kept post falls through to the generic rule below. Specific before generic — the oldest lesson in routing, and the easiest to forget until something 404s.

Make them permanent

By default these redirects are temporary — a 302. That’s wrong for a migration: a 302 tells search engines “this move might not last, keep the old URL indexed.” What I wanted was a 301, which says “this is permanent, transfer the authority.” One line per rule:

        '^/company/?$':
          to: "https://www.{default}/about/"
          regexp: true
          code: 301

Fourteen years of accumulated link equity flows to the new URLs instead of evaporating.

Proving it

A redirect table you haven’t tested is a guess. The thing that made verification mechanical was a decision I’d made in the archive content itself: every migrated post carries the URL it used to live at, in its front matter.

originalUrl: "/coping-with-technical-debt/2012/04/03/"

That field is an audit trail. With it, checking the whole contract is a loop over every old URL, asserting each one returns a 301 to the right place:

curl -sI "https://www.artetecha.com/coping-with-technical-debt/2012/04/03/"
# HTTP/2 301
# location: https://www.artetecha.com/writing/archive/coping-with-technical-debt/

Run that across every originalUrl, plus the section pages and the dropped slugs, and either everything is a 301 to a live 200 — or you’ve found the rule you got wrong before your readers did.

The CMS was the part everyone notices. The redirect table is the part that decides whether the last decade of this site’s presence on the web survives the change. It’s fifty lines of YAML. It was worth every one.