D
P
0
← All articles Baca dalam Bahasa Indonesia

Technical SEO, Redirects & Migrations

Rank Math Title Shows Only the Site Name and No Canonical on `add_rewrite_rule` Pages: There Is No Queried Object

· · 9 min read
Rank Math Title Shows Only the Site Name and No Canonical on `add_rewrite_rule` Pages: There Is No Queried Object

On August 19 the client's SEO lead sent a short request: update the canonical tags on four role hub pages, the pages that gather people's profiles by role, to point at their parent page at /people/ for now, since all four were still empty. The verb was update, but the responses of those four URLs, which I checked before any fix shipped, did not contain the string canonical even once. There was no tag to update. The request was really an addition.

A meta sheet from the same SEO lead, dated the following day, brought the second symptom. Of the 30 rows of titles and descriptions in that sheet, 28 URLs had no post behind them, and all 28 were rendering the bare site name as their title. It was the same shape as the canonical problem: a virtual page, no queried object, and theme code as the only lever.

When a URL does have a post or a term behind it, Rank Math meta can still be written in bulk from outside the editor, and that is a story I have told separately. This one is about URLs that have no object at all to attach meta to.

A route that carries no object

The role hubs are virtual pages. Their route is registered in the theme with add_rewrite_rule, and the rule fills in only two query vars, a view flag and a role slug, without pointing at any post.

// Simplified. Query var names and the path have been replaced.
add_rewrite_rule(
    '^people/role/([^/]+)/?$',
    'index.php?theme_role_view=1&theme_role_slug=$matches[1]',
    'top'
);

The hub template runs its own people-by-role query, but as far as WordPress is concerned the request never has a queried object. Rank Math has nothing to build a canonical from, so it emits none, and with no post there is no Rank Math metabox to set one by hand either. An SEO tag ended up needing a code deploy.

The meta sheet told the same story at a larger scale. url_to_postid() returned 0 for all 28 of those URLs, only two URLs in the sheet turned out to be real Pages, and the other URL families were add_rewrite_rule routes as well. The bulk update_post_meta method I had used on August 18 writes rank_math_title and rank_math_description, the same keys Rank Math's own importer writes, and it reached none of these URLs. update_post_meta() needs a post ID, and here the ID is 0.

A 0 from url_to_postid() is only a lead on its own. On a different site, the same function once returned 0 for a URL whose post did exist, because that post's post_name carried a -2 suffix while the URI from the Permalink Manager plugin did not. In this case, the zeros were backed by rewrite rules that really are registered in the theme code.

Six hubs, zero people, one typo

The theme defines six roles and the client's list named four, so before writing any code I wanted to know which hubs were actually empty. My first measuring script called the people-counting helper by a name that never existed in the theme, and I had wrapped that call in function_exists(). All six hubs came back empty, and I came close to reporting that number as a finding. It looked roughly like this:

// Simplified. Helper names have been replaced.
// A misspelled name makes function_exists() return false,
// nothing errors, and every hub counts as 0.
$count = function_exists('theme_people_by_role_tag')
    ? count(theme_people_by_role_tag($key))
    : 0;
 
// What it should have been: the helper must exist, or the script stops.
if (!function_exists('theme_people_by_role')) {
    fwrite(STDERR, "helper not found\n");
    exit(1);
}
$count = count(theme_people_by_role($key));

The correct count, checked directly against the raw role meta, showed two hubs with 2 people each and the other four at 0. The four empty hubs were exactly the four URLs on the client's list. In theme code, function_exists() is a reasonable fence. In a measuring script, the same fence swallows a typo and hands it back as data, so the rule I wrote down afterwards is simple: assert that the helper exists, and abort if it does not.

A canonical that follows the hub's contents

The client wrote "for now". Rather than pinning four slugs in code, I made the canonical depend on whether the hub is empty. A hub that gets its first person drops the canonical on its own, and a hub that empties out gets it back, so "for now" is honored without anything to remember to revert. A hub counts as empty when the people-by-role helper, which fetches published profile posts with matching role meta, returns zero for the role key derived from the slug in the URL. The result is memoized because the hub template runs the same query.

The canonical goes through the rank_math/frontend/canonical filter at priority 20, which for an empty hub returns the parent page URL and records that it has fired. A wp_head fallback at priority 99 prints a canonical only when that filter did not fire, so the two can never both print. Priority 99 was chosen because Rank Math renders its head early, so by the time the fallback runs, the filter has either fired or never will. The filter below is quoted from the theme with names replaced, while the fallback is rewritten in its shortest form.

// Simplified. Function names and the path have been replaced.
add_filter('rank_math/frontend/canonical', function ($canonical) {
    if (!theme_is_empty_role_hub()) return $canonical;
    theme_role_hub_canonical_fired(true);
    return home_url('/people/');
}, 20);
 
