TechEarl

Catch and Route Your Own 404s in WordPress

Intercept requests that would 404 in WordPress on template_redirect, then resolve them to real content with status_header(200), 301 to the right URL with wp_safe_redirect(), or let them fall through. A fallback router for dynamic and legacy slugs you cannot enumerate.

Ishan Karunaratne⏱️ 11 min readUpdated
Share thisCopied
How to catch and route your own 404s in WordPress: hook template_redirect, check is_404(), parse the requested path, then resolve to content with status_header(200), 301-redirect with wp_safe_redirect(), or let it fall through to the real 404.

When you cannot (or do not want to) write a rewrite rule for every URL a site needs to answer, let WordPress run its normal routing, then catch the requests that would 404 and resolve them yourself. The hook is template_redirect: it fires after the main query has run, so is_404() is already accurate, and you have full knowledge of what was requested. Inspect the path, and either load real content and force a 200, issue a clean 301 to the correct URL, or do nothing and let the genuine 404 stand:

php
add_action( 'template_redirect', function () {
    if ( ! is_404() ) {
        return;
    }

    $path = trim( wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH ) ?? '', '/' );

    // Only handle the namespace this router owns; leave everything else alone.
    if ( strpos( $path, 'profiles/' ) !== 0 ) {
        return;
    }

    $handle = sanitize_title( substr( $path, strlen( 'profiles/' ) ) );

    $post = te_resolve_profile( $handle );
    if ( $post ) {
        te_serve_post( $post );
    }
    // No match: fall through and let WordPress render the real 404.
} );

That is the whole shape of it. The first guard means you never touch a request unless WordPress already decided it had no answer, and the second guard means you only claim a path prefix you actually own. Everything else falls through untouched.

Why template_redirect and not earlier

template_redirect fires after WordPress has parsed the query and decided what to show, but before it picks a template file. That timing is exactly what you want for a fallback router: the conditional tags are live, so is_404() returns a real answer instead of the false you get if you call it too early. Per the developer reference, it is "a good hook to use if you need to do a redirect with full knowledge of the content that has been queried."

One caveat the same reference is blunt about: do not include an alternative template and then exit() from inside this hook. If you die() here, later template_redirect callbacks never run, and you can break other plugins. For swapping the template file, the right tool is the template_include filter, not this one. The pattern below stays clean by either reshaping the query (and letting WordPress load the normal template for that content) or by redirecting.

Option A: resolve the 404 to real content

This is the case where the URL is legitimate but WordPress did not know how to map it to a post. You look the content up yourself, then hand the main query a real post and clear the 404 flag so the page renders with a 200:

php
/**
 * Look up a post by some identifier the default routing cannot resolve.
 * Swap the meta_key / post_type for whatever your site stores.
 */
function te_resolve_profile( $handle ) {
    if ( $handle === '' ) {
        return null;
    }

    $found = get_posts( array(
        'post_type'      => 'profile',
        'post_status'    => 'publish',
        'meta_key'       => 'legacy_handle',
        'meta_value'     => $handle,
        'posts_per_page' => 1,
        'no_found_rows'  => true,
    ) );

    return $found ? $found[0] : null;
}

/**
 * Point the main query at a known post and turn off the 404 state,
 * so WordPress renders the normal single template with a 200.
 */
function te_serve_post( $post ) {
    global $wp_query;

    $wp_query->is_404      = false;
    $wp_query->is_single   = true;
    $wp_query->is_singular = true;
    $wp_query->queried_object    = $post;
    $wp_query->queried_object_id = $post->ID;
    $wp_query->post  = $post;
    $wp_query->posts = array( $post );
    $wp_query->post_count   = 1;
    $wp_query->found_posts  = 1;
    $wp_query->max_num_pages = 1;

    status_header( 200 );
    nocache_headers();
}

is_404() reads the main query's is_404 property (it delegates straight to $wp_query->is_404()), so flipping that property to false and seeding post/posts is what convinces the rest of the page load that this was a normal singular request. status_header( 200 ) overwrites the 404 status line WordPress had already queued. After this, the standard single-profile.php (or single.php) template takes over and the loop runs against your post as if the URL had matched a rewrite rule all along.

Option B: 301 to the correct URL

When the requested path is wrong but you know where it should go (a renamed slug, a moved section, a legacy permalink structure), do not fake a 200. Send the visitor and the crawler to the canonical URL with a permanent redirect:

php
add_action( 'template_redirect', function () {
    if ( ! is_404() ) {
        return;
    }

    $path = trim( wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH ) ?? '', '/' );

    // Old structure: /team/jane-doe  ->  /profiles/jane-doe
    if ( preg_match( '#^team/([a-z0-9-]+)$#', $path, $m ) ) {
        wp_safe_redirect( home_url( '/profiles/' . $m[1] . '/' ), 301 );
        exit;
    }
} );

wp_safe_redirect() is the right call here rather than raw wp_redirect(): it validates the target against the site's allowed hosts before sending the Location header, so a bug in your path-building cannot turn into an open redirect off-site. exit immediately after a redirect is correct and expected (the page is done), which is different from the "do not exit mid-hook to swap a template" caution above. Use 301 for a permanent move so link equity transfers; use 302 only if the destination is genuinely temporary.

Option C: let it fall through

The most important branch is the one that does nothing. If the path is not one your router owns, or the lookup comes back empty, return and let WordPress render its real 404. A fallback router that silently swallows every miss is worse than no router, because broken links stop surfacing as 404s and you lose the signal that something is wrong. Match tightly, resolve what you can, and leave the rest to fail honestly.

Rewrite rule or fallback router: which one

This technique is not a replacement for add_rewrite_rule(). They solve different problems, and the choice comes down to whether you can express the URL space as a pattern up front.

SituationReach for
The URL space is a fixed, expressible pattern (/events/:year/:slug)add_rewrite_rule() plus a query var
You own a new public endpoint with structured paramsadd_rewrite_rule() (or the REST API)
Slugs are dynamic, user-generated, or pulled from an external systemFallback router on template_redirect
A pile of legacy URLs with no consistent shapeFallback router (resolve or 301)
You cannot enumerate the rules because the set changes at runtimeFallback router

If you can write the rule, write the rule: a real rewrite rule is matched before the query runs, it is cacheable, and it does not depend on a request 404ing first. I cover that path in full in adding a custom rewrite rule in WordPress. The fallback router earns its place precisely when enumeration is impossible: thousands of legacy slugs, identifiers that live in another table, paths generated by code you do not control. In those cases catching the 404 is cheaper and more honest than trying to register a rule for every possibility.

You can also combine them. Register rewrite rules for the patterns you do know, and keep a tight fallback router for the long tail of legacy paths that predate them.

Resolving before the query runs: parse_request

template_redirect is the easy place to work because the query has already run and is_404() is reliable. The trade-off is that WordPress did the full query, found nothing, and built a 404 state that you then unwind. If you want to resolve a path before the main query executes, hook parse_request (or filter request) instead and set the query vars yourself:

php
add_action( 'parse_request', function ( $wp ) {
    $path = trim( $wp->request, '/' );

    if ( strpos( $path, 'profiles/' ) !== 0 ) {
        return;
    }

    $post = te_resolve_profile( sanitize_title( substr( $path, strlen( 'profiles/' ) ) ) );
    if ( $post ) {
        // Hand WordPress real query vars; it runs one correct query, no 404.
        $wp->query_vars = array(
            'post_type' => 'profile',
            'p'         => $post->ID,
        );
    }
} );

$wp->request is the resolved path WordPress is about to query, and $wp->query_vars is what it will hand to WP_Query. Set them here and the main query runs once, correctly, and never enters the 404 branch at all, so there is nothing to unwind and no status_header to override. It is the cleaner option when the lookup is cheap and you are confident in your matching. The template_redirect approach is more forgiving when the lookup is expensive or you only want to act as a last resort, because by then you already know WordPress had no answer of its own.

Cautions: tight matching and no redirect loops

Two ways this bites you, both avoidable:

  • Match tightly so you do not hijack genuine 404s. Guard on a path prefix or a strict regex that describes only the URLs your router owns. A loose match that fires on every 404 will start "resolving" typos and probe traffic to whatever your lookup returns, and it hides real broken links. The strpos( $path, 'profiles/' ) !== 0 guard exists for exactly this reason: anything outside that namespace returns immediately.
  • Never redirect to a URL that would 404 again. If your 301 target is itself unresolved, the browser comes back, 404s, hits your hook, and redirects again. Always send wp_safe_redirect() to a URL you know resolves, and confirm the destination is not also routed through the same fallback. When in doubt, resolve to content (Option A) rather than redirect, since serving a post in place cannot loop.

