Retire a duplicate page with a 301, not a delete

  • seo
  • wordpress
  • php

One of my side projects is a news site that publishes each story at several reading levels, so a single story lives at a handful of URLs like /stories/some-slug/a1/ and /stories/some-slug/b2/. The stories come out of an automated pipeline, and a few days ago it did something pipelines eventually do: it published the same news event twice, from two different sources, under two different slugs.

Both pages were live. Both had been shared. Both were being picked up by search engines. The fix took about twenty lines, but the order of operations matters more than the code, so that’s what this post is about.

Why “just delete one” is the wrong move

The tempting fix is to trash the weaker copy and move on. That leaves three problems:

  • Every link to it now 404s. People shared it, a newsletter linked to it, and maybe another page on your own site points at it. All of those visitors hit a dead end.
  • Search engines see a page vanish instead of a page that moved. Whatever signals the old URL had built up don’t get handed to anything.
  • You lose the ability to undo. If you picked the wrong one, the content is gone.

A permanent redirect (HTTP 301) solves all three. Old links keep working, crawlers learn that the old URL has been replaced by a specific new one, and the original post can stay in the database, unpublished, in case you need it back.

Step 1: pick the keeper with data, not vibes

Before redirecting anything, decide which copy survives. I compared the two on things I could actually measure:

  • Traffic. One copy had well over twice the page loads of the other in the previous two days. That’s the version people were already finding.
  • Completeness. The weaker copy was also missing some of its reading levels. Redirecting a complete page to an incomplete one would have broken the deeper URLs.
  • Which one is better written. The tiebreaker, not the first question.

Then unpublish the loser. On WordPress I set it to draft instead of trashing it, so it disappears from the site, feeds, and sitemaps but stays recoverable.

Step 2: redirect every URL, not just the main one

This is the part that’s easy to get wrong. The duplicate didn’t have one URL, it had one per level, plus the bare /stories/slug/ path. A redirect that only catches the main URL sends everyone on a level page to a 404.

The rule I wanted was: old slug plus a level goes to new slug plus the same level, and the bare path goes to the default level. Here’s the whole thing as a WordPress hook:

add_action('template_redirect', function () {
    // Retired duplicate slug => the slug we kept. Note the date and reason for each entry.
    $map = [
        'old-duplicate-slug' => 'kept-story-slug',
    ];

    $path = (string) wp_parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
    if (!preg_match('#^/stories/([^/]+)(?:/(a1|a2|b1|b2))?/?$#i', $path, $m)) {
        return;
    }

    $slug = strtolower($m[1]);
    if (!isset($map[$slug])) {
        return;
    }

    $level = !empty($m[2]) ? strtolower($m[2]) : 'b2';
    wp_safe_redirect(home_url("/stories/{$map[$slug]}/{$level}/"), 301);
    exit;
}, 1);

A few choices in there I’d make again:

  • A map, not a one-off if. The pipeline will produce another duplicate someday. When it does, the fix is one new line in the map, not another copy of this function.
  • A strict pattern. The regex only matches the exact URL shapes the site uses. Anything else falls through untouched, so a typo in the map can’t hijack unrelated pages.
  • wp_safe_redirect with home_url. The destination is always built on my own domain. Even though the map is hardcoded today, I don’t want a redirect helper that could ever be coaxed into sending someone off site.
  • Early priority and exit. Running at priority 1 on template_redirect means the redirect fires before WordPress decides the unpublished post is a 404 and renders a template. The exit makes sure nothing else runs after the header is sent.

Why code instead of a redirect plugin

My SEO plugin has a redirect manager, and the usual advice is to use it. I didn’t, for a couple of reasons. It had no API I could script against, and I didn’t want to install a second plugin for a single rule. More importantly, a redirect in the theme lives in git. It shows up in a pull request, it has a commit message explaining why it exists, and undoing it is reverting a line. A redirect buried in a database table is invisible until someone wonders why a URL keeps bouncing.

If you manage hundreds of redirects, or non-developers need to add them, a plugin or a server-level rule is the better tool. For a handful of deliberate retirements, code is easier to reason about.

Step 3: check the redirect like a crawler would

Don’t trust a browser for this; it caches 301s aggressively and hides the hops. Use curl:

curl -sI https://example.com/stories/old-duplicate-slug/a2/ | grep -iE '^(HTTP|location)'
# HTTP/2 301
# location: https://example.com/stories/kept-story-slug/a2/

I check three things: every level redirects to the same level on the keeper, the bare path lands on the default level, and the destination itself returns a 200 instead of chaining into another redirect. Chains waste crawl budget and slow people down.

Tradeoffs

  • A 301 is sticky. Browsers and search engines cache permanent redirects for a long time. If you’re not sure yet which page wins, use a 302 until you are, then switch.
  • The map grows. Every entry is cheap, but it’s still a rule that runs on every story request. A short-circuit on the URL pattern keeps the cost to a regex for everything that isn’t a retired slug. If the list ever gets long, I’d move it into an option or a small table.
  • The real fix is upstream. Redirects clean up after duplicates; they don’t prevent them. The pipeline still needs a check that notices when two sources describe the same event before both get published.

The checklist I’ll reuse

  • Pick the keeper using traffic and completeness, not just which one reads better.
  • Unpublish the duplicate instead of deleting it.
  • Redirect every URL variant to its matching variant on the keeper, with a 301.
  • Keep redirects in a small, commented map in version control.
  • Verify with curl: correct status, correct destination, no chains.
  • Fix whatever produced the duplicate so the map stays short.