D
P
0

WordPress & PHP

A Custom Post Type at the Root URL (`/title/`) Falls Through to the Homepage — Fix It With Per-Slug Rewrite Rules

June 24, 2026·5 min read
A Custom Post Type at the Root URL (`/title/`) Falls Through to the Homepage — Fix It With Per-Slug Rewrite Rules

I once built a multi-listing site on WordPress with a request that looked trivial: every entry of a custom post type had to live at the root URL, exactly /entry-title/, with no /book/ prefix anywhere. There was no red error message, no warning in the log, no PHP notice. What actually happened was more confusing than a crash: for most slugs, the page that rendered was the HOMEPAGE, not the single post of the CPT.

The symptom was easy to reproduce. I opened /first-book/ in the browser, and instead of the single view, the front page rendered. A quick check with conditional tags in the template confirmed my suspicion:

add_action('wp', function () {
    error_log('is_home: ' . var_export(is_home(), true));
    error_log('is_singular(book): ' . var_export(is_singular('book'), true));
});
// Output for /first-book/ :
// is_home: true
// is_singular(book): false

WordPress was treating /first-book/ as the homepage query. Yet the post clearly existed, the slug was correct, and when I hit /?post_type=book&name=first-book everything rendered fine. So the data wasn't missing — what was wrong was how the clean URL got mapped to a query.

My first approach was the one most commonly recommended online: hook the request filter and massage the query vars by hand.

add_filter('request', function ($query_vars) {
    if (!empty($query_vars['name']) && empty($query_vars['post_type'])) {
        $query_vars['post_type'] = 'book';
    }
    return $query_vars;
});

On paper this makes sense. In practice, for many slugs it silently fell through to is_home() and the homepage rendered anyway. It took me a good while to understand why.

Why this happens

The heart of the problem is resolution order. When WordPress receives /title/, it has to guess what this request is: the front page, a Page, a post, or something else. The request filter runs late and is not something you can rely on to map a bare /title/ to a CPT. By the time my filter appended post_type=book, the base query vars had already been formed on the wrong assumption — WP had already resolved that bare URL as the front page / Page query first.

So the $query_vars['name'] I was leaning on often simply wasn't populated for a root pattern like this, or it was populated but inside a query context that had already tilted toward the homepage. As a result WP_Query ran the front page query, is_home() came out true, and patching in post_type afterward didn't change a decision that had effectively already been made at the rewrite stage. I wasn't fixing the URL mapping — I was slapping a bandage over a query that was already pointed the wrong way.

The small lesson: when the mapping from clean URL to query var is itself broken, fixing it at the request layer is incidental, not deterministic. The right place is the rewrite layer, where clean URLs are actually translated.

The fix

The solution is to register an explicit rewrite rule that catches the root pattern and maps it straight to the CPT. There is no hardcoded slug list — the rule is fully dynamic via a capture group:

add_action('init', function () {
    add_rewrite_rule(
        '^([^/]+)/?$',
        'index.php?post_type=book&name=$matches[1]',
        'top'
    );
});

The regex ^([^/]+)/?$ captures a single segment at the root (anything that isn't a slash), and $matches[1] flows into the name query var with post_type=book forced right there. Because it's placed at the top position, this rule gets evaluated early and the clean URL resolves directly as a single CPT instead of falling through to the front page.

But a new rewrite rule doesn't take effect until rewrite rules are flushed. I didn't want editors to have to visit Settings > Permalinks every time, so I wired up an auto-flush triggered on save_post and gated behind a version option so it doesn't flush on every save:

add_action('save_post', function () {
    if (get_option('book_rewrite_version') !== '2') {
        flush_rewrite_rules();
        update_option('book_rewrite_version', '2');
    }
});

Once the flush runs, the version gate disables itself — flush_rewrite_rules() is expensive, so it must never be called on every request. Bump the version number whenever I change the rule, and it auto-flushes one more time on its own.

There's one caveat I had to handle honestly: a root-level catch-all is greedy. ^([^/]+)/?$ also matches real Page slugs like /about/ or /contact/, so it can shadow genuine Pages and make them unreachable. The way I guarded against it: let Pages take precedence — check get_page_by_path() before treating a slug as a CPT entry, or register the rule below page handling so Pages win first.

add_action('init', function () {
    add_rewrite_rule(
        '^([^/]+)/?$',
        'index.php?post_type=book&name=$matches[1]',
        'top'
    );
}, 11); // run after the built-in Page handler
 
// Extra safety net: let real Pages pass through untouched.
add_filter('request', function ($qv) {
    if (!empty($qv['name']) && get_page_by_path($qv['name'])) {
        return ['pagename' => $qv['name']];
    }
    return $qv;
});

With this in place, slugs that match a real Page are handed to the Page, and everything else falls through to the CPT as intended.

The takeaway

To put a custom post type at the site root, the request filter is the wrong tool — it runs too late and maps a bare /title/ unreliably, so it silently falls through to the homepage. Use a real rewrite rule that captures the root pattern dynamically, pair it with an auto-flush gated behind a version option so editors never have to touch Settings > Permalinks, and give Pages precedence so your root catch-all doesn't swallow legitimate pages. Fix the URL mapping at the layer where it actually happens, rather than taping a patch downstream.