Your Redirect Rules Are Untested Code

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.

