The Day My Site Grew A Second Title Tag

The head of a web page showing two identical title tags: one printed by the SEO plugin, one printed by WordPress core, with the rest of the head between them.

The site was finished. That is always when this happens.

I was on the last pass before launch, checking that a batch of SEO titles and descriptions had actually reached the front end rather than just being saved somewhere. What I found instead was a duplicate title tag on every page of the site. The quickest way to see it is to count them:

$ curl -s https://example.com/ | grep -c '<title'

Two. Not a wrong title — two correct ones, identical, on every single page.

A duplicate title tag breaks nothing, which is the annoying part. Browsers take the first <title> and ignore everything after it, so the tab was right, the search preview was right, and the social card was right. The only way to see it is to read the page source — which is, as it happens, roughly the third thing anyone does when they are deciding whether you know what you are doing.


Where a duplicate title tag comes from: two functions, one job

WordPress has two title renderers, and which one you get depends on what kind of theme you are running.

  • _wp_render_title_tag() is the classic one. It is attached in default-filters.php, on wp_head at priority 1, and it bails out unless the theme declares add_theme_support( 'title-tag' ).
  • _block_template_render_title_tag() is the block theme one. It does not check for theme support at all — block themes always get a title, which is the correct decision. You can read it in the function reference.

Core is careful about this. When it resolves a block template, it swaps one for the other in the same breath:

// wp-includes/block-template.php, inside locate_block_template()

// Render title tag with content, regardless of whether theme has title-tag support.
remove_action( 'wp_head', '_wp_render_title_tag', 1 );          // Remove conditional title tag rendering...
add_action( 'wp_head', '_block_template_render_title_tag', 1 ); // ...and make it unconditional.

Take one away, put one back. Exactly one renderer attached at any moment. Left to itself, core is fine.

It stops being fine when something else joins in.

The SEO plugin wants the title too

And reasonably so. An SEO plugin owns your description and your canonical, and it would like the title to sit next to them in one tidy block rather than floating somewhere further up the head. So it takes core’s renderer off wp_head and re-attaches it inside its own output. Rank Math, with the comment left in:

// Code to move title inside the Rank Math's meta.
remove_action( 'wp_head', '_wp_render_title_tag', 1 );
add_action( 'rank_math/head', '_wp_render_title_tag', 1 );

That is right. On a classic theme it is the whole story and you never think about it again.

On a block theme, look at the timing. The plugin sets its hooks up early, the way plugins do. locate_block_template() runs much later — on the {$type}_template filter, while WordPress is still working out which template file to load. By the time core gets there, the plugin has already taken _wp_render_title_tag away.

So core’s remove_action() removes nothing. And its add_action() goes ahead anyway, because why wouldn’t it.

The plugin’s removal is correct. It just happens before the thing that puts a different renderer back.

A duplicate title tag. Nobody wrote a bug. Everyone was polite. You still get two.


The fix that lies to you

I did what you would do. Remove the block renderer, somewhere sensible, and template_redirect is where everyone reaches first:

add_action( 'template_redirect', function () {
	remove_action( 'wp_head', '_block_template_render_title_tag', 1 );
} );

Then I checked it, because I check things:

has_action( 'wp_head', '_block_template_render_title_tag' );
// false

Gone. Confirmed gone. I reloaded the page feeling quite good about myself and counted two titles.

template_redirect fires before the template is located. Core re-attaches the action a moment later, inside locate_block_template(). Your removal ran, succeeded, and was then quietly overtaken — and has_action() has no way to tell you that, because at the moment you asked, the answer really was false.

I lost more time to this than to the actual bug. I trusted a passing assertion over the rendered HTML, which is a thing I have told other people not to do.

A list of every function attached to wp_head at priority 1, in order. The last entry, _block_template_render_title_tag, is highlighted; _wp_render_title_tag is absent from the list.
Every callback on wp_head at priority 1, in order. The last line is the one that took three passes to notice.

What finally found it was blunt: attach a marker to every wp_head priority, print the callback list, and read the output in order. _block_template_render_title_tag only ever appears in that list. Its name is close enough to the classic one that it slides straight past a grep, which is how it stayed hidden through three rounds of looking directly at it.

The fix that works

wp_head at priority 0. It is the first moment where the registration has definitely happened and the callback definitely has not run:

add_action( 'wp_head', function () {
	// Only when something else has taken the title over. A plugin removing
	// core's classic renderer is that plugin saying "I am printing one".
	if ( false !== has_action( 'wp_head', '_wp_render_title_tag' ) ) {
		return;
	}

	remove_action( 'wp_head', '_block_template_render_title_tag', 1 );
}, 0 );

The guard is doing more work than the removal, and it is the part I would argue about if someone tried to delete it. Fixing a duplicate title tag is not worth trading for a missing one.

Remove core’s renderer unconditionally and it works beautifully until the day somebody deactivates the SEO plugin. Then the site has no <title> at all — on every page, with nothing in the admin to say so, and no error anywhere. You have traded a cosmetic duplicate for a silent catastrophe. Keying off the plugin’s own remove_action() means the worst case is that the site falls back to core behaviour, which is a fine place to fall back to.

Go and count yours

curl -s https://your-site.com/ | grep -c '<title'

One is the answer you want. If it says two, you have a duplicate title tag, you are running a block theme with something else printing your title, and everything above is why.

I only tested Rank Math, so I am not going to tell you every SEO plugin does this. But nothing in the mechanism is specific to Rank Math: any plugin that removes _wp_render_title_tag before the template is located, and prints its own title, lands in the same place. The curl takes a second and settles it either way.


The part I want to keep: when a remove_action() does not take, the question is almost never “did my code run”. It is “what runs after me”. has_action() answers the first question very convincingly, and the second one not at all.

This one turned up while I was rebuilding this site as a block theme. If you want the rest of that, it is on the about page.

Verified on WordPress 7.1 with Rank Math 1.0.276.