Medium wave · 540–1600 kHz78 stn
1368kHz

Your Redirect Rules Are Untested Code

A towering brass pneumatic-tube switchyard in drifting fog, dozens of hand-thrown routing levers diverting glowing capsules down branching copper tubes, one lever jammed so its capsule loops endlessly back into itself, neon purple and amber light tracing every path, dark steampunk signal hall with no operator at the controls

You rename a post’s slug. You add a line to _redirects. You deploy, click the old URL once, see the new page, and move on.

That line is now permanent infrastructure. It runs on every request to that path, forever, and nothing in your repo knows it exists. Six months and forty rules later, nobody on the team can tell you which of them still resolve.

Everything else in a static build is verified. Content fails the build when the frontmatter is wrong. The link checker crawls dist/ and screams about a dead anchor. Types get checked, the bundle gets budgeted, the HTML gets validated.

The redirect map gets none of that. It is the one piece of your site that is code, ships with every deploy, and has no tests.


Why nothing catches it

Redirects don’t live in your output. They live in a file your host parses at request time — _redirects on Netlify and Cloudflare Pages, a redirects array in vercel.json, routes in render.yaml, a location block in nginx, RedirectMatch in .htaccess.

Your build never executes any of it. Astro, Next, and Eleventy will copy that file into dist/ without looking inside. It is an opaque blob until the CDN reads it.

So the tooling that protects the rest of your site structurally cannot see it:

# Your link checker crawls the built HTML.
# It never requests /old-slug, because nothing links to /old-slug.
# That is the entire point of the redirect.

A redirect exists to serve URLs your site no longer references — old slugs, links in someone else’s blog post, a QR code on a conference flyer. By definition they are unreachable from your own pages, which means every crawler-based check you own is blind to them.


Three ways the file is already wrong

Order does not mean what you think

Most _redirects-style files are first match wins, top to bottom. So this is broken:

/blog/*     /posts/:splat    301
/blog/rss   /feed.xml        301

The splat swallows /blog/rss on line one. Line two is dead code that will never execute, and nothing told you — no parse error, no warning, no failed build. The rule is simply never reached.

The fix is trivial once you know the rule: specific before general.

/blog/rss   /feed.xml        301
/blog/*     /posts/:splat    301

What makes this genuinely dangerous is that the precedence rule is not the same everywhere. Array-order evaluation in vercel.json behaves like the file above. Nginx does not: prefix location blocks match by longest prefix regardless of where they sit in the config, while regex locations are tried in the order they appear. Apache’s Redirect matches on prefix, so a rule for /docs quietly catches /docs-archive too.

Move a site between hosts and your redirect map is the piece most likely to change meaning without changing a character.

Chains that nobody flattened

Each rename adds a hop. You go from /old-name to /new-name, then later to /posts/new-name, and the honest-looking file grows:

/old-name    /new-name          301
/new-name    /posts/new-name    301

That is two round trips for every visitor arriving on the oldest link, and each hop is a full request against the CDN edge before any HTML moves. Crawlers cap how many hops they will follow before giving up on the destination entirely.

Every old URL should point at the current target, not at the previous rename:

/old-name    /posts/new-name    301
/new-name    /posts/new-name    301

Chains are a maintenance artifact, not a requirement. Flatten them every time you add a hop.

Loops with your host’s own normalization

Your host already rewrites URLs before your rules ever run — adding or stripping a trailing slash, lowercasing, collapsing index.html. Write a rule that fights that behavior and you get a loop:

/docs    /docs/    301

If the platform is configured to strip trailing slashes, it sends /docs/ back to /docs, your rule sends it to /docs/, and the browser gives up with ERR_TOO_MANY_REDIRECTS. It works fine in local dev, because your dev server has different normalization than your CDN.

And when it breaks, it breaks permanently: browsers cache a 301 aggressively and will keep following the loop from cache even after you delete the rule. Use 302 while a redirect is still in flux, and promote it to 301 once you are certain. A wrong 302 is a bug. A wrong 301 is a bug your users carry around in their browser.


Test them like the code they are

Redirects are a pure function: URL in, status and Location out. That is the easiest thing in your stack to assert on.

Write the assertions as a flat table. This is your routing spec, and it belongs in the repo next to the rules:

# redirects.spec - path, expected status, expected Location
/old-name              301  /posts/new-name
/blog/rss              301  /feed.xml
/blog/some-old-post    301  /posts/some-old-post
/docs                  200  -

Then check it against a real deployment — not a local dev server, because the dev server is not the thing that will serve your users:

// scripts/check-redirects.mjs
import { readFileSync } from 'node:fs';

const base = process.argv[2];
const rows = readFileSync('redirects.spec', 'utf8')
  .split('\n')
  .filter((line) => line.trim() && !line.startsWith('#'))
  .map((line) => line.trim().split(/\s+/));

let failed = 0;

for (const [path, status, location] of rows) {
  const res = await fetch(base + path, { redirect: 'manual' });
  const got = res.headers.get('location') ?? '-';
  const ok = res.status === Number(status) && got.replace(base, '') === location;

  if (!ok) {
    failed++;
    console.error(`FAIL ${path}\n  want ${status} ${location}\n  got  ${res.status} ${got}`);
  }
}

console.log(`${rows.length - failed}/${rows.length} redirects OK`);
process.exit(failed ? 1 : 0);

redirect: 'manual' is the whole trick. The default fetch behavior follows redirects and hands you a 200 from the destination, which passes whether your rule is correct, chained through four hops, or missing entirely because the destination happens to resolve on its own.

Run it against the preview URL in CI, after deploy and before promoting:

node scripts/check-redirects.mjs "https://deploy-preview-42.example.com"

Thirty lines, no dependencies, and it turns the one unverified file in your repo into something that can fail a build.


Two rules to keep it from rotting

Add a spec row in the same commit as the rule. Not later. The commit that renames a slug touches three things: the content file, the redirect rule, and the assertion. If your reviewer sees two of the three, the change is not finished.

Never redirect in the page. A meta refresh tag or a location.href assignment in a script is not a redirect — it is a full page load, a render, and then a second full page load, with no status code for anything downstream to act on. Sites reach for it because it is the only redirect you can ship without touching host config, which is exactly why it keeps showing up in static builds. It costs your users a round trip to avoid editing a config file.


The takeaway

Your redirect map is the oldest, least-read, most-executed code you own. It is written in a DSL that changes meaning between hosts, evaluated by a machine you do not control, and reviewed by no one after the day it was written.

Give it a spec file and thirty lines of CI. You will find a dead rule on the first run.