Date

What the WPGraphQL + SEO Guide Doesn’t Tell You: Running It on wpgraphql.com

A follow-up to Headless WordPress SEO with Yoast and the Next.js App Router. That post covers the happy path. This one covers what happened when I implemented it on this site, wpgraphql.com, which I think has some nice tangibles to call out.

Shortly after publishing that guide, I did the obvious thing and finally decided to implement some SEO best practices on this site, which I have long ignored.

This site was not following proper SEO best practices, for no other reason than I never took the time to implement them.

wpgraphql.com had no <title> tag. Not a bad one, none at all. 🤦‍♂️

No meta description, no canonical, no Open Graph, no Twitter card, no structured data.

Again, this wasn’t a bug, it was just never implemented. There was a layoutProps.meta object on the homepage template declaring a title, as if I was planning to use it at some point, but nothing anywhere consumed it. I intended to get the SEO wired up years ago but never got to fully implementing it, until now.

So I followed my own guide, but also hit four problems that the guide had no reason to mention, because you might only meet them on a site, like this one, that has been running for a while.

Problem 1: Yoast was telling me to deindex the entire site

The WordPress backend here is headless. Nobody should land on it, so it has “Discourage search engines” turned on. That is the correct setting for a CMS whose front end lives somewhere else.

Here is what that does to the API:

{
posts(first: 8) {
nodes {
seo {
metaRobotsNoindex
canonical
}
}
}
}

Every single post came back identically:

metaRobotsNoindex: "noindex"
canonical: ""

Both are direct consequences of the blog_public option. Yoast suppresses canonicals entirely when a site discourages search engines, and it marks everything noindex.

Now look at that from the front end’s point of view. If you do the natural thing and map those values into your metadata, you have just shipped noindex on every page of your public site, on the authority of a setting that was describing a completely different hostname.

metaRobotsNoindex is answering a question about the CMS, not about your content.

That distinction does not exist in a traditional WordPress site, where the CMS and the public site are the same thing. It is fundamental in a headless one.

Worth knowing on its own: despite the name, metaRobotsNoindex does not hold a boolean or the string "noindex" specifically. It holds whatever Yoast computed for the index directive, so it is "index" on a normal post. Treat it as a truthy flag and you get the same catastrophe from the opposite direction.

Problem 2: every URL Yoast produced pointed at the CMS

opengraphUrl: https://our-cms.example.com/2026/01/22/...
schema.raw hosts: our-cms.example.com, schema.org, secure.gravatar.com

The canonical, the Open Graph URL, and every @id inside the JSON-LD graph pointed at the WordPress backend.

This is the one thing the original guide does warn about: set Site Address to your front end. I wrote that sentence and then did not follow it on my own site.

Emitting these verbatim would be worse than emitting nothing. You would be publishing structured data and social metadata that advertises your staging backend to every crawler and every social platform that touches a link.

Problem 3: I couldn’t change Site Address, and fixing it didn’t fix it

This site’s CMS is on WordPress.com, which will not let you change Site Address unless the domain is mapped to WordPress.com. The DNS points at Vercel, where the front end lives, and as long as the decoupled NextJS site is hosted there, the DNS needs to continue to point there.

Filtering the option gets you the same result without touching DNS:

PHP
add_filter( 'option_home', static function () {
return 'https://www.wpgraphql.com';
} );

Worth being precise about what this does and does not touch, because it sounds more dangerous than it is. It deliberately leaves siteurl alone, which matters:

  • WordPress builds the uploads base URL from the siteurl option, not home, so media and the Jetpack CDN URLs derived from it are unaffected
  • WPGraphQL builds its endpoint from site_url(), so /graphql does not move
  • WPGraphQL’s uri field is the permalink with home_url() stripped off the front, and permalinks are built from home_url() too, so both sides move together and uri stays a correct relative path
  • nodeByUri skips its host check entirely for relative URIs, and its allowed-host list already contains the hosts from both site_url() and home_url()

That last point is worth calling out. WPGraphQL anticipates this exact setup, and there is a graphql_allowed_hosts filter if you need more.

So I added the filter, ran Yoast’s “Optimize SEO Data” to rebuild, and checked.

Nothing changed.

Yoast serves stored data, not computed data. The WPGraphQL addon reads YoastSEO()->meta->for_post(), which comes out of Yoast’s indexables table. open_graph_url and canonical are columns, written when a post is indexed. Changing home_url() at runtime does not retroactively rewrite them.

And Yoast will not notice on its own. Its permalink watcher hooks update_option_permalink_structure, update_option_category_base and update_option_tag_base. Nothing watches update_option_home, and a filter does not fire an update action anyway. Yoast has no idea the home URL moved. Re-saving your permalinks does not help either, because update_option_* only fires when the value actually changes.

