TechEarl

Override Your SEO Plugin's Title and Meta Description (Yoast, Rank Math, or Core)

Hooking pre_get_document_title does nothing once Yoast or Rank Math is active: the plugin owns the title and meta description tags. Here is which filter to hook for Yoast, Rank Math, and core, plus a portable helper that detects the active plugin.

Ishan Karunaratne⏱️ 9 min readUpdated
Share thisCopied
How to override the SEO title and meta description in WordPress: hook wpseo_title/wpseo_metadesc for Yoast, rank_math/frontend/title and description for Rank Math, or pre_get_document_title for core.

If you need to change the SEO title or meta description from code and an SEO plugin is active, hook the plugin's own filter, not WordPress core. Once Yoast or Rank Math is running, it takes over the document title and prints its own meta description, so the core pre_get_document_title filter you reached for is simply never the last word. Here is the short version:

  • Yoast SEO is active: hook wpseo_title for the title and wpseo_metadesc for the description.
  • Rank Math is active: hook rank_math/frontend/title and rank_math/frontend/description.
  • No SEO plugin (core only): hook pre_get_document_title (or document_title_parts) for the title, and print your own <meta name="description"> on wp_head, because core does not emit one.

That is the whole decision. The rest of this page is the code for each path, a portable helper that detects which plugin is active and hooks the right filter, and the one gotcha that wastes everyone an afternoon: hooking core while a plugin is active and wondering why nothing changes.

Which filter to hook

Active setupTitle filterDescription filterDescription tag exists by default?
Yoast SEOwpseo_titlewpseo_metadescYes, Yoast prints it
Rank Mathrank_math/frontend/titlerank_math/frontend/descriptionYes, Rank Math prints it
Core only (no SEO plugin)pre_get_document_title(none)No, you must print it yourself

The pattern is the same in all three rows: return your value to override, return the value you were given to pass through unchanged. The difference is where the filter lives. Below are the three paths, each one a few lines.

Yoast: wpseo_title and wpseo_metadesc

Yoast generates the title and meta description through its own presentation layer, then exposes them as filters. Hook those and your value wins:

php
add_filter( 'wpseo_title', 'te_yoast_filter_title' );
function te_yoast_filter_title( $title ) {
    if ( ! is_singular( 'product' ) ) {
        return $title; // pass through untouched
    }
    return 'Buy ' . get_the_title() . ' | Acme';
}

add_filter( 'wpseo_metadesc', 'te_yoast_filter_metadesc', 10, 2 );
function te_yoast_filter_metadesc( $description, $presentation ) {
    if ( ! is_singular( 'product' ) ) {
        return $description;
    }
    return 'In-stock now: ' . get_the_title() . '. Free returns within 30 days.';
}

Two things worth knowing. The wpseo_metadesc filter passes a second argument (the Indexable_Presentation object) since Yoast 14.x, so declare it with add_filter( ..., 10, 2 ) if you want the presentation context; you can ignore it and take one argument if you do not. And both wpseo_title and wpseo_metadesc exist in Yoast SEO free, not just Premium, so you do not need a paid tier to override programmatically.

Rank Math: rank_math/frontend/title and /description

Rank Math uses namespaced filter names. Same shape, different hook:

php
add_filter( 'rank_math/frontend/title', 'te_rankmath_filter_title' );
function te_rankmath_filter_title( $title ) {
    if ( ! is_singular( 'product' ) ) {
        return $title;
    }
    return 'Buy ' . get_the_title() . ' | Acme';
}

add_filter( 'rank_math/frontend/description', 'te_rankmath_filter_description' );
function te_rankmath_filter_description( $description ) {
    if ( ! is_singular( 'product' ) ) {
        return $description;
    }
    return 'In-stock now: ' . get_the_title() . '. Free returns within 30 days.';
}

Rank Math applies these on the front end as the page builds its head output, so a conditional check (post type, query var, a $_GET flag on a rewrite page) decides per-request whether you override or fall through. Return the incoming $description to leave Rank Math's own value in place.

Core only: pre_get_document_title plus a wp_head meta tag

With no SEO plugin installed, WordPress core owns the title via wp_get_document_title(), and it does not output a meta description at all. So the title and the description are two separate jobs.

For the title, pre_get_document_title short-circuits the whole generator: return a non-empty string and core uses it verbatim, skipping its normal title assembly.

php
add_filter( 'pre_get_document_title', 'te_core_filter_title' );
function te_core_filter_title( $title ) {
    if ( ! is_singular( 'product' ) ) {
        return $title; // empty string -> core builds the title normally
    }
    return 'Buy ' . get_the_title() . ' | Acme';
}

If you only want to tweak the parts (page title, separator, site name) rather than replace the whole string, hook document_title_parts instead and edit the array members, leaving the rest to core.

For the description, there is nothing to filter, because core never prints one. You output the tag yourself on wp_head:

php
add_action( 'wp_head', 'te_core_print_metadesc', 1 );
function te_core_print_metadesc() {
    if ( ! is_singular( 'product' ) ) {
        return; // print nothing on other contexts
    }
    $description = 'In-stock now: ' . get_the_title() . '. Free returns within 30 days.';
    printf(
        '<meta name="description" content="%s">' . "\n",
        esc_attr( $description )
    );
}

