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:test is 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.

Generating host configuration and tests from one redirect file A single redirects.yaml file feeds two generators. One writes the host configuration, such as _redirects for Netlify or a JSON map for a Worker. The other writes test cases, one per literal rule and a few samples per pattern. The preview deploy applies the configuration and the tests run against it. From redirect data to test cases redirects.yaml 1,400 rules, 12 patterns Host config _redirects, Worker JSON Test cases 1,436 expectations Preview deploy rules applied by host Assertions status, Location, final 200
Because tests and host rules come from the same data, a rule can never exist without a test.

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.

Number of failing cases per regression caught Vertical bars of failing test cases. A catch-all rule placed above specific rules shadowed 1,160 redirects. Destinations that had moved again produced 14 redirect chains. A stale path pattern in _headers dropped the immutable header from 1 asset class, shown as one failing header case. Failures reported by the three caught regressions 1,160 Catch-all shadowing rules 14 Redirect chains 1 Missing immutable header
All three were caught on pull requests; before the suite, a similar shadowing bug had been live for nine days.

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.

Header assertions by response type A matrix of response types and the headers asserted on each. HTML pages check revalidating Cache-Control, CSP and HSTS. Hashed assets check immutable Cache-Control and correct Content-Type. The feed checks the Atom Content-Type and a short max-age. 404 responses check that security headers are still present. Redirects check HSTS and no caching of personalised redirects. One header expectation per response type Response Cache-Control Security headers Other HTML page max-age=0, revalidate CSP, HSTS, nosniff Content-Encoding br Hashed asset immutable, 1 year nosniff correct Content-Type Feed max-age=900 nosniff application/atom+xml 404 page no-store same as HTML status 404 not 200 Redirect short or none HSTS Location exact
Six response types cover the header behaviour of the whole site in about thirty assertions.

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

MeasureBeforeAfter (12 months)
Redirect or header regressions in production90
Longest time a broken redirect set was live9 days—
Test cases01,436 redirects + 31 headers
Time per pull request—41 s (Netlify), 18 s (Workers)
Redirect chains in the map14 (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.

Filed underHow-tosNetlify