,

Link WooCommerce & Yoast SEO Product Schema

Link WooCommerce & Yoast SEO Product Schema

If you sell products with WooCommerce and use Yoast SEO, you already get a good amount of structured data out-of-the-box.

But there are two common gaps:

  1. WooCommerce’s Product JSON-LD is not explicitly linked to Yoast’s WebPage graph, even though Yoast’s whole Schema approach is “graph-based”.(Yoast developer portal)
  2. Important technical attributes (material, width, height, load class, etc.) are missing from the schema, even though you show them in your product tables.

In this article we’ll:

  • Add a semantically correct link between the Yoast WebPage graph and the WooCommerce Product graph using mainEntity and mainEntityOfPage.
  • Use Schema.org’s additionalProperty + PropertyValue pattern to expose all key product attributes in a machine-readable way.(schema.org)
  • Package everything as a small WordPress plugin you can install on any WooCommerce + Yoast SEO site.

But wait! Before we can even start, if you don’t know what schema markup is, then please read our article here → What Is Schema Markup? & How to Add It to Your Site


Why bother? A quick refresher on Product structured data

Google uses Schema.org Product markup to power product snippets, Google Images, Google Lens and merchant experiences.(Google for Developers)

Benefits:

  • Richer search results (price, availability, ratings, etc.).
  • Cleaner input for LLMs and other semantic search systems.
  • Easier feed-free syncing with Google Merchant Center and similar tools.(support.google.com)

Most docs focus on the basics (name, price, availability). They rarely cover domain-specific attributes such as:

  • Channel material
  • Nominal width
  • Load class according to EN or DIN
  • Packaging units, etc.

That’s exactly what additionalProperty + PropertyValue were designed for: arbitrary key/value feature pairs that still live in the Schema.org universe.


How WooCommerce and Yoast SEO handle Schema by default

  • WooCommerce outputs a Product JSON-LD block on single product pages via WC_Structured_Data. It already includes name, description, price, offers, image, etc.
  • Yoast SEO outputs a full Schema graph with WebPage, WebSite, Organization, breadcrumbs and more, using its Schema API.

By default these two worlds don’t “know” about each other:

  • Yoast’s WebPage doesn’t declare that its main entity is the WooCommerce Product.
  • WooCommerce’s Product doesn’t explicitly say which WebPage it belongs to.

Search engines are smart enough to infer this, but linking them explicitly with mainEntity and mainEntityOfPage gives you a cleaner, more robust graph.


The Schema pattern we’re aiming for

Conceptually we want this:

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "WebPage",
      "@id": "https://example.com/product/example-url/",
      "url": "https://example.com/product/example-url/",
      "name": "Example Product ...",
      "mainEntity": {
        "@id": "https://example.com/product/example-url/#product"
      }
    },
    {
      "@type": "Product",
      "@id": "https://example.com/product/example-url/#product",
      "name": "Example Product ...",
      "mainEntityOfPage": {
        "@id": "https://example.com/product/example-url/"
      },
      "additionalProperty": [
        {
          "@type": "PropertyValue",
          "name": "A Sample Value Name",
          "value": "A Sample Value"
        },
        {
          "@type": "PropertyValue",
          "name": "Width",
          "value": 160,
          "unitCode": "MMT"
        }
      ]
    }
  ]
}

Key points:

  • WebPage.mainEntity → references the Product @id.
  • Product.mainEntityOfPage → references the WebPage / canonical URL.
  • All extra attributes live under Product.additionalProperty as PropertyValue items.(schema.org)

Step-by-step: creating the plugin

We’ll now build a self-contained plugin that:

  1. Hooks into WooCommerce’s Product JSON-LD and:
    • Sets a stable @id for the Product.
    • Adds mainEntityOfPage pointing to the WebPage URL.
    • Appends an additionalProperty array from Woo attributes.
  2. Hooks into Yoast SEO’s WebPage piece and adds mainEntity referencing the Product @id.

1. Plugin file structure

Create a folder inside wp-content/plugins:

wp-content/
  plugins/
    wc-yoast-product-schema/
      wc-yoast-product-schema.php

2. Paste this plugin code

