Quick Answer: How to Fix Yoast SEO for Headless WordPress?
Yoast SEO breaks on headless sites because it assumes the CMS and Frontend are on the same domain. To fix this, you must:
- Rewrite URLs: Filter the REST API response to swap
cms.yoursite.comwithyoursite.com. - Fix Canonical Tags: Manually inject the correct frontend URL into the head tags.
- Patch Word Counts: Force Yoast to count words from ACF fields, or Schema will show “0 words.”
You built a blazing-fast Headless WordPress site with Astro or Next.js. The Lighthouse score is 100. The design is pixel-perfect.
But when you check Google Search Console, it’s a disaster.
Your canonical tags are pointing to the wrong domain (cms.yoursite.com instead of yoursite.com). Your schema data says “0 Words” because the content lives in ACF blocks, not the classic editor. And your internal links are leaking authority to your backend staging site.
In this guide, I’m sharing the exact “Headless SEO Suite” code I use on production sites to force Yoast to behave. This PHP snippet fixes the Schema, rewrites the URLs, and patches the Word Count bug automatically.
Why Traditional Plugins Fail in Headless SEO
The core issue is architectural mismatch. WordPress plugins like Yoast SEO, RankMath, and All in One SEO were built for the monolithic era—where the database, admin panel, and frontend theme all live on the same server and domain.
In a headless setup, you decouple the frontend (Astro, Next.js, Nuxt) from the backend (WordPress). This creates three major SEO gaps:
- Domain Confusion: The plugin generates sitemaps, canonicals, and OpenGraph tags using the backend URL (e.g.,
admin.mysite.com). If search engines index these, they ignore your actual frontend, leading to duplicate content issues or indexing your login page. - Incomplete Schema Data: Standard SEO plugins scrape the
the_content()loop to calculate word counts and “Time to Read” estimates. Since headless sites often use Advanced Custom Fields (ACF) or Gutenberg blocks served via API, the plugin sees an empty post. The result?wordCount: 0in your schema, telling Google your detailed guide is “thin content.” - Preview & Drafts Break: Without special configuration, the “Preview” button in WordPress tries to load the backend theme (which doesn’t exist) instead of your headless frontend draft.
Why Go Headless Despite the SEO Challenges?
If fixing SEO requires custom code, is Headless WordPress worth it? For many enterprise and performance-focused sites, the answer is a resounding yes.
- Unmatched Performance: By pre-rendering content with static site generators (SSG) like Astro, you serve pure HTML/CSS on the edge. This leads to near-instant load times and Core Web Vitals scores that traditional WordPress struggles to hit without heavy caching plugins.
- Enhanced Security: Your WordPress database and admin panel are hidden from the public internet. Hackers attacking your frontend URL hit a static CDN, not your SQL database. This drastically reduces the attack surface for common WordPress vulnerabilities.
- Developer Experience: Frontend teams can use modern JavaScript frameworks (React, Vue, Svelte) and component-based architectures without being tied to PHP templates.
The goal is to get these benefits without sacrificing the robust SEO tools that content editors love in WordPress. That’s where our patch comes in.
Key Takeaways
- The Problem: Yoast SEO defaults to the CMS domain for canonicals and schema, confusing Google.
- The “0 Word” Bug: If you use ACF or Blocks, Yoast often reports empty content, hurting your E-E-A-T score.
- The Solution: You don’t need a heavy plugin. A single
functions.phpfilter can intercept and rewrite the Yoast REST API response before it hits your frontend.
The Code: Headless SEO Patcher
This script hooks into the WordPress REST API. It grabs the data Yoast is about to send to your Astro/Next.js frontend and “sanitizes” it. It fixes the domain, the canonical tag, and the word count calculation in real-time.
Instructions: Add this to your theme’s functions.php file or a custom plugin.
/**
* HEADLESS WORDPRESS SEO SUITE (Final Production Version)
* Author: MD Pabel
* 1. Schema Generator: Forces "TechArticle" & Author data for ALL content.
* 2. URL Fixer: Rewrites CMS URLs to Frontend URLs.
* 3. Data Patcher: Fixes Word Count & Canonical Tags for Headless.
*/
// ==============================================================================
// PART 1: SCHEMA CONFIGURATION (The "Expert" Force)
// ==============================================================================
// 1. Override Yoast Settings (Forces TechArticle for EVERYTHING)
add_filter( 'option_wpseo_titles', function( $options ) {
// Custom Types
$options['schema-article-type-case-study'] = 'TechArticle';
$options['schema-article-type-malware-log'] = 'TechArticle';
$options['schema-article-type-guide'] = 'TechArticle';
// Force Standard Blog Posts to be TechArticle too
$options['schema-article-type-post'] = 'TechArticle';
$options['schema-page-type-post'] = 'WebPage';
return $options;
});
// 2. Register Capabilities (Ensure 'post' is included)
add_filter( 'wpseo_schema_article_post_types', function( $post_types ) {
return array_merge( $post_types, ['post', 'case-study', 'malware-log', 'guide'] );
});
// 3. Force the "Article" Graph Piece (Fixes "WebPage" default)
add_filter( 'wpseo_schema_needs_article', '__return_true' );
// 4. Force the "Author" Graph Piece (Ensures Profile appears)
add_filter( 'wpseo_schema_needs_profile', '__return_true' );
// 5. ✅ NEW: Fix "8 Word" Count Issue (Scans ACF/Meta if content is empty)
add_filter( 'wpseo_schema_article', function( $data, $context ) {
// If Yoast detects < 50 words, it likely missed the ACF content. Let's recount.
if ( !isset($data['wordCount']) || $data['wordCount'] < 50 ) { $post = $context->presentation->source;
// Start with standard content
$raw_text = $post->post_content;
// Only proceed if we actually have a post object with an ID
if ( ! $post || ! isset( $post->ID ) ) {
return $data;
}
// Brute Force: Append all text-based Custom Fields (ACF) to the count
$meta = get_post_meta($post->ID);
foreach($meta as $key => $values) {
// Skip hidden WP internal keys (start with _)
if ( substr($key, 0, 1) === '_' ) continue;
foreach($values as $val) {
// If it looks like a sentence, add it to our text bucket
if ( is_string($val) && strlen($val) > 3 ) {
$raw_text .= ' ' . $val;
}
}
}
// Strip HTML tags and count words
$clean_text = strip_tags($raw_text);
$real_count = str_word_count($clean_text);
// Update Schema Data
$data['wordCount'] = $real_count;
// Update TimeToRead (Avg 200 words/min) - Format: PTxM (ISO 8601)
$minutes = max(1, ceil($real_count / 200));
$data['timeRequired'] = "PT{$minutes}M";
}
return $data;
}, 10, 2 );
// ==============================================================================
// PART 2: URL REWRITER (The Sanitizer)
// ==============================================================================
function mdpabel_headless_seo_rewriter($response, $post, $request) {
if (!isset($response->data['yoast_head'])) {
return $response;
}
// --- CONFIGURATION ---
$cms_domain = 'cms.mdpabel.com';
$frontend_domain = 'https://www.mdpabel.com';
// --- FOLDER MAPPING ---
$folder = '/blog';
if ( isset($post->post_type) ) {
if ($post->post_type === 'case-study') $folder = '/case-studies';
if ($post->post_type === 'malware-log') $folder = '/malware-log';
if ($post->post_type === 'guide') $folder = '/guides';
}
$head = $response->data['yoast_head'];
// 1. Force Indexing
$head = str_replace('content="noindex', 'content="index', $head);
// 2. Fix Author Link
$head = str_replace(
'content="https://www.facebook.com/in/mdpabe1"',
'content="' . $frontend_domain . '/author/mdpabel/"',
$head
);
// 3. ✅ FORCE CANONICAL (The Fix)
// First, REMOVE any existing canonical (even if it points to CMS)
$head = preg_replace('//i', '', $head);
// Second, CREATE the correct one
$canonical_url = $frontend_domain . $folder . '/' . $post->post_name . '/';
// Third, PREPEND it to the top (so it is the first thing bots see)
$head = '' . "\n" . $head;
// 4. Main Domain Rewrite Loop
$pattern = '/https?:\/\/' . preg_quote($cms_domain, '/') . '(\/[^"\'\s<]*)?/'; $head = preg_replace_callback($pattern, function($matches) use ($frontend_domain, $folder, $post) { $full_url = $matches[0]; $path = isset($matches[1]) ? $matches[1] : ''; // Skip Assets if (strpos($path, '/wp-content/') !== false || strpos($path, '/wp-includes/') !== false || preg_match('/\.(xml|xsl|json)$/', $path)) { return $full_url; } // Rewrite Logic if (strpos($path, $post->post_name) !== false) return $frontend_domain . $folder . '/' . $post->post_name . '/';
if (strpos($path, '/author/') !== false) return $frontend_domain . $path;
if (strpos($path, '?s=') !== false) return $frontend_domain . '/blog' . $path;
return $frontend_domain . $path;
}, $head);
$response->data['yoast_head'] = $head;
return $response;
}
// Apply Rewriter to All Content Types
add_filter('rest_prepare_post', 'mdpabel_headless_seo_rewriter', 10, 3);
add_filter('rest_prepare_page', 'mdpabel_headless_seo_rewriter', 10, 3);
add_filter('rest_prepare_case-study', 'mdpabel_headless_seo_rewriter', 10, 3);
add_filter('rest_prepare_malware-log', 'mdpabel_headless_seo_rewriter', 10, 3);
add_filter('rest_prepare_guide', 'mdpabel_headless_seo_rewriter', 10, 3);
FAQ: Headless WordPress SEO
Does Yoast SEO work with Headless WordPress?
Why does my Headless WordPress site have “0 words” in Schema?
Should I use a “Headless SEO” plugin?
Need help implementing this? If you are struggling with a complex Headless WordPress setup or dealing with SEO data leaks, hire me to audit your architecture. I specialize in fixing the technical debt that plugins leave behind.



































