Adding Product JSON-LD on WooCommerce — and keeping it in sync
How to add and validate Product JSON-LD on WooCommerce — which fields Google actually checks, and how to keep markup synced with the page.

WooCommerce already writes basic Product JSON-LD into every product page. The gap most stores hit isn't "no schema," it's incomplete or stale schema — missing brand and GTIN, an aggregateRating that doesn't match the stars on the page, or a price that changed in the cart but not in the application/ld+json block. This guide covers the fields that matter, where WooCommerce populates them automatically, where you need to add data, and how to keep the two in sync.
The fields that actually matter
Google's Product structured data documentation requires name plus at least one of offers, review, or aggregateRating for a page to be eligible for a product rich result at all. Everything else is "recommended," but in practice these are the ones worth getting right:
name— required, must match the visible product title.brand— not required for the basic Product snippet, but Google uses brand (together with GTIN and MPN) to match your listing to a known product entity, which matters more once you're feeding Merchant Center or AI shopping surfaces.gtin/gtin8/gtin12/gtin13/gtin14— the most specific GTIN that applies. Recommended for the snippet, effectively required for Merchant Center / free listings eligibility.sku— your internal identifier. Useful, but Google explicitly treats it as separate from GTIN/MPN for entity matching, not a substitute.offers— needsprice(decimal, e.g.39.99) and should includepriceCurrency(ISO 4217) andavailability(schema.orgItemAvailabilityvalues).aggregateRating— needsratingValueandreviewCount/ratingCount, and it must reflect real, on-page reviews. Google prohibits self-authored or fabricated review markup, and will disable rich results if the markup doesn't match what's visible.
What WooCommerce generates on its own
WooCommerce ships a WC_Structured_Data class that automatically renders application/ld+json on every single product page — view-source on any product and search for application/ld+json to see it. Out of the box, generate_product_data() sets name, url, description, sku, @id, and an image if the product has a featured image. It adds offers whenever the product has a price (the shape differs for simple, variable, and grouped products), and it adds aggregateRating (and up to five recent review entries) automatically once the product has at least one approved rating and two checkboxes are both on under WooCommerce → Settings → Products, in the Reviews section: Enable product reviews and Enable star rating on reviews. Either one turned off suppresses aggregateRating, even if the product has ratings.
Two fields are worth calling out specifically:
- GTIN: as of WooCommerce 9.2, there's a native "GTIN, UPC, EAN, or ISBN" field (
global_unique_id) in the Inventory tab of the product editor, for both simple products and variations.WC_Structured_Datastrips any non-digit characters from that field, then emits the result asgtin(always that one key, nevergtin8/gtin12/gtin13/gtin14) only if what's left is exactly 8, 12, 13, or 14 digits. Punctuation like hyphens or spaces is harmless — it's discarded before the length check — but letters, or a digit count that doesn't land on 8/12/13/14 after stripping, gets silently dropped from the JSON-LD. On an older WooCommerce version the field doesn't exist at all, which is the more common reason a store has GTIN data entered (in a custom field) but missing from its native markup. - Brand:
WC_Structured_Datadoes not setbrandat all, on any version. WooCommerce's Brands functionality (aproduct_brandtaxonomy, folded into core as of WooCommerce 9.4, on by default since 9.6) doesn't feed the structured data output either — you still need a small filter to map it into the schema.
Closing the brand and GTIN gaps
Add a functions.php (or must-use plugin) filter on woocommerce_structured_data_product to fill in what core leaves out:
add_filter( 'woocommerce_structured_data_product', function ( $markup, $product ) {
// Brand: pull from the product_brand taxonomy if present.
$brands = get_the_terms( $product->get_id(), 'product_brand' );
if ( $brands && ! is_wp_error( $brands ) ) {
$markup['brand'] = array(
'@type' => 'Brand',
'name' => $brands[0]->name,
);
}
// GTIN fallback: if the native field is empty, read from a custom meta key.
if ( empty( $markup['gtin'] ) ) {
$gtin = preg_replace( '/[^0-9]/', '', (string) $product->get_meta( '_custom_gtin' ) );
if ( preg_match( '/^(\d{8}|\d{12,14})$/', $gtin ) ) {
$markup['gtin'] = $gtin;
}
}
return $markup;
}, 10, 2 );
Because this reads live product data on every request (rather than writing a static string), it self-corrects whenever the underlying attribute or meta field changes — which is the core discipline for keeping schema in sync.
Keeping JSON-LD in sync with the visible page
The failure mode to design against isn't "we never added schema" — it's schema that drifts from the page. Three causes cover most real cases:
- Hardcoded snippets. A JSON-LD block pasted into a theme header or a page builder's "custom HTML" widget will not update when price, stock, or rating changes. Always generate markup from the live
$productobject via thewoocommerce_structured_data_productandwoocommerce_structured_data_product_offerfilters, never as a static string. - Duplicate schema sources. Yoast SEO, Rank Math, and similar plugins can also emit Product schema. If both WooCommerce core and an SEO plugin output a
Productblock, you get two (sometimes conflicting) JSON-LD graphs on one page. Check your SEO plugin's schema settings and disable its Product/offer schema if WooCommerce's is the one you're maintaining, or vice versa — pick one source of truth. - Full-page caching. If you run a page cache (server-level, a plugin, or a CDN), a cached HTML page can serve JSON-LD with yesterday's price or stock status even after the product updates. Make sure product save/stock-change events purge that specific product page from cache, not just the shop/category pages.
A worked example
{
"@context": "https://schema.org",
"@type": "Product",
"@id": "https://example.com/product/trail-runner-boot/#product",
"name": "Trail Runner Waterproof Boot",
"description": "Waterproof trail boot with reinforced toe cap and Vibram outsole.",
"image": "https://example.com/wp-content/uploads/2026/06/trail-runner-boot.jpg",
"sku": "TRB-2200-BLK-10",
"gtin": "0885909950805",
"brand": {
"@type": "Brand",
"name": "Anglera Outfitters"
},
"offers": {
"@type": "Offer",
"url": "https://example.com/product/trail-runner-boot/",
"priceCurrency": "USD",
"price": "129.00",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.6",
"reviewCount": "82"
}
}
How to validate
- View-source vs. rendered DOM: view-source (Ctrl/Cmd+U) shows exactly what WooCommerce emitted server-side; also inspect the live DOM in DevTools to confirm no JavaScript later strips or duplicates the
<script type="application/ld+json">block. curl: runcurl -s https://yourstore.com/product/your-product/ | grep -A 40 'application/ld+json'to pull the raw markup without browser rendering — useful for catching cache or bot-blocking issues, since some setups serve different HTML to curl than to a browser.- Rich Results Test: paste the URL or raw HTML into Google's Rich Results Test to confirm the Product type is detected and see which properties are read as required vs. recommended.
- Cross-check against the page: manually compare
price,availability, andratingValue/reviewCountin the JSON-LD against what a shopper actually sees on the rendered page. A mismatch is a policy violation, not just a cosmetic bug.
Verified as of July 2026 against Google's Product and Merchant Listing structured data documentation and the WooCommerce structured data wiki and code reference; WooCommerce's native GTIN field and Brands functionality are recent (2024–2025) additions, so confirm your installed version before assuming they exist.
None of this matters if the underlying product record is thin — a brand filter has nothing to read if brand was never captured, and a GTIN field stays empty if no one populated it. Anglera enriches those attributes (brand, GTIN, specs, use-cases) directly in the PIM or product data source WooCommerce reads from, continuously and at catalog scale, so the JSON-LD wiring above has real, current data to render rather than blanks.