<?php
/**
 * Plugin Name: Woo + Yoast Product Schema Linker
 * Description: Links WooCommerce Product schema to Yoast WebPage schema and exposes product attributes via additionalProperty/PropertyValue.
 * Author: ZedKima.com
 * Version:     1.0.0
 * Requires PHP: 7.4
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class WC_Yoast_Product_Schema_Link {

	public static function init() : void {
		// Enrich WooCommerce Product schema.
		add_filter( 'woocommerce_structured_data_product', [ __CLASS__, 'filter_product_schema' ], 10, 2 );

		// Link Yoast WebPage piece -> Product piece.
		add_filter( 'wpseo_schema_webpage', [ __CLASS__, 'filter_yoast_webpage' ], 10, 2 );
	}

	/**
	 * Add @id, mainEntityOfPage and additionalProperty to Woo's Product schema.
	 *
	 * @param array      $markup
	 * @param WC_Product $product
	 * @return array
	 */
	public static function filter_product_schema( $markup, $product ) : array {
		if ( ! $product instanceof WC_Product ) {
			return $markup;
		}

		// Stable Product @id and page URL.
		$page_url   = get_permalink( $product->get_id() );
		$product_id = trailingslashit( $page_url ) . '#product';

		if ( empty( $markup['@id'] ) ) {
			$markup['@id'] = $product_id;
		}

		// Product -> WebPage via mainEntityOfPage (WebPage is a CreativeWork / URL).
		$markup['mainEntityOfPage'] = [
			'@id' => $page_url,
		];

		// Build additionalProperty array from visible attributes.
		$additional = self::build_additional_properties( $product );
		if ( ! empty( $additional ) ) {
			if ( isset( $markup['additionalProperty'] ) && is_array( $markup['additionalProperty'] ) ) {
				$markup['additionalProperty'] = array_values( array_merge( $markup['additionalProperty'], $additional ) );
			} else {
				$markup['additionalProperty'] = $additional;
			}
		}

		return $markup;
	}

	/**
	 * Make Yoast's WebPage schema reference the Product as mainEntity.
	 *
	 * @param array      $webpage Yoast WebPage piece.
	 * @param \Yoast\WP\SEO\Generators\Schema\Context|null $context
	 * @return array
	 */
	public static function filter_yoast_webpage( array $webpage, $context = null ) : array {
		if ( function_exists( 'is_product' ) && is_product() ) {
			$product_url = get_permalink( get_queried_object_id() );

			$webpage['mainEntity'] = [
				'@id' => trailingslashit( $product_url ) . '#product',
			];
		}

		return $webpage;
	}

	/**
	 * Convert WooCommerce product attributes to PropertyValue objects.
	 *
	 * @param WC_Product $product
	 * @return array[]
	 */
	protected static function build_additional_properties( WC_Product $product ) : array {
		$additional = [];

		// Only visible attributes.
		$attributes = array_filter( $product->get_attributes(), 'wc_attributes_array_filter_visible' );

		foreach ( $attributes as $attribute ) {
			$label = $attribute->is_taxonomy()
				? wc_attribute_label( $attribute->get_name(), $product )
				: $attribute->get_name();

			if ( ! $label ) {
				continue;
			}

			// Get a human-readable value.
			if ( $attribute->is_taxonomy() ) {
				$terms = wc_get_product_terms(
					$product->get_id(),
					$attribute->get_name(),
					[ 'fields' => 'names' ]
				);
				$values = array_map( 'wc_clean', $terms );
			} else {
				$values = array_map( 'wc_clean', $attribute->get_options() );
			}

			$value_text = trim( implode( ', ', array_filter( $values ) ) );
			$value_text = str_replace( "\xC2\xA0", ' ', $value_text ); // Replace &nbsp;

			if ( $value_text === '' || $value_text === '-' ) {
				continue;
			}

			$item = [
				'@type' => 'PropertyValue',
				'name'  => wp_strip_all_tags( $label ),
			];

			// Try to parse "160 mm", "34.80 kg", "88 cm²" into number + unitCode.
			$parsed = self::parse_numeric_with_unit( $value_text );

			if ( $parsed['is_numeric'] ) {
				$item['value'] = $parsed['number'];

				if ( $parsed['unitCode'] ) {
					$item['unitCode'] = $parsed['unitCode']; // UN/CEFACT
				} elseif ( $parsed['unitText'] ) {
					$item['unitText'] = $parsed['unitText']; // human-readable fallback
				}
			} else {
				// Keep complex ranges or multi-values as text.
				$item['value'] = $value_text;
			}

			$additional[] = $item;
		}

		return $additional;
	}

	/**
	 * Parse "34.80 kg", "160 mm", "88 cm²", "100", "5 %", etc.
	 *
	 * @param string $text
	 * @return array{is_numeric:bool,number:?float,unitCode:?string,unitText:?string}
	 */
	protected static function parse_numeric_with_unit( string $text ) : array {
		$out = [
			'is_numeric' => false,
			'number'     => null,
			'unitCode'   => null,
			'unitText'   => null,
		];

		// Single number with optional unit token.
		if ( preg_match( '/^\s*([\-+]?[0-9]+(?:[.,][0-9]+)?)\s*([a-zA-Zµ°%]+|cm2|cm²|m2|m²|cm3|cm³|m3|m³)?\s*$/u', $text, $m ) ) {
			$num = str_replace( ',', '.', $m[1] );

			if ( is_numeric( $num ) ) {
				$out['is_numeric'] = true;
				$out['number']     = (float) $num;

				$token = isset( $m[2] ) ? mb_strtolower( $m[2] ) : '';

				// Normalise superscripts and µ.
				$token = strtr(
					$token,
					[
						'cm²' => 'cm2',
						'm²'  => 'm2',
						'cm³' => 'cm3',
						'm³'  => 'm3',
						'µm'  => 'um',
					]
				);

				if ( $token !== '' ) {
					// Basic UN/CEFACT mapping for common units.
					$map = [
						'mm'  => 'MMT',
						'cm'  => 'CMT',
						'm'   => 'MTR',
						'kg'  => 'KGM',
						'g'   => 'GRM',
						'l'   => 'LTR',
						'cm2' => 'CMK',
						'm2'  => 'MTK',
						'cm3' => 'CMQ',
						'm3'  => 'MTQ',
						'%'   => 'P1',   // percent
						'°c'  => 'CEL',  // degree Celsius
						'um'  => 'UMT',  // micrometre
					];

					if ( isset( $map[ $token ] ) ) {
						$out['unitCode'] = $map[ $token ];
					}

					$out['unitText'] = $token;
				}
			}
		}

		return $out;
	}
}