// Fallback: prints only when the filter above never fired.
add_action('wp_head', function () {
    if (!theme_is_empty_role_hub() || theme_role_hub_canonical_fired()) return;
    echo '<link rel="canonical" href="' . esc_url(home_url('/people/')) . '" />' . "\n";
}, 99);

The fallback deliberately does not use the defined('RANK_MATH_VERSION') bail-out that another section of the same file relies on. That bail-out keeps the theme silent whenever Rank Math is active, and on these views Rank Math is active yet cannot be relied on to print anything. If the client later wants those four hubs pinned to the parent page no matter how many people they hold, the count test only needs to be swapped for a slug allow-list.

A cache-busted fetch on all six hubs showed the four empty hubs each returning exactly 1 canonical to the parent page, while the two populated hubs returned 0 and were left untouched. The day after, a new robots.txt went live through the same file, and the regression check that followed gave the same numbers, with the homepage at 1 canonical and the virtual titles described below still rendering.

Titles from a map of request paths

That canonical commit is timestamped August 20 at 18:04 (UTC+7), and the commit for titles and descriptions followed at 20:12 the same day. The fix is one new file in the theme's inc folder, required from functions.php right after the SEO filters file that holds the canonical code. Inside is a map of 27 entries keyed by the lower-cased, slash-wrapped request path, a shape chosen on purpose to match the client's sheet so the file stays diffable against its source.

The count is 27, not 30. The two URLs that turned out to be real Pages got ordinary rank_math_title and rank_math_description post meta instead, so people on the client side can still edit them themselves, and one more row was left out on purpose for editorial reasons. That is 30 minus 2 minus 1.

Before matching, the path is taken from REQUEST_URI with parse_url, decoded, stripped of slashes at both ends, lower-cased, and wrapped in slashes again. Decoding has to come first because one slug in the sheet contains a comma, and an HTTP client that encodes that comma as %2C would otherwise miss the map.

// From the virtual meta file.
$path = (string) parse_url($uri, PHP_URL_PATH);
if ($path === '') return $entry;
 
// Decode before matching. One slug in the sheet contains a comma, and a
// client that percent-encodes the comma would otherwise miss the map.
$path = rawurldecode($path);
$trimmed = trim($path, '/');
$path = ($trimmed === '') ? '/' : '/' . strtolower($trimmed) . '/';

A matching entry is wired to the rank_math/frontend/title and rank_math/frontend/description filters at priority 20, and I confirmed that both filters exist in the installed Rank Math 1.0.273 before building on them. A blank cell in the sheet falls through to Rank Math's own value rather than blanking the tag. The map lookup is memoized, because it runs on every front-end request, including the 458 posts that will never match.

A harness for a map that cannot complain

The map has no editor screen. If one of its keys drifts, nothing anywhere complains, and the page quietly goes back to rendering the bare site name. That is why the map has its own harness: 11 path cases, plus 11 map assertions that check for lower-case slash-wrapped keys, no blank cells, no title over 60 characters or description over 160, unique titles, and the three rows that must be absent.

Building the harness surfaced two more traps. Without ABSPATH defined, requiring a theme file exits with status 0 before a single assertion runs, so the harness looks like it passed while printing nothing at all. The usual ABSPATH guard at the top of a theme file calls exit, and exit with no argument leaves with status 0. Then Git Bash rewrote every argument starting with a slash into a Windows path, so every case that should have matched failed and every case that should have missed passed, for the wrong reason.

What was accepted and what is still open

One gap was accepted on purpose. The theme used to print a second set of OG tags on wp_head at priority 5, after Rank Math at priority 1, and that set would have overridden the descriptions that had just shipped. With the second set cleaned up, the role hubs have no og:title or og:description at all, because Rank Math emits none for a rewrite route with no queried object. Before, those values were just the site name and the tagline. The real values are left for the client's SEO lead to write into the same map rather than invented by me.

Three more things remain open with the client. The empty hubs still serve meta robots follow, index, and since a canonical is a hint while noindex is a directive, whether empty hubs should also be noindexed is left as the SEO lead's call. The two populated hubs still have no canonical of their own, and og:url on the role hub views echoes whatever query string the request carries, utm_ links included. Both are older conditions that this change did not introduce. I have already flagged the og:url echo, while for the two populated hubs the SEO lead may want each one to carry a canonical that explicitly points at its own URL.

From the outside, a URL built with add_rewrite_rule looks like any other page. To Rank Math it does not exist as an object: no metabox, no post meta, no canonical of its own. Whenever a public page is created through a rewrite rule, its title, description, and canonical become the theme code's job from day one.