Also keep the work small. This runs on every 404, including bot probes and broken asset requests, so do the cheap prefix guard first and only run a database lookup once the path is in your namespace. no_found_rows => true on the lookup query (above) skips the SQL_CALC_FOUND_ROWS pass you do not need for a single-row fetch.

Ship it as a must-use plugin

I keep routing logic out of the theme. A fallback router is infrastructure, not presentation, and it must survive a theme switch, so it belongs in wp-content/mu-plugins/ where it loads automatically before regular plugins:

php
<?php
/**
 * Plugin Name: TE Fallback Router
 * Plugin URI:  https://techearl.com/wordpress-catch-404-custom-routing
 * Description: Catches requests that would 404 and resolves them to content, 301-redirects them, or lets them fall through.
 * Version:     1.0.0
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 * License:     GPL-2.0-or-later
 * Text Domain: te-fallback-router
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

function te_resolve_profile( $handle ) {
    if ( $handle === '' ) {
        return null;
    }

    $found = get_posts( array(
        'post_type'      => 'profile',
        'post_status'    => 'publish',
        'meta_key'       => 'legacy_handle',
        'meta_value'     => $handle,
        'posts_per_page' => 1,
        'no_found_rows'  => true,
    ) );

    return $found ? $found[0] : null;
}

function te_serve_post( $post ) {
    global $wp_query;

    $wp_query->is_404      = false;
    $wp_query->is_single   = true;
    $wp_query->is_singular = true;
    $wp_query->queried_object    = $post;
    $wp_query->queried_object_id = $post->ID;
    $wp_query->post  = $post;
    $wp_query->posts = array( $post );
    $wp_query->post_count    = 1;
    $wp_query->found_posts   = 1;
    $wp_query->max_num_pages = 1;

    status_header( 200 );
    nocache_headers();
}

function te_route_fallback() {
    if ( ! is_404() ) {
        return;
    }

    $path = trim( wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH ) ?? '', '/' );

    // Legacy /team/{handle} -> canonical /profiles/{handle}
    if ( preg_match( '#^team/([a-z0-9-]+)$#', $path, $m ) ) {
        wp_safe_redirect( home_url( '/profiles/' . $m[1] . '/' ), 301 );
        exit;
    }

    // Dynamic profile handles we cannot pre-register as rewrite rules.
    if ( strpos( $path, 'profiles/' ) === 0 ) {
        $post = te_resolve_profile( sanitize_title( substr( $path, strlen( 'profiles/' ) ) ) );
        if ( $post ) {
            te_serve_post( $post );
        }
    }

    // Anything else: fall through to the real 404.
}
add_action( 'template_redirect', 'te_route_fallback' );

The ABSPATH guard stops anyone loading the file directly. The functions carry a te_ prefix because WordPress has no namespaces, so initials-prefixing your own functions is the normal way to avoid collisions with core, the theme, and other plugins.

Verify with curl -I

curl showing a would-be 404 URL /legacy/1/ returning a 301 redirect to the canonical post
Real output: a would-be 404 caught and 301-redirected to the right URL.

The whole point is changing a status code, so check the status code. Before the router, a would-be match returns 404:

code
curl -I https://example.com/profiles/jane-doe
HTTP/2 404

After Option A resolves it to content, the same URL returns 200:

code
curl -I https://example.com/profiles/jane-doe
HTTP/2 200
content-type: text/html; charset=UTF-8

And a path handled by Option B returns a clean permanent redirect with the canonical Location:

code
curl -I https://example.com/team/jane-doe
HTTP/2 301
location: https://example.com/profiles/jane-doe/

-I sends a HEAD request and prints only the response headers, so you see the status line and Location without downloading the body. If a path your router should own still shows 404, the usual causes are a prefix guard that does not match the real path, a page cache serving the pre-router response (purge it), or a lookup returning nothing because the identifier was sanitized differently than it is stored.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressPHPRoutingRewrite Rulestemplate_redirect404

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Add a Custom WP-CLI Command in WordPress

How to register a custom WP-CLI command: guard it with defined('WP_CLI'), wire it up with WP_CLI::add_command(), turn class methods into subcommands, document args with @synopsis, and show progress with make_progress_bar().