All WordPress HTML Templates Forms & Webhooks AI & Tools
WordPress

Schema Markup in WordPress: Beyond What Your SEO Plugin Gives You

Go beyond Yoast defaults: how to extend WordPress schema markup with real JSON-LD, fix merchant listing gaps, kill duplicate nodes and validate properly.

A real working moment illustrating the theme of an article about wordpress schema markup. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

We audited an ecommerce client whose Search Console showed 1,187 valid Product items and zero merchant listing impressions. The plugin was doing its job. The markup validated. It just didn’t contain shippingDetails or hasMerchantReturnPolicy, which meant Google filed every product under “product snippet” and skipped the richer treatment entirely. Two days of work on the WooCommerce schema filter, and the merchant listing report started populating within a fortnight.

That’s the gap. WordPress schema markup from Yoast or Rank Math gets you a clean, well-formed entity graph for pages, posts, breadcrumbs and your organisation. It does not get you the domain-specific types that actually trigger anything interesting in search results, and it cannot know what your custom fields mean.

This is the part nobody writes about, because it requires reading Google’s documentation and writing a PHP filter rather than toggling a switch.

Key Takeaways

  • Yoast and Rank Math both expose a single filter on their JSON-LD graph (wpseoschemagraph and rankmath/jsonld). Extending that graph is almost always better than adding a second <script type="application/ld+json"> block, because node @id references only resolve within one document.
  • @id is not decoration. It’s what lets Google connect your Course, Product or Event node to the Organization and WebPage nodes the plugin already emitted. Get the IDs wrong and you have orphaned entities that validate perfectly and do nothing.
  • FAQ and HowTo rich results are effectively dead for commercial sites. Google restricted FAQ snippets to authoritative government and health sources in August 2023 and retired HowTo entirely. Keep the markup if you like, but stop treating it as a traffic lever.
  • For products, “valid” and “eligible” are different states. Merchant listings need price, availability, shipping details and a return policy. Missing the last two produces green ticks in Search Console and no rich result.
  • Always verify with curl, not just the Rich Results Test. Full-page caches, JS-injected schema and country-specific pricing all produce markup that differs between what the tester fetches and what a real crawler sees.

What your SEO plugin actually emits, and where it stops

Yoast has shipped a connected @graph since version 11 in 2019, and it’s genuinely good work. On a typical post you get WebSite, WebPage, Article, BreadcrumbList, Organization, Person and ImageObject nodes, each with a stable @id, all cross-referencing each other. Rank Math produces something similar with a different node-naming convention.

What neither can do is infer meaning from your content model. If you have a course post type with ACF fields for duration, instructor and price, the plugin sees a post. It will output Article. That’s not wrong exactly, but Course with a hasCourseInstance carrying courseWorkload is what makes you eligible for course info panels. No plugin is going to guess that fielddurationhours maps to an ISO 8601 duration string.

The same applies to Event, JobPosting, SoftwareApplication, LocalBusiness with real openingHoursSpecification, Vehicle listings, ProfilePage for author or member pages, and DiscussionForumPosting if you run anything resembling a forum. All of these need you to write the mapping.

A real working moment that fits an article section about

What Google still rewards in 2026

Be brutal about this. Effort spent on dead types is effort wasted. The honest tiers:

  • Worth real engineering time: merchant listings and product variants (ProductGroup with hasVariant), Event, JobPosting, Recipe, LocalBusiness, Course info, Video with clip and seekToAction, and loyalty pricing via MemberProgramTier on the offer if you run a membership scheme.
  • Worth doing cheaply, no rich result expected: Organization with sameAs, logo and contactPoint; Article with real author entities that have their own @id and a ProfilePage. This is entity disambiguation work. It feeds the Knowledge Graph and, increasingly, the retrieval layer behind AI answers, where unambiguous machine-readable facts matter more than prose.
  • Dead or near-dead: FAQPage snippets for commercial sites, HowTo, SearchAction sitelinks search box (deprecated November 2024), and self-serving Review markup on your own products or services.

One position worth defending: structured data is not a ranking factor, and claiming otherwise is how agencies sell audits. Google has said this repeatedly. What structured data does is make you eligible for specific presentation formats and help machines resolve what your page is about. Those are worth having. They are not the same as “rank higher”.

Extending the graph instead of bolting on a second script

The most common mistake on inherited sites: a theme or snippet plugin dumping a second JSON-LD block into the footer. It parses. It also creates a duplicate, disconnected Organization and a Product node that references an @id living in a different script tag. Cross-document and cross-block @id references do not reliably resolve. Put everything in one graph.

Here’s the Yoast approach, which works well on most custom builds:

<?php
/**
  • Add a Course node for the course CPT and wire it into Yoast's existing graph.
  • Priority 11 so it runs after Yoast has built its own nodes.
*/ addfilter( 'wpseoschema_graph', function ( $graph, $context ) { if ( ! is_singular( 'course' ) ) { return $graph; } $postid = getqueriedobjectid(); $course_id = $context->canonical . '#course'; // absolute and page-scoped, never a bare slug $hours = (int) getpostmeta( $postid, 'durationhours', true ); $graph[] = [ '@type' => 'Course', '@id' => $course_id, 'name' => getthetitle( $post_id ), 'description' => wpstripalltags( gettheexcerpt( $postid ) ), // Reuse Yoast's Organization node rather than redeclaring the company. 'provider' => [ '@id' => $context->site_url . '#organization' ], 'hasCourseInstance' => [ [ '@type' => 'CourseInstance', 'courseMode' => 'Online', // ISO 8601 duration. Required for course info eligibility. 'courseWorkload' => 'PT' . $hours . 'H', ], ], ]; // Point the WebPage node at the course so the primary entity is unambiguous. foreach ( $graph as $i => $node ) { if ( in_array( 'WebPage', (array) ( $node['@type'] ?? [] ), true ) ) { $graph[ $i ]['mainEntity'] = [ '@id' => $course_id ]; break; } } return $graph; }, 11, 2 );

The $context object is the useful part. It gives you canonical, siteurl, companyname and the indexable, so your IDs match Yoast’s conventions exactly instead of being guesses. Rank Math’s equivalent is addfilter( 'rankmath/json_ld', fn( $data, $jsonld ) => ..., 20, 2 ), where $data is an associative array keyed by node name rather than a list.

When you have no SEO plugin

Headless setups, custom themes, sites where the client refuses another plugin. Fine. Emit one graph yourself:

<?php
addaction( 'wphead', function () {
    if ( ! is_singular( 'recipe' ) ) {
        return;
    }

    $graph = [ / your nodes / ];

    echo '<script type="application/ld+json">'
        . wpjsonencode(
            [ '@context' => 'https://schema.org', '@graph' => $graph ],
            // JSONHEXTAG matters: it escapes < and > so a stray </script>
            // inside post content cannot break out of the JSON block.
            JSONUNESCAPEDSLASHES | JSONUNESCAPEDUNICODE | JSONHEXTAG
        )
        . '</script>' . "\n";
}, 20 );

That JSONHEXTAG flag is not theoretical. A product description containing pasted HTML can terminate the script tag early, which silently invalidates every node after it. It happens.

Duplicate entities and the quiet damage they do

Run validator.schema.org against a mature WordPress site and count the Organization nodes. Three is common: one from the SEO plugin, one from WooCommerce, one from the theme’s “schema support” feature. Google will pick one and you don’t get to choose which.

The audit order that works: disable the theme’s schema output first (if your theme ships its own, it’s usually the least complete; keeping theme markup minimal lets the SEO plugin own the graph cleanly), then decide whether WooCommerce or the SEO plugin owns Product. Owning it in WooCommerce is generally better because it has live stock and price data. Yoast’s WooCommerce SEO add-on handles the handoff; without it, use woocommercestructureddatatypefor_page to strip what you don’t want.

Then there’s the sameAs problem. If your Organization node lists social profiles in the SEO plugin but a legacy theme option lists a different, outdated set, you’re sending conflicting identity signals about the same entity. Pick one source of truth and delete the other.

Product schema, merchant listings and the caching trap

The eligibility bar for merchant listings is higher than most people realise. Price, currency and availability get you a product snippet. Shipping and returns get you the merchant listing experience, including the free listing surfaces. Here’s the WooCommerce filter:

<?php
addfilter( 'woocommercestructureddataproduct', function ( $markup, $product ) {
    if ( empty( $markup['offers'][0] ) ) {
        return $markup;
    }

    $markup['offers'][0]['shippingDetails'] = [
        '@type'        => 'OfferShippingDetails',
        'shippingRate' => [
            '@type'    => 'MonetaryAmount',
            'value'    => '0',
            'currency' => getwoocommercecurrency(),
        ],
        'shippingDestination' => [
            '@type'          => 'DefinedRegion',
            'addressCountry' => 'GB',
        ],
        'deliveryTime' => [
            '@type'        => 'ShippingDeliveryTime',
            'handlingTime' => [ '@type' => 'QuantitativeValue', 'minValue' => 0, 'maxValue' => 1, 'unitCode' => 'DAY' ],
            'transitTime'  => [ '@type' => 'QuantitativeValue', 'minValue' => 1, 'maxValue' => 3, 'unitCode' => 'DAY' ],
        ],
    ];

    $markup['offers'][0]['hasMerchantReturnPolicy'] = [
        '@type'                => 'MerchantReturnPolicy',
        'applicableCountry'    => 'GB',
        'returnPolicyCategory' => 'https://schema.org/MerchantReturnFiniteReturnWindow',
        'merchantReturnDays'   => 30,
        'returnMethod'         => 'https://schema.org/ReturnByMail',
        'returnFees'           => 'https://schema.org/FreeReturn',
    ];

    return $markup;
}, 10, 2 );

Hardcoding country and rates like this only works for single-market stores. Multi-region shops need the values derived from shipping zones. If you serve different currencies from the same URL via geolocation, your structured data and your visible price will diverge for some visitors. That mismatch is a policy violation, not just a bug.

Caching is the other landmine. Structured data must match what the user sees. A full-page cache holding yesterday’s price in the JSON-LD while a fragment-cached price block shows today’s is a real problem. Exclude product pages from long-TTL caching, or invalidate on woocommerceproductset_stock and price save hooks. Broken price pairs tend to show up on sites running aggressive object caching with no product-specific purge rules. If you’re chasing something like this, the usual suspects surface quickly with Query Monitor and a disciplined log-reading pass.

While you’re in the product template, check that the visible page actually supports the claims your markup makes. Schema promising free returns on a page with no returns information is the kind of inconsistency manual reviewers notice.

Validating properly, then monitoring

The Rich Results Test tells you about eligibility. The Schema Markup Validator tells you about vocabulary correctness. You need both, and you need a third check: what a plain HTTP fetch returns.

# Dump every JSON-LD block from the rendered HTML, pretty-printed.
curl -sA "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
  https://example.com/product/widget/ \
| python3 -c "import sys,re,json; \
[print(json.dumps(json.loads(m),indent=2)) for m in \
re.findall(r'<script type=\"application/ld\+json\">(.*?)</script>', sys.stdin.read(), re.S)]"

If that prints nothing and the Rich Results Test shows markup, your schema is JavaScript-injected. Googlebot renders JS, but it does so on a separate, slower pass, and plenty of other consumers (Bing, social scrapers, LLM crawlers) don’t. Server-render it. Same argument as everywhere else in progressive enhancement: the important stuff belongs in the initial response.

For monitoring, the Search Console Enhancements reports are the only continuous signal you get. Set a monthly reminder to check item counts against your actual published counts. A drop from 1,100 to 400 valid Product items usually means someone deployed a template change, not that Google changed its mind.

Headless and static: who owns the schema now

Decoupling WordPress moves schema generation out of PHP and into whatever renders the page. Yoast exposes its full graph through the REST API via the Yoast SEO REST API add-on or WPGraphQL integration, so you can fetch the computed @graph and inject it in your Next.js or Astro head. That works, and it’s the right approach if you’ve already gone headless.

What doesn’t work is assuming the graph comes along for free. The front end can render perfect pages with zero structured data if nobody wired the SEO payload through. If you’re weighing the architecture, the trade-offs are worth going through honestly before you commit. Schema plumbing is one of the small costs that adds up.

One last thing. Structured data describes your page; it doesn’t replace it. If your markup says Article but the page is a <div> soup with no headings and no <main>, you’ve told machines one story in JSON and a different one in HTML. Getting the tags right is the foundation the schema sits on, not an alternative to it.

Frequently Asked Questions

Do I still need an SEO plugin if I’m writing my own JSON-LD?

Usually yes, because the plugin handles the boring, interconnected base graph (WebSite, WebPage, BreadcrumbList, Organization) correctly and keeps it updated as Google’s requirements shift. Writing all of that yourself is a maintenance burden with no upside. Use the plugin for the foundation and the filter for your domain types.

Is FAQ schema worth adding in 2026?

Not for the rich result. Google limited FAQ snippets to authoritative government and health sites in August 2023 and has not reversed it. The markup is still valid vocabulary and may help machine comprehension of Q and A content, so adding it costs nothing, but don’t build a content strategy on it.

Should structured data live in Microdata, RDFa or JSON-LD?

JSON-LD, without exception for new work. Google explicitly recommends it, it keeps markup out of your templates so designers can’t break it with a class rename, and it’s the only format where a connected @graph with @id references is practical. Microdata only persists because older themes and WooCommerce templates still carry it.

Why does Search Console say my markup is valid but no rich result appears?

Validity and eligibility are separate. Valid means the syntax and required fields parse; eligibility also depends on recommended fields, content quality, site-level trust and whether Google chooses to show that format for the query. Products are the most common case: missing shipping and return policy data keeps you at product snippet level.

Can I use the same @id across different pages to avoid repeating nodes?

Only for genuinely global entities like your Organization, and even then you must repeat the full node definition on every page that references it. Parsers resolve @id within a single document; they do not fetch a node from another URL. A bare @id reference to a node defined only on your homepage will be treated as an unresolved stub.

Where to start

Pick one content type where a rich result genuinely exists for your business: products, events, job postings, courses, local business hours. Map its fields to the required and recommended properties in Google’s documentation, add them through your SEO plugin’s graph filter, and verify with a curl fetch rather than just the tester. Then watch the relevant Search Console report for three weeks before touching anything else.

Resist the urge to add every type you can. A small, accurate, well-connected graph outperforms a sprawling one full of orphaned nodes, and it’s a fraction of the maintenance when Google changes the rules again.

duplicate organization schema wordpress fix hasMerchantReturnPolicy shippingDetails woocommerce json-ld wordpress custom post type rank_math json_ld filter structured data wordpress woocommerce merchant listings wordpress schema markup wordpress schema markup beyond seo plugin wpseo_schema_graph filter example