WC_Yoast_Product_Schema_Link::init();

Activate the plugin in Plugins → Installed Plugins and visit any single product page.

You should now see:

  • The Yoast <script type="application/ld+json" class="yoast-schema-graph"> block with a WebPage that has: "mainEntity": { "@id": "https://example.com/product/your-product/#product" }
  • A separate WooCommerce <script type="application/ld+json"> block with your Product node that has: "@id": "https://example.com/product/your-product/#product", "mainEntityOfPage": { "@id": "https://example.com/product/your-product/" }, "additionalProperty": [ ...PropertyValue items... ]

Exactly like in the conceptual example above.


How additionalProperty + PropertyValue works

Schema.org describes PropertyValue as a generic type for property–value pairs, and additionalProperty as a way to attach such pairs to any entity (including Product).

In our plugin:

  • Each visible WooCommerce attribute becomes a PropertyValue node.
  • The attribute label becomes name.
  • The attribute value becomes:
    • either a number with "unitCode" (when we can parse “160 mm”, “34.8 kg”, etc.), in line with Schema.org’s recommendations for numeric values,(schema.org)
    • or plain "value" text when parsing doesn’t make sense (ranges like “1000 x 160 x 160 mm”, load classes like “A 15 – F 900”, etc.).

This gives search engines and LLMs a rich, strongly typed view of your catalogue without you having to fight the schema for every single physical property.

Linking the graphs the “Yoast way”

Yoast’s Schema API is designed to let you plug your own entities into their graph. Their docs explain the pattern with examples using Book and other types.

We’re following that same pattern:

  1. On product pages, the plugin makes Yoast’s WebPage piece say: $webpage['mainEntity'] = [ '@id' => trailingslashit( get_permalink( get_queried_object_id() ) ) . '#product', ];
  2. On the WooCommerce side, the plugin makes the Product piece say: $markup['mainEntityOfPage'] = [ '@id' => get_permalink( $product->get_id() ) ];

Together that creates a two-way link between WebPage and Product that matches both Schema.org’s model and Yoast’s own integration guidelines.


Testing and debugging

Before shipping this to production, run a few checks:

  1. Google’s Rich Results Test
    Paste a product URL and confirm:
    • You have a Product item.No errors for “mainEntityOfPage” target type.Your extra attributes appear under additionalProperty.
    https://search.google.com/test/rich-results (Google for Developers)
  2. Google Search Console / Merchant Center
    For bigger catalogues, keep an eye on the Enhancements → Product reports and/or Merchant Center diagnostics for structured data-related issues.(support.google.com)
  3. Yoast Schema debugging
    Yoast offers a “development mode” that pretty-prints the Schema JSON so it’s easier to read while you’re working on it: add_filter( 'yoast_seo_development_mode', '__return_true' ); Don’t leave this on in production — it adds overhead.

Advanced custom templates (like ours)

If you’re using a custom single-product template where you don’t call the normal woocommerce_single_product_summary hook, WooCommerce might not print its Product JSON-LD at all.

In that case, you can:

if ( function_exists( 'WC' ) && isset( WC()->structured_data ) ) {
	WC()->structured_data->generate_product_data();
}

…in a place you control (for example, in a custom action your template triggers). That’s exactly what we do on our own site before applying the filters above.


Wrap-up

With a relatively small amount of code, we have accomplished our goal which it is → Link WooCommerce & Yoast SEO Product Schema

  • Enriched WooCommerce’s Product schema with all those critical engineering attributes your customers care about.
  • Linked the Product node into Yoast’s Schema graph so search engines can clearly see which product a page is about.
  • Packaged it as a plugin that you (and other developers) can install, tweak and extend.

You can now add domain-specific semantics to your shop without fighting the default Woo/Yoast behavior — and give both Google and modern LLMs exactly the structured data they need to understand your products.

  • How To Use the WordPress Register Sidebar Function

    How To Use the WordPress Register Sidebar Function

    In this article, we’ll explore practical approaches for using the WordPress register sidebar function. We’ll also share some advanced techniques to help you get even more out of your sidebars! WordPress Register Sidebar – Single If you’d like to add a sidebar to your WordPress theme, the first step is to let WordPress know about…

  • How to Rename WordPress User Roles Safely

    How to Rename WordPress User Roles Safely

    On a recent client project, we ran into a surprisingly annoying UX bug: user roles with old names that no longer matched the business. The client runs a subscription-based WordPress site. At launch they had separate “Print” and “Digital” products, with roles like: Later, they introduced a combined “Bundle” subscription (print + digital). Marketing and…