The fix: stop asking WordPress where your pages live

At this point I stopped trying to make Yoast produce the right URLs and moved the decision to the only place that reliably knows the answer.

The front end derives its own canonical from its own site URL and the node’s uri. It never reads Yoast’s canonical, Open Graph URL, or robots values.

What it does consume from Yoast is the editorial content: titles, descriptions, social titles and images, and the structured data. Those are genuinely authored in WordPress and belong there. Host policy is not.

For the JSON-LD, which really does have the CMS origin baked through it, the front end rewrites the origin on the way out. The neat part is that you do not need to configure the CMS hostname to do it. Yoast builds the page’s @id as <wp-origin><uri>, and you already know the uri, so you can find the origin in the payload itself:

JavaScript
function wpOrigin(raw, uri) {
const escaped = uri.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const match = raw.match(new RegExp(`https?://[a-z0-9.-]+(?=${escaped})`, "i"))
return match ? match[0] : null
}

Self-correcting, and it survives someone pointing an environment variable at a caching proxy instead of the origin. Which is exactly what had happened on this site.

Problem 4: empty strings are not null

The implementation passed TypeScript, passed ESLint, passed the test suite, and deployed a preview with a title, a canonical, robots directives, Open Graph tags and JSON-LD.

No meta description.

The fallback looked reasonable:

JavaScript
const description = seo.metaDesc ?? seo.opengraphDescription

Yoast returns absent fields as empty strings, not null. "" ?? x is "". Nullish coalescing does not fall through an empty string, so the chain stopped at the first field, and every post on this site has an empty metaDesc because nobody fills it in manually. Meanwhile opengraphDescription was populated the whole time, generated from the excerpt.

Every fallback chain touching Yoast data needs to treat a blank string as missing:

JavaScript
function firstNonEmpty(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim() !== "") return value
}
return null
}

Nothing in the type system catches this. The field is String, the value is a string. It took looking at a deploy preview.

While I was in there: stop serving the CMS at all

One more thing, which solves the root cause rather than the symptom.

The reason blog_public was off is that nobody should be reading the CMS. But the CMS was still rendering full pages. A permalink on the backend returned a complete HTML document with a real title and the entire post body. The only thing keeping it out of search was robots.txt.

That is a weak defence, and a subtly wrong one. Disallow blocks crawling, not indexing. A disallowed URL can still be indexed on the strength of inbound links, and because crawlers are barred they can never see a noindex or a redirect on it.

A small snippet makes the backend stop rendering templates entirely and 301 to the front end instead, while leaving the REST and GraphQL endpoints alone:

PHP
add_action( 'parse_request', function () {
if ( current_user_can( 'edit_posts' ) ) {
return;
}
if (
( defined( 'DOING_CRON' ) && DOING_CRON ) ||
( defined( 'REST_REQUEST' ) && REST_REQUEST ) ||
( defined( 'GRAPHQL_HTTP_REQUEST' ) && GRAPHQL_HTTP_REQUEST ) ||
is_admin()
) {
return;
}
global $wp;
wp_safe_redirect( trailingslashit( FRONT_END_URL ) . $wp->request, 301 );
exit;
}, 99 );

Priority 99 matters. WPGraphQL’s router hooks parse_request at priority 10 and defines GRAPHQL_HTTP_REQUEST there, so checking that constant later in the chain works. Run earlier and you would redirect your own API.

Uploads are unaffected, because static files never boot WordPress and parse_request never fires for them.

Once the CMS redirects everything, there is no longer any reason to discourage search engines, so you can turn indexing back on. Which in turn makes Yoast’s per-post noindex meaningful again, since it is no longer drowned out by a site-level setting.

Order matters here, and getting it backwards is the one genuinely risky move in this whole exercise: put the redirect in place and verify it while logged out, then enable indexing. Reverse that and you have a fully crawlable duplicate of your site for however long the gap lasts.

What I’d take away from this

The guide I wrote was not wrong. It was just written about a clean setup, and real sites are not clean.

The pattern underneath all four problems is the same. WordPress was built on the assumption that the CMS and the public site are the same thing, and Yoast inherits that assumption everywhere. Decouple them, and every field that encodes a URL or a policy becomes ambiguous: is it talking about the backend, or the site people actually visit?

The rule I ended up with: take content from WordPress, take identity from the front end. Titles, descriptions, social copy, and structured data are authored in the CMS and should come from it. Canonicals, robots directives, and anything else describing where a page lives are the front end’s business, because the front end is the only thing that knows.

The work is public if you want to read it. The component and the fragments are in the monorepo, and the site now has title tags for the first time.