Testing Redirects and Headers Before Deploy
Redirects and response headers are the parts of a static site that live outside the build. They sit in _redirects, _headers, netlify.toml, vercel.json, a CloudFront function or a Worker, and they are applied by the host at request time. That makes them invisible to every test that reads the build directory, and it makes them fragile: a reordered rule shadows a thousand redirects, a new path pattern in _headers drops the immutable cache header from all JavaScript, a platform migration forgets the security headers entirely. On the two sites measured here, redirect and header regressions were 12% of all defects, and each one was found by readers or search engines days later.
This guide turns redirects and headers into a table of expectations, generates most of the table from the same data that produces the host configuration, and runs it against every preview deploy before production. It is part of Testing Static Sites in CI. The examples come from a Hugo documentation site with 1,400 redirects from two migrations, deployed on Netlify, and an Astro site on Cloudflare Workers static assets.
Prerequisites
- A preview deploy per pull request with its URL available to CI.
- Redirects defined in data (a JSON or YAML file) rather than only in host syntax — if they are not, the first step below converts them.
- Node 20+ for the test runner (the built-in
node:testis enough).
Step 1: One Source for Redirects
Redirect rules that exist only in _redirects syntax cannot easily be tested or moved between hosts. Keep them in a data file and generate the host format at build time:
# data/redirects.yaml
- { from: /docs/installation.html, to: /guide/install/, status: 301 }
- { from: /docs/:section/:page.html, to: /guide/:section/:page/, status: 301 }
- { from: /blog/2019/*, to: /archive/2019/, status: 301 }
- { from: /chat, to: https://discord.gg/example, status: 302 }
A build script writes _redirects for Netlify, redirects.caddy for self-hosting, or a redirects.json for a Worker from the same list, as in Keeping Redirects Working After an SSG Migration. The test generator reads the same file.
Step 2: Generate the Expectation Table
Each literal redirect becomes one test case. Each pattern rule becomes a few concrete cases from sample paths. Headers get a hand-written table, because there are few of them and each encodes a decision:
// tests/http/cases.mjs
import yaml from 'yaml';
import { readFileSync } from 'node:fs';
const rules = yaml.parse(readFileSync('data/redirects.yaml', 'utf8'));
export const redirectCases = rules.flatMap((r) =>
r.from.includes(':') || r.from.includes('*')
? (r.samples ?? []).map(([from, to]) => ({ from, to, status: r.status }))
: [{ from: r.from, to: r.to, status: r.status }]);
export const headerCases = [
{ path: '/', expect: { 'cache-control': /max-age=0|must-revalidate/, 'strict-transport-security': /max-age=\d{8}/ } },
{ path: '/guide/install/', expect: { 'content-security-policy': /default-src 'self'/, 'x-content-type-options': 'nosniff' } },
{ path: '/_astro/index.BX3k9aQ2.js', expect: { 'cache-control': /immutable/ } },
{ path: '/feed.xml', expect: { 'content-type': /application\/(atom|rss)\+xml/ } },
{ path: '/manifest.webmanifest', expect: { 'content-type': /application\/manifest\+json/ } }
];
Hashed file names change every build, so the header test discovers a real asset path from the built HTML rather than hard-coding one.
Step 3: The Runner
The runner requests each case without following redirects, checks the status and Location, then checks that the destination itself returns 200. Concurrency keeps it fast:
// tests/http/redirects.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import pLimit from 'p-limit';
import { redirectCases } from './cases.mjs';
const BASE = process.env.BASE_URL;
const limit = pLimit(16);
test('redirects', async () => {
const failures = [];
await Promise.all(redirectCases.map((c) => limit(async () => {
const res = await fetch(BASE + c.from, { redirect: 'manual' });
const loc = res.headers.get('location') ?? '';
const target = new URL(loc, BASE);
if (res.status !== c.status) failures.push(`${c.from}: status ${res.status}, expected ${c.status}`);
else if (target.pathname + target.search !== c.to && target.href !== c.to) failures.push(`${c.from}: → ${loc}, expected ${c.to}`);
else if (target.origin === new URL(BASE).origin) {
const final = await fetch(target, { redirect: 'manual' });
if (final.status !== 200) failures.push(`${c.from}: destination ${c.to} returned ${final.status}`);
}
})));
assert.deepEqual(failures, []);
});
On the Netlify preview, 1,436 cases took 41 s at a concurrency of 16. Collecting all failures before asserting, rather than stopping at the first, means one run shows everything a rule change broke.
Step 4: What It Caught
In its first month the suite caught three real problems. A pull request that added a catch-all rule for /docs/* above the specific rules in _redirects — Netlify applies the first match — silently shadowed 1,160 specific redirects; every one of them failed. A header refactor changed /assets/* to /_astro/* in _headers for the new Astro output but left the old path pattern, so JavaScript lost its immutable cache header. And 14 redirects pointed at pages that had since been moved again, producing chains; the destination check flagged them.
Step 5: Headers on Every Response Type
Headers vary by response type, so test one example of each: an HTML page, a hashed asset, an unhashed asset (favicon.ico, robots.txt), the feed, a 404 response, and a redirect. The 404 matters because some hosts and server configurations drop custom headers on error responses — nginx's add_header without always is the classic case, described in Serving a Static Site with Nginx. The redirect matters because HSTS on the redirect response is what protects the first request.
For previews specifically, assert the inverse too: X-Robots-Tag: noindex must be present, as described in robots.txt and noindex for Preview and Staging. A post-deploy run against production asserts that it is absent.
Running Locally Against an Emulator
Waiting for a preview deploy to test a redirect change is slow. Most hosts have a local emulator that applies the same rules: netlify dev for _redirects and _headers, wrangler dev for Workers with static assets, vercel dev for vercel.json. Pointing BASE_URL at the emulator runs the same suite in seconds on a laptop. The emulators are close but not identical to production — Netlify's local redirect engine differed from production on one edge case involving query-string matching — so the preview run remains the gate, and the local run is for fast iteration.
Once the table exists, it earns its keep a second time during host migrations. Moving the Astro site from Cloudflare Pages to Workers static assets meant rewriting redirect and header configuration in a new format; running the unchanged expectation table against the new deployment proved parity before DNS moved, and found two header rules that had been silently dropped in translation.
Keep the header table short and opinionated; each row documents a decision someone made about caching or security, and the table is where a new team member learns those decisions.
Measured Impact
| Measure | Before | After (12 months) |
|---|---|---|
| Redirect or header regressions in production | 9 | 0 |
| Longest time a broken redirect set was live | 9 days | — |
| Test cases | 0 | 1,436 redirects + 31 headers |
| Time per pull request | — | 41 s (Netlify), 18 s (Workers) |
| Redirect chains in the map | 14 (unknown) | 0 |
Pitfalls & Rollback
- Following redirects in tests. Hides chains and wrong status codes; test each hop.
- Hard-coding hashed asset names. They change every build; discover them from HTML.
- Testing only HTML pages for headers. Error pages and redirects often differ.
- Rule order. Many hosts apply the first matching rule; keep specific rules above patterns and test both.
- Rollback: the suite changes nothing on the site. If a redirect change fails the suite after merge, revert the data file; the host config regenerates from it.
Conclusion
Redirects and headers deserve tests precisely because they live outside the build. Keep redirects in data, generate both host configuration and test cases from it, assert headers per response type, and run the table against every preview. On these sites that turned a class of silent, days-long regressions into pull request failures that took seconds to understand.
FAQ
Why can't redirects be tested against the build directory?
Redirects and headers are applied by the host, from files like _redirects, _headers, vercel.json or a Worker, not by the static files themselves. Only a request to a deployed preview, or to a local emulator of the host, exercises the real rules.
How many redirects is too many to test on every pull request?
Thousands are fine. Redirect checks are single HEAD or GET requests without following redirects, and with modest concurrency 1,400 checks took about 40 seconds against a preview. Very large maps can be sampled on pull requests and checked in full nightly.
What headers are worth asserting?
Cache-Control for each asset class, Content-Type for non-HTML files such as feeds and manifests, the security headers such as Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options and Referrer-Policy, X-Robots-Tag on previews, and Content-Encoding for compressed responses.
Should the tests follow redirects?
Test each hop explicitly without following, asserting the status code and Location header, then separately assert that the final destination returns 200. That catches chains, loops and permanent redirects that should have been temporary.
Related
- Parent: Testing Static Sites in CI — the rest of the suite.
- Netlify Redirects and Rewrites for Static Sites — rule order and syntax.
- Configuring Redirects on Cloudflare Pages — the Cloudflare equivalent.
- Writing a Content Security Policy for a Static Site — the header most worth asserting.
- Post-Deploy Verification Checklists — running the same table against production.