Part of a WordPress migration: 131 redirect rules had to land in the free redirect plugin the site already ran. The set was 123 redirects from old blog URLs to a single hub page, plus 8 specific page redirects. The commercial pages were confirmed safe and deliberately excluded from that list. The stack is WordPress and PHP, and the plugin only offers an Add New button for adding rules one at a time.
Two pieces of cleanup came first. I cleared four redirect collisions, three of them soft and one a hard rule inside the plugin that had to be deleted. Then I created one destination page, because out of the whole list it was the only redirect target that did not exist yet.
The rest looked like ordinary automation work. On this project, wp-admin and database changes are made directly on the live site through Playwright, with no theme re-upload. I tried five different ways to push those 131 rows through the UI, and all five added zero rules.
Five paths that added zero rules
The first one is tempting because it looks cheapest. The plugin's rules screen is essentially a table of inputs, so I built the rows myself, appended them to the DOM, and hit Save.
// Field names anonymized. These rows get injected into the table, then Save is pressed.
const row = document.createElement('tr');
row.innerHTML = `
<input name="rule[from][]" value="/story/">
<input name="rule[to][]" value="/insights/">
`;
table.appendChild(row);Save ran without an error and the injected rows were simply ignored.
Second path: stop building rows myself and let the plugin build them. I called a native .click() on the Add New button so the plugin's own code would create the row. It failed, and the reason was isTrusted. An event born from a script flags itself as not coming from a human, and on this path that flag decides the outcome.
Third path: skip the UI entirely and POST the form directly. That returned a 500, because one internal field never made it into the payload.
Fourth path: use the file upload the plugin already ships. Playwright's file picker kept timing out right there.
The fifth is not really a separate path but an obstacle stuck to all of them. A marketing popup plugin on this site puts position: fixed overlays on the page that steal clicks, which makes both the Add input and the file input flaky. Any position: fixed node with a z-index above 100 has to be stripped first before interaction becomes reliable at all.
The root cause is where Save reads its data
One sentence explains why the first two attempts failed so quietly: on Save, this plugin only reads rows from its own internal JS registry. The HTML form on screen is not the source of truth, only a reflection of it.
So the rows I injected into the DOM genuinely existed on the page, but were never registered in that registry, and Save never saw them. And the code that registers a new row in the registry is the Add New button handler, which is exactly the one that refuses to run from a scripted click. Which makes bulk entry through the UI unfeasible in this plugin, no matter how clever the DOM work gets.
A headerless CSV, and the radio that decides everything
The bulk path that actually works sits in the plugin's Import/Export tab, on the admin page options-general.php?page=<plugin>&tab=import-export. The format is specific and easy to get wrong:
301,/story/,/insights/
301,/team/press-old,/team/pressNo header row at all, and the columns run Status,RequestURL,RedirectTo in that order. The destination may be a URL or a page-id, because the plugin auto-resolves a URL into a PAGEID.
The decisive choice is not the CSV format but the import mode radio. The Skip Duplicates radio behaves as a safe append and does not wipe the existing rules. On a site whose redirect table already holds hundreds of inherited rows, picking wrong here means deleting someone else's work.
The file that finally went up contained 123 rows, and I uploaded it through my own browser because Playwright's file picker kept timing out.
699 rules, 10 duplicates, then 689
Verification could not stop at looking at a full table. I re-extracted every rule from the plugin, diffed it against the planned list, and spot-checked the 301 status on raw URLs. The result: all 131 planned redirects were live, 0 of 131 missing, with 699 rules in the plugin in total.
That same diff surfaced 10 rules sharing a from. All of them were harmless, since the same source pointed at the same destination, but their origin is the interesting part. Eight were pre-existing duplicates already sitting in the cloned production data, not mine. The other two were mine, caused by a test import and the full import overlapping.
What let those two slip through is how Skip Duplicates compares: it looks at the resolved destination and compares a PAGEID form against a URL form. Two entries that are practically identical read as different, so the duplicate filter waved them past. That is a direct consequence of the auto-resolve feature that sounded so friendly a moment ago.
After removing those 10 duplicate copies, the count went from 699 minus 10, which is 689 rules with 0 duplicates.
Deleting a rule is AJAX, not a GET link
Removing those 10 rows surfaced a small finding that saves time later. The per-row Delete button in this plugin is not an ordinary link. It is an AJAX POST to /wp-admin/admin-ajax.php.
// Action name, global name, and parameter are anonymized.
await fetch('/wp-admin/admin-ajax.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
action: 'rdr_delete_entry',
_ajax_nonce: window.rdr_cfg.nonce_delete,
id: String(ruleId),
}),
});The part worth writing down: the GET href in the markup, the one shaped like ?remove_rule=ID, does not delete anything on its own, because the AJAX nonce is still required. If you have ever deleted rules by opening that URL one by one and wondered why the table never changed, that is your answer. Every nonce this plugin uses lives in a single JS global, so once you know the global's name, its whole AJAX surface becomes reusable.
The 384 rows you must not import
The redirect CSV that arrived with this migration is far longer than 131 rows. Another 384 rows map old episode URLs, /episode/{slug}/, onto a single hub page, and those rows were deliberately kept out of the plugin.
They moved to a rewrite that resolves them dynamically instead: old episode URLs /episode/{slug}/ now 301 to their new counterparts at /series/{show}/{slug}/. The difference is large. This preserves per-episode SEO, while the CSV literally asked for all 384 URLs to be dumped onto one hub, which would have killed 384 live episodes.
The lesson I keep from this: a migration redirect file is a proposal, not an order. The rows that come in the hundreds and share one pattern deserve the most suspicion before any bulk apply.
The one URL that kept answering 200
The spot-checks found a stubborn URL. /guides/ answered 200 instead of 301, even though its rule existed. Two things shadowed that rule at once: a similarly titled Page still had published status, and the origin page cache was serving its old response.
There are two options here, and one of them is not mine to make. The rule could be dropped if the client side wants to keep that page, and that decision belongs to them. The choice made was the opposite one, keeping the redirect. I trashed the root Page over REST rather than deleting it outright, so it can come back if that turns out wrong:
// Without force, WordPress moves the Page to trash instead of deleting it permanently.
await fetch(`/wp-json/wp/v2/pages/${pageId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: { 'X-WP-Nonce': window.wpApiSettings.nonce },
});The WordPress side was done, the redirect rule was present, and /guides/ still answered 200. This time WordPress was not the culprit:
# /guides/ served through the CDN, never reaching the origin
HTTP/2 200
<cdn>-cache-status: HIT
age: 1238
cache-control: max-age=3600The CDN in front of the site was holding that page in its cache. Why this matters well beyond one URL: the migration ran on the assumption that the CDN caches HTML for 1 second, while max-age=3600 means 1 hour. Any redirect or content change can lag by up to an hour on URLs that are already cached.
The age value makes that lag concrete. This copy was 1238 seconds old, roughly 20.6 minutes once divided by 60, against a ceiling of 3600 seconds. That leaves 3600 minus 1238, or 2362 seconds, about 39.4 minutes before it expires on its own. The redirect starts working when the CDN cache expires in under an hour, or sooner if someone runs a manual purge for that URL.
Lessons
- When a plugin's Save reads its own internal registry, every DOM trick on that page is wasted effort. Look for the official import path before burning hours in the UI.
isTrustedkills a native.click()on the elements that matter, so never assume browser automation can always imitate a human click.- A bulk import landing on top of someone else's data must append, not replace. Read what each import mode option actually does before pressing the button.
- A duplicate filter that compares destinations after resolving them will happily pass duplicates written in two different shapes that mean the same thing.
- Verify by re-extracting and diffing, not by looking at a table and feeling reassured. The same diff that proved 0 of 131 missing is the one that found the 10 duplicates.
- Always verify on a live site with a cache-buster query string, because there is a cache at the origin and a CDN in front of it. Without one you are testing an old copy.
- All of this happened in wp-admin and the live database, so not a single line of theme code needed committing. Sometimes the highest impact change leaves no trace in the repo at all.