A made-up site and the agreed map
This is a synthetic specification, not a client case and not a test of a real site. The site has six live pages (home, about, two service pages, one article and contact). Nine old addresses must be handled according to an agreed map: each goes to one named page, or is deliberately gone. The agreed destinations are: /about-us and /team to /about; /services/web-design and /services/design to /services/web; /services/seo-audit and /services/search to /services/seo; /blog/2019/tips to /blog/website-tips; /contact.html to /contact; and /products/blue-widget removed on purpose with status 410. The invented redirect rules for the site before and after the fix, and the script that follows them, are printed in the next section; the two sheets on this page are exactly that script's output.
- A pass needs a final page that returns 200, one permanent redirect (301 or 308) and the agreed destination, or the agreed 410.
- Codes list each hop in order, so 301>301 is a chain of two permanent redirects.
The rules and the script
Save this as a file and run it with Node; it needs nothing else. The rules are plain tables of address, status and destination, so you can edit one and see the sheet change. It follows each old address through the rules, records every status, and applies the pass rule above. It prints the sheet for the before rules under the word BEFORE, then the sheet for the after rules under AFTER; the two sheets below are those two blocks without the headings. It tests nothing real: no server, no network.
- before is the invented current state; after is the corrected one.
- An address with no rule is simply not redirected, so a missing address answers 404 unless it is one of the six live pages.
If the matrix is wider than the box, scroll horizontally to read every column. Keyboard: focus the matrix and use Left/Right.
// Invented site: six live pages return 200.
const live = ['/', '/about', '/services/web', '/services/seo', '/blog/website-tips', '/contact'];
// The agreed map: old address -> its destination, or 410 for "deliberately gone".
const agreed = {
'/about-us': '/about',
'/team': '/about',
'/services/web-design': '/services/web',
'/services/design': '/services/web',
'/services/seo-audit': '/services/seo',
'/services/search': '/services/seo',
'/blog/2019/tips': '/blog/website-tips',
'/contact.html': '/contact',
'/products/blue-widget': 410,
};
// Rules: address -> [status, destination]. An address with no rule is not redirected.
const before = {
'/about-us': [302, '/about'],
'/team': [301, '/about-us'],
'/services/web-design': [301, '/services/design'],
'/services/design': [301, '/services/web'],
'/services/seo-audit': [301, '/services/search'],
'/services/search': [301, '/services/seo-audit'],
'/blog/2019/tips': [301, '/'],
'/contact.html': [301, '/contact'],
};
const after = {
'/about-us': [301, '/about'],
'/team': [301, '/about'],
'/services/web-design': [301, '/services/web'],
'/services/design': [301, '/services/web'],
'/services/seo-audit': [301, '/services/seo'],
'/services/search': [301, '/services/seo'],
'/blog/2019/tips': [301, '/blog/website-tips'],
'/contact.html': [301, '/contact'],
'/products/blue-widget': [410, null],
};
// Follow one old address through the rules, recording each status.
function follow(rules, start) {
const codes = [], seen = new Set([start]);
let at = start, loop = false;
while (rules[at]) {
const [status, to] = rules[at];
codes.push(status);
if (status === 410) return { codes, hops: codes.length - 1, at, end: 410, loop, shown: 'gone (410)' };
if (seen.has(to)) { loop = true; break; }
seen.add(to);
at = to;
}
const end = live.includes(at) ? 200 : 404;
return { codes, hops: codes.length, at, end, loop, shown: loop ? 'loop' : at + ' ' + end };
}
// A pass: one permanent hop to the agreed page that answers 200, or the agreed 410 with no redirect.
function verdict(old, r) {
const want = agreed[old], faults = [];
if (r.loop) faults.push('loop');
else if (want === 410) {
if (r.end !== 410) faults.push('should be 410');
if (r.hops > 0) faults.push('redirects before the 410');
} else {
if (r.codes.some((c) => [302, 303, 307].includes(c))) faults.push('temporary redirect');
if (r.hops > 1) faults.push('chain of ' + r.hops);
if (r.at !== want) faults.push(r.at === '/' ? 'unrelated destination' : 'wrong destination');
if (r.end !== 200) faults.push('ends at ' + r.end);
}
return faults.length ? 'FAIL ' + faults.join(', ') : 'pass';
}
function sheet(rules) {
const head = ['old address', 'codes', 'hops', 'final', 'verdict'];
const rows = Object.keys(agreed).map((old) => {
const r = follow(rules, old);
return [old, r.codes.length ? r.codes.join('>') : '-', String(r.hops), r.shown, verdict(old, r)];
});
const w = head.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
const line = (row) => row.map((c, i) => c.padEnd(w[i])).join(' | ').trimEnd();
console.log([head, ...rows].map(line).join('\n'));
console.log('pass ' + rows.filter((row) => row[4] === 'pass').length + ' of ' + rows.length);
}
console.log('BEFORE');
sheet(before);
console.log('\nAFTER');
sheet(after);Before: what the test finds
Read the verdict column. The old about address uses a temporary redirect (302), so that move is not signalled as permanent. The old team address goes through a permanent hop and then that temporary one, so it fails twice over: a temporary redirect and a chain of two. The old web-design address takes two permanent hops to reach its page: a chain. The two search-related service addresses send each other round in a loop, so visitors never reach a page. One article goes to the home page, an unrelated destination, which Google warns can be treated as a soft error. The discontinued product has no rule and returns a plain not-found instead of the deliberate removal. Only two of the nine addresses pass.
- Temporary redirect: change the code to permanent.
- Chain: point the old address straight at the final page, and update the intermediate rule too.
- Loop: break it by setting the true destination.
- Unrelated destination: use the page that replaces the content.
If the matrix is wider than the box, scroll horizontally to read every column. Keyboard: focus the matrix and use Left/Right.
old address | codes | hops | final | verdict
/about-us | 302 | 1 | /about 200 | FAIL temporary redirect
/team | 301>302 | 2 | /about 200 | FAIL temporary redirect, chain of 2
/services/web-design | 301>301 | 2 | /services/web 200 | FAIL chain of 2
/services/design | 301 | 1 | /services/web 200 | pass
/services/seo-audit | 301>301 | 2 | loop | FAIL loop
/services/search | 301>301 | 2 | loop | FAIL loop
/blog/2019/tips | 301 | 1 | / 200 | FAIL unrelated destination
/contact.html | 301 | 1 | /contact 200 | pass
/products/blue-widget | - | 0 | /products/blue-widget 404 | FAIL should be 410
pass 2 of 9After: the same test on the corrected rules
The corrected rules send every old address, including the intermediate ones, to its final destination in one permanent hop, and return 410 for the removed product. The same script now passes all nine. Re-running it after each release is the point: a later routing change can bring a fault back, and a sheet with the previous result makes the difference obvious.
- Keep the sheet with the rules as a regression test.
- Add a sample of addresses outside the list, so a broad rule does not catch more than intended.
If the matrix is wider than the box, scroll horizontally to read every column. Keyboard: focus the matrix and use Left/Right.
old address | codes | hops | final | verdict
/about-us | 301 | 1 | /about 200 | pass
/team | 301 | 1 | /about 200 | pass
/services/web-design | 301 | 1 | /services/web 200 | pass
/services/design | 301 | 1 | /services/web 200 | pass
/services/seo-audit | 301 | 1 | /services/seo 200 | pass
/services/search | 301 | 1 | /services/seo 200 | pass
/blog/2019/tips | 301 | 1 | /blog/website-tips 200 | pass
/contact.html | 301 | 1 | /contact 200 | pass
/products/blue-widget | 410 | 0 | gone (410) | pass
pass 9 of 9Limits of this example
The rules, pages and results are invented, and the sheet only follows a small rule table; it does not test a server, a content network or a search engine, and it says nothing about ranking or indexing. Real sites also need canonical tags and sitemap entries to agree with the map, covered in the canonical guide. Nothing here means any site has been repaired for a client. The matching paid job is our address job (posted test price from £395, untested, up to 300 agreed addresses). Send the site, what changed and a rough count of addresses in your first enquiry, not logins.
Sources and limits
- Google Search Central: Site move with URL changes Checked 2026-10-11.
- Map each old URL to a specific destination; use permanent server-side redirects; avoid chains (ideally no more than 3 and fewer than 5 hops); do not redirect many old URLs to one irrelevant destination; test with scripts for large batches.
- Google Search Central: Redirects and Google Search Checked 2026-10-11.
- 301 and 308 are permanent redirects; 302, 303 and 307 are temporary and are not a canonical signal.