Always esc_attr() the content: it goes straight into an HTML attribute, and an unescaped quote in a post title will break the tag. The early priority (1) just keeps it tidy near the top of the head.

A portable helper that detects the active plugin

If you ship code to sites you do not control (a plugin, a reusable snippet, a client base split across Yoast and Rank Math), do not hardcode one path. Detect the active SEO plugin and hook the matching filter. Yoast registers the WPSEO_Options class; Rank Math registers a top-level RankMath class (and a RANK_MATH_FILE constant). A class_exists() check is the cheap, reliable signal:

php
function te_seo_active_plugin() {
    if ( class_exists( 'WPSEO_Options' ) ) {
        return 'yoast';
    }
    if ( class_exists( 'RankMath' ) ) {
        return 'rankmath';
    }
    return 'core';
}

Then wire each plugin's title and description filter to a single pair of callbacks, so your override logic lives in one place no matter what is installed:

php
add_action( 'init', 'te_seo_register_overrides' );
function te_seo_register_overrides() {
    switch ( te_seo_active_plugin() ) {
        case 'yoast':
            add_filter( 'wpseo_title', 'te_seo_title' );
            add_filter( 'wpseo_metadesc', 'te_seo_description' );
            break;
        case 'rankmath':
            add_filter( 'rank_math/frontend/title', 'te_seo_title' );
            add_filter( 'rank_math/frontend/description', 'te_seo_description' );
            break;
        default: // core only
            add_filter( 'pre_get_document_title', 'te_seo_title' );
            add_action( 'wp_head', 'te_seo_print_core_metadesc', 1 );
    }
}

function te_seo_title( $title = '' ) {
    if ( ! is_singular( 'product' ) ) {
        return $title;
    }
    return 'Buy ' . get_the_title() . ' | Acme';
}

function te_seo_description( $description = '' ) {
    if ( ! is_singular( 'product' ) ) {
        return $description;
    }
    return 'In-stock now: ' . get_the_title() . '. Free returns within 30 days.';
}

function te_seo_print_core_metadesc() {
    $description = te_seo_description( '' );
    if ( '' === $description ) {
        return;
    }
    printf(
        '<meta name="description" content="%s">' . "\n",
        esc_attr( $description )
    );
}

te_seo_title() and te_seo_description() are the only places you write your actual title and description logic; the wiring picks the right hook. The same two callbacks serve the Yoast filter, the Rank Math filter, and (for the description) the manual core wp_head print. When you are not overriding, every path returns the value it was handed, so the plugin's or core's own output passes through cleanly.

Ship it as a must-use plugin so it loads before regular plugins and survives theme switches:

php
<?php
/**
 * Plugin Name: TE SEO Meta
 * Plugin URI:  https://techearl.com/wordpress-override-seo-title-meta-description
 * Description: Overrides the SEO title and meta description, hooking Yoast, Rank Math, or core depending on which is active.
 * Version:     1.0.0
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 * License:     GPL-2.0-or-later
 * Text Domain: te-seo-meta
 */

// ... the te_seo_* functions above go here ...

This is the approach I reach for on dynamic, rewrite-driven pages the SEO plugin cannot template: faceted listing URLs, a custom rewrite endpoint, a virtual page assembled from query vars. The plugin has no editor screen for those routes, so there is no meta box to fill in. Detect the active plugin, key the override off the query var, and the title and description are correct on a URL that does not exist as a post.

The gotcha: hooking core does nothing when a plugin is active

This is the mistake that sends people in circles. You write a clean pre_get_document_title filter, load the page, and the title in the browser tab is exactly what it was before. The filter is firing, your callback is returning the right string, and the title still does not change.

The reason: with Yoast or Rank Math active, the plugin is what actually renders the <title> tag. It does not necessarily route through wp_get_document_title() at all, and even where core builds a string, the plugin overrides the final output downstream. So your pre_get_document_title value gets computed and then discarded. Same story for the description: your hand-printed wp_head meta tag ends up as a second <meta name="description"> next to the plugin's, and the plugin's is the one that counts.

The fix is the whole point of this article: when a plugin owns the tags, hook the plugin. pre_get_document_title and a manual wp_head description are correct only on a site with no SEO plugin. The detection helper above exists precisely so you never hook the wrong layer.

Verify it

Curl the page and grep the head. Replace the URL with your own:

bash
curl -s https://example.com/your-page/ | grep -i '<title>'
curl -s https://example.com/your-page/ | grep -i 'name="description"'

What you are checking:

  • The <title> text is your overridden value.
  • There is exactly one <meta name="description"> and it carries your text. Two description tags means you printed a core one on wp_head while a plugin is also printing its own: you hooked the wrong layer, go back to the plugin's filter.
  • On a core-only site, confirm the description tag appears at all (core does not add one, so if your wp_head print is missing or conditioned out, there will be none).

If the title still will not change, the usual causes are: a page cache serving stale HTML (purge it), hooking pre_get_document_title while a plugin is active (the gotcha above), or your conditional (is_singular, a query var) not matching the request you are testing.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressSEOYoastRank MathPHPFilters

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

How to Show Lines Before and After a grep Match (Context)

grep -C 3 'pattern' file prints the matching line plus 3 lines on each side. The three context flags (-A after, -B before, -C both), how the -- group separator works between match blocks, asymmetric context, recursive context search, and the macOS BSD vs GNU differences that bite.