MODRACXKENNETH D'SILVA

← Archive & Insights

Technical SEO & Structured Data Implementation for E-Commerce

One product page emitted three BreadcrumbList blocks and Google picked the wrong one. Site-wide structured data is an architecture problem before it is a markup problem.

By Kenneth D'SilvaReading Time: 24 min readCategory: SEO & Marketing

1. Three Breadcrumbs, One Page, Two Of Them Lying

A garden equipment distributor asked me why their search results showed the breadcrumb trail example.co.uk › Sale › Clearance for a product that had been full price for eight months. Not a ranking problem. Not a traffic problem, particularly. Just a URL line in the SERP that said something untrue about every one of the 2,300 products that had ever been in a sale.

The page emitted three BreadcrumbList nodes. One from the theme's header partial, rendering the visible trail. One from a "SEO booster" extension installed in 2022, which built its trail from the product's first assigned category by ID — and category ID 84 was Clearance, created early and assigned to everything that had ever been discounted. And one from a category-landing-page module that had been copy-pasted into the product template by someone who wanted the same visual component.

Google picked the second one. It had no reason not to; all three were structurally valid, all three were on the page, and nothing in the specification says which wins when a page contradicts itself. The fix took forty minutes. Finding it took most of a day, because the three blocks were emitted from three layers of the stack and none of them appeared in the same template file.

That is the shape of nearly every site-wide structured data problem I get called about. Not "the markup is wrong" but "the site says several things at once and something downstream had to choose." Product-level markup — Product, Offer, prices, ratings, merchant listing requirements — is a self-contained problem with its own rulebook, and I have written about it separately in the piece on product schema for ecommerce. This article is about everything else: the nodes that describe the site rather than the item, the features Google has quietly withdrawn, and the mechanics of getting exactly one correct block onto every page from a template system that was not designed to guarantee it.

2. The Two Jobs Site-Wide Structured Data Does

It is worth being precise about why any of this is worth building, because a lot of it is not.

The first job is earning a specific SERP feature. Breadcrumb trails replacing the URL line. A sitelinks search box. A knowledge panel with your logo in it. These are visible, measurable, and — this is the part people forget — entirely at Google's discretion to withdraw.

The second job is disambiguation. Telling Google that alderwoodhome.co.uk, @alderwoodhome on three social platforms, a Companies House registration, and a Wikipedia stub are all the same organisation. Telling it that this product sits inside this category inside this section. None of that produces a visible feature directly. It produces correctness in systems you cannot inspect — entity resolution, the Shopping graph, whatever assembles AI overviews this quarter.

The first job has been shrinking for three years. The second has not. If you are deciding where to spend effort, I would put it in the second, and I say that as someone who spent a lot of 2022 building FAQ markup that is now dead weight.

3. The Entity Graph, and What @id Is Actually For

Most implementations emit a pile of independent JSON-LD blocks: a breadcrumb here, an organisation there, a product somewhere else. That works, in the sense that each is parsed. What it does not do is state the relationships between them, and the relationships are where the disambiguation value lives.

The @graph construct lets you emit one block containing several nodes that reference each other by @id.

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": "https://alderwoodhome.co.uk/#organization",
      "name": "Alderwood Home",
      "url": "https://alderwoodhome.co.uk/",
      "logo": {
        "@type": "ImageObject",
        "@id": "https://alderwoodhome.co.uk/#logo",
        "url": "https://alderwoodhome.co.uk/media/logo-600x600.png",
        "width": 600,
        "height": 600
      }
    },
    {
      "@type": "WebSite",
      "@id": "https://alderwoodhome.co.uk/#website",
      "url": "https://alderwoodhome.co.uk/",
      "name": "Alderwood Home",
      "publisher": { "@id": "https://alderwoodhome.co.uk/#organization" },
      "inLanguage": "en-GB"
    },
    {
      "@type": "WebPage",
      "@id": "https://alderwoodhome.co.uk/products/oak-lamp-classic#webpage",
      "url": "https://alderwoodhome.co.uk/products/oak-lamp-classic",
      "name": "Classic Oak Table Lamp",
      "isPartOf": { "@id": "https://alderwoodhome.co.uk/#website" },
      "breadcrumb": { "@id": "https://alderwoodhome.co.uk/products/oak-lamp-classic#breadcrumb" },
      "primaryImageOfPage": { "@id": "https://alderwoodhome.co.uk/products/oak-lamp-classic#primaryimage" }
    },
    {
      "@type": "BreadcrumbList",
      "@id": "https://alderwoodhome.co.uk/products/oak-lamp-classic#breadcrumb",
      "itemListElement": [
        { "@type": "ListItem", "position": 1, "name": "Home",
          "item": "https://alderwoodhome.co.uk/" },
        { "@type": "ListItem", "position": 2, "name": "Lighting",
          "item": "https://alderwoodhome.co.uk/lighting/" },
        { "@type": "ListItem", "position": 3, "name": "Table Lamps",
          "item": "https://alderwoodhome.co.uk/lighting/table-lamps/" },
        { "@type": "ListItem", "position": 4, "name": "Classic Oak Table Lamp" }
      ]
    }
  ]
}

The convention worth adopting: fragment identifiers on the origin for site-wide nodes (/#organization, /#website) and fragment identifiers on the page URL for page-level nodes (/products/x#webpage). It reads clearly, it is stable across a site migration as long as the domain holds, and every node in the graph then has a name you can reference from anywhere.

Is the graph strictly necessary? No. Google will happily parse four separate script blocks and infer some of the same relationships. What the graph gives you is a single assembly point in your code — one function that produces the whole page's structured data — which turns "three breadcrumbs from three layers" into a structurally impossible outcome. That is the real argument for it, and it is an engineering argument rather than an SEO one.

Where the product node fits

On a product page, the Product node joins the same graph, with mainEntity on the WebPage pointing at it. The internals of that node — offers, availability, ratings — are their own subject, covered in the product schema article. What matters here is that it is one more node in one graph produced by one assembler, not a fifth script tag added by a fifth developer.

4. BreadcrumbList: The One That Still Reliably Pays

Breadcrumbs are the most dependable site-wide rich result left. Google has shown them for years, has given no indication of withdrawing them, and they replace an ugly truncated URL with a readable hierarchy. On mobile especially, that is real estate.

The rules are short and people break all of them.

Position starts at 1 and increments by 1. No gaps, no zero-indexing. I have seen a React implementation emit positions 0 through 3 and lose the feature entirely.

The last item should omit item. The current page does not need a URL — it is the page. Including a self-referential URL is tolerated but the cleaner form is name alone. What is not tolerated is item pointing somewhere else, which happens when a template reuses the category breadcrumb builder on a product page.

The trail must match a real path through the site. This is the one the garden distributor broke. Google compares your breadcrumb against the URL structure and the internal linking. A trail that says Home › Sale › Clearance for a URL of /lighting/table-lamps/oak-lamp-classic is a contradiction, and when Google resolves contradictions it does not always resolve them your way.

One trail per page. If a product genuinely belongs to three categories, pick one — the primary one, the one the canonical internal linking uses — and be consistent. Emitting three BreadcrumbList nodes to express three valid paths is permitted by the vocabulary and is a bad idea in practice, because it gives Google a choice you would rather make yourself.

That last point deserves a paragraph of honesty, because it is the question I get most. Google's documentation does allow multiple breadcrumb trails for a page reachable by several paths. In principle you can express "this lamp is in Lighting › Table Lamps and also in Brands › Alderwood." In practice, across maybe fifteen implementations, I have never seen the second trail produce a benefit and I have twice seen the wrong one selected. Pick one. If your merchandising team insists a product's primary category is ambiguous, that ambiguity is a taxonomy problem to solve in the catalogue, not a schema problem to solve with markup — and it is worth reading alongside how keywords map to page types in the piece on intent-led keyword research, because the two problems have the same root.

Building the trail from the right source

The trail in your markup should come from the same data as the trail on your page. Not a parallel implementation. If they are computed separately, they will diverge, and the divergence will be invisible because nobody reads JSON-LD by eye.

In Magento, that means reading the breadcrumbs block rather than re-deriving the category path:

<?php
declare(strict_types=1);

namespace Modracx\Schema\Model\Node;

use Magento\Framework\View\LayoutInterface;

class Breadcrumb
{
    public function __construct(
        private LayoutInterface $layout
    ) {
    }

    /**
     * Read the crumbs the page is actually rendering. If the visible trail is
     * wrong, the markup will be wrong in the same way — which is a bug you can
     * see, rather than one hiding in a script tag.
     */
    public function build(string $pageUrl): ?array
    {
        $block = $this->layout->getBlock('breadcrumbs');
        if (!$block) {
            return null;
        }

        $crumbs = $block->getCacheKeyInfo()['crumbs'] ?? null;
        $crumbs = $crumbs ? json_decode(base64_decode($crumbs), true) : [];
        if (!$crumbs) {
            return null;
        }

        $items = [];
        $position = 0;
        foreach ($crumbs as $crumb) {
            $position++;
            $item = [
                '@type'    => 'ListItem',
                'position' => $position,
                'name'     => (string) ($crumb['label'] ?? ''),
            ];
            // Last crumb is the current page: no URL.
            if (!empty($crumb['link']) && $position < count($crumbs)) {
                $item['item'] = (string) $crumb['link'];
            }
            $items[] = $item;
        }

        return [
            '@type'           => 'BreadcrumbList',
            '@id'             => $pageUrl . '#breadcrumb',
            'itemListElement' => $items,
        ];
    }
}

Reading from the layout block rather than the category model is deliberate. It means the markup inherits whatever logic the theme uses to pick a category path, including the client's custom rules, and it means a wrong trail is visible on the page rather than only in the source.

5. Organization: The Node That Earns Nothing And Matters Most

Organization produces no rich result on its own. No stars, no annotation, nothing you can screenshot for a stakeholder. It is also the node I would keep if I had to delete everything else, because it is what connects a domain to an entity, and entity resolution is upstream of a great deal you cannot see.

{
  "@type": "Organization",
  "@id": "https://alderwoodhome.co.uk/#organization",
  "name": "Alderwood Home",
  "legalName": "Alderwood Home Limited",
  "url": "https://alderwoodhome.co.uk/",
  "logo": {
    "@type": "ImageObject",
    "url": "https://alderwoodhome.co.uk/media/logo-600x600.png",
    "width": 600,
    "height": 600
  },
  "image": "https://alderwoodhome.co.uk/media/logo-600x600.png",
  "email": "[email protected]",
  "telephone": "+44-1225-555010",
  "vatID": "GB384920117",
  "iso6523Code": "0060:213456789",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "14 Milsom Street",
    "addressLocality": "Bath",
    "addressRegion": "Somerset",
    "postalCode": "BA1 1DE",
    "addressCountry": "GB"
  },
  "sameAs": [
    "https://www.linkedin.com/company/alderwood-home",
    "https://www.instagram.com/alderwoodhome",
    "https://find-and-update.company-information.service.gov.uk/company/09183774"
  ],
  "contactPoint": [{
    "@type": "ContactPoint",
    "telephone": "+44-1225-555010",
    "contactType": "customer service",
    "areaServed": "GB",
    "availableLanguage": ["English"]
  }]
}

Points worth making about that.

The logo has real requirements. Minimum 112×112 pixels, crawlable, not blocked in robots.txt, and — the one people miss — it should be the logo on a background, not a transparent PNG that renders as black-on-black in a dark context. Square is safest. I keep a dedicated 600×600 asset for this rather than reusing the header SVG, because SVG is not accepted and because the header logo is usually a horizontal lockup that crops badly.

sameAs should be identity, not marketing. Links to profiles that unambiguously belong to this organisation. A Companies House record, a Wikidata item, a verified LinkedIn page are worth more than six social profiles with fourteen followers. Padding the list with every platform you registered a handle on adds noise, and I have no evidence it helps. Three strong links beat nine weak ones.

Do not use LocalBusiness unless you are one. An online-only retailer with a warehouse is not a local business, and marking up an unstaffed unit on an industrial estate with opening hours to try for a map pack listing is both ineffective and the kind of thing that gets a Business Profile suspended. If you have real shops customers can visit, model each shop as its own Store node on its own location page, with the Organization as parentOrganization.

vatID and iso6523Code are new-ish and cheap. Google has been explicit that structured business identifiers help with entity confidence, particularly for the merchant knowledge panels that show alongside shopping results. Two lines of static template. I add them by default now.

This node is identical on every page, which means it is the perfect candidate for the site-wide half of your assembler and the perfect thing to get wrong exactly once and everywhere.

6. WebSite, and the Search Box Google Took Away

The WebSite node has two uses and one of them died in November 2024.

The dead one first, because people are still building it. potentialAction with a SearchAction was the mechanism for the sitelinks search box — the search field Google sometimes rendered under a brand's homepage result, letting a user search your site directly from the SERP.

{
  "@type": "WebSite",
  "@id": "https://alderwoodhome.co.uk/#website",
  "url": "https://alderwoodhome.co.uk/",
  "potentialAction": {
    "@type": "SearchAction",
    "target": {
      "@type": "EntryPoint",
      "urlTemplate": "https://alderwoodhome.co.uk/search?q={search_term_string}"
    },
    "query-input": "required name=search_term_string"
  }
}

Google deprecated the sitelinks search box feature in November 2024 and stopped showing it. The markup is not harmful and the property is still valid vocabulary, but it will not produce the feature, and any agency proposing it as a deliverable in 2026 is either behind or hoping you are. I leave existing implementations in place — removing them costs time and buys nothing — and I do not build new ones.

The live use is duller and better: WebSite as the anchor for site name in search results. Google uses the name property, alongside the og:site_name meta and your title patterns, to decide what to display as the site name above a result. If your results show alderwoodhome.co.uk rather than Alderwood Home, this node is one of the levers. It only works on the homepage — Google reads it from the root document — and it takes weeks to settle.

{
  "@type": "WebSite",
  "@id": "https://alderwoodhome.co.uk/#website",
  "url": "https://alderwoodhome.co.uk/",
  "name": "Alderwood Home",
  "alternateName": "Alderwood",
  "inLanguage": "en-GB",
  "publisher": { "@id": "https://alderwoodhome.co.uk/#organization" }
}

Give alternateName a genuine alternative — an abbreviation people actually use, not a keyword phrase. "Alderwood Home | Lighting & Homeware Bath" is not an alternate name, it is a title tag, and putting it there is the kind of thing that makes Google ignore the field.

7. FAQ and HowTo: What Actually Happened

Between August and September 2023, Google removed two rich results that a great deal of agency work had been built on. It is worth stating precisely what happened, because the misremembered version leads people to either keep building dead markup or conclude that all structured data is a waste.

FAQ rich results — the expandable question list under a search result — were restricted in August 2023 to "well-known, authoritative government and health websites." Not removed from the vocabulary, not penalised, simply no longer shown for the overwhelming majority of sites. A retailer marking up FAQs today gets no feature. The markup is still parsed and there is no evidence it hurts.

HowTo rich results were removed entirely on desktop in September 2023, having already gone from mobile. No exceptions, no authoritative-site carve-out.

Both changes were announced, both were unambiguous, and both are still being sold. I have reviewed proposals in the last year quoting FAQ schema as a route to "double your SERP real estate."

So should you strip existing FAQ markup? Generally no. It costs engineering time, it carries a small regression risk, and there is a reasonable argument — unprovable, but reasonable — that structured question-and-answer pairs help whatever assembles AI overviews and answer boxes. What you should stop doing is treating it as a feature investment or letting it drive content decisions. The pattern where a product page grows six invented questions purely so it can carry FAQ markup was always bad for users and now buys nothing at all.

The durable lesson is about dependency. Anything Google renders, Google can stop rendering, and the announcement will come the same week it happens. Build the markup that describes reality — what this page is, what it sells, who publishes it — and treat rich results as an upside rather than the reason. The nodes that survived 2023 untouched were the descriptive ones.

8. What Is Actually Worth Building In 2026

A blunt table, based on what I currently recommend and what I have watched work.

NodeVisible feature?Build it?
BreadcrumbListYes, breadcrumb trail in resultsAlways
OrganizationNo direct featureAlways — entity confidence
WebSite (name)Site name in resultsYes, homepage only
WebSite (SearchAction)Retired Nov 2024No new builds
WebPageNoOnly as graph connective tissue
Product / OfferYes, price and availabilityAlways, on product pages
FAQPageRestricted since Aug 2023Keep existing, build no more
HowToRemoved Sept 2023No
ItemList (collections)Occasionally, carouselsYes on category pages
Article / BlogPostingModest, in DiscoverYes on editorial
VideoObjectYes, video results and key momentsYes if you have real video
LocalBusiness / StoreYes, with a Business ProfileOnly if you have real premises

The two on that list most often skipped that I would prioritise are ItemList on category pages and VideoObject. Category ItemList is cheap — you are already rendering the products — and it makes explicit that this URL is a list rather than a thing, which reduces the chance of a category page being treated as a product page. VideoObject with hasPart clip markers still produces genuine SERP features for anyone with product video, and almost nobody in ecommerce bothers.

9. Emitting It Exactly Once: The Assembler Pattern

Now the mechanics, which is where the garden distributor's problem actually lived. Three breadcrumbs on one page is not a schema mistake; it is an architecture mistake. The defence is structural: make it impossible for more than one component to write structured data.

The pattern is the same in every stack. One registry that collects nodes during the request. One emitter that runs once, late, and prints the graph. Individual components contribute nodes; none of them can print.

Magento 2

<?php
declare(strict_types=1);

namespace Modracx\Schema\Model;

/**
 * Request-scoped collector. Nodes are keyed by @id, so a second contribution
 * for the same @id replaces rather than duplicates — which is exactly the
 * failure mode we are defending against.
 */
class Graph
{
    private array $nodes = [];

    public function add(array $node): void
    {
        $id = $node['@id'] ?? null;
        if ($id === null) {
            // Anonymous nodes are allowed but cannot be de-duplicated, so they
            // are appended under a synthetic key.
            $this->nodes[] = $node;
            return;
        }
        $this->nodes[$id] = $node;
    }

    public function has(string $id): bool
    {
        return isset($this->nodes[$id]);
    }

    public function isEmpty(): bool
    {
        return $this->nodes === [];
    }

    public function toArray(): array
    {
        return [
            '@context' => 'https://schema.org',
            '@graph'   => array_values($this->nodes),
        ];
    }
}

The emitter is a single block placed in default.xml so it exists on every page, rendering nothing when the graph is empty:

<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      layout="1column"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="before.body.end">
            <block class="Modracx\Schema\Block\GraphEmitter"
                   name="modracx.schema.graph"
                   template="Modracx_Schema::graph.phtml"
                   cacheable="true" />
        </referenceContainer>
    </body>
</page>

Placing it in before.body.end rather than head.additional is a deliberate choice and one I changed my mind about. Head placement feels tidier, but blocks that contribute nodes — the product block, the breadcrumb block — render after the head does, so a head-placed emitter sees an empty graph. Google does not care where in the document the script tag sits. Put it last.

The template is four lines and the escaping in it is the entire security story:

<?php /** @var \Modracx\Schema\Block\GraphEmitter $block */ ?>
<?php if (!$block->isEmpty()): ?>
<script type="application/ld+json">
<?= /* @noEscape */ $block->getJson() ?>
</script>
<?php endif; ?>

And the block's getJson() must escape the sequence that can break out of a script element:

public function getJson(): string
{
    $json = json_encode(
        $this->graph->toArray(),
        JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
    );

    // A product description containing "</script>" would otherwise terminate
    // the block early. Escaping the "<" as a unicode sequence keeps the JSON
    // valid and the HTML intact.
    return str_replace(
        ['</', '<!--'],
        ['\u003C/', '\u003C!--'],
        (string) $json
    );
}

That replacement is not paranoia. I have seen a product description pasted from a supplier's site containing a tracking script, which terminated the JSON-LD block, dumped the rest of the graph as visible text on the page, and produced a support ticket about "weird code on the lamp page." The JSON_UNESCAPED_SLASHES flag makes it more likely, not less, which is why the two go together.

Shopify

Liquid has no request-scoped registry, so the equivalent is a single snippet included once from theme.liquid, which decides internally what to emit based on template.

{%- comment -%}
  snippets/schema-graph.liquid — the ONLY place this theme emits JSON-LD.
  Included once from theme.liquid, immediately before </body>.
{%- endcomment -%}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": {{ shop.url | append: '/#organization' | json }},
      "name": {{ shop.name | json }},
      "url": {{ shop.url | append: '/' | json }}
      {%- if settings.schema_logo %},
      "logo": {
        "@type": "ImageObject",
        "url": {{ settings.schema_logo | image_url: width: 600 | prepend: "https:" | json }}
      }
      {%- endif -%}
    },
    {
      "@type": "WebSite",
      "@id": {{ shop.url | append: '/#website' | json }},
      "url": {{ shop.url | append: '/' | json }},
      "name": {{ shop.name | json }},
      "publisher": { "@id": {{ shop.url | append: '/#organization' | json }} }
    }
    {%- if template contains 'product' -%},
    {%- render 'schema-node-product', product: product -%}
    {%- endif -%}
    {%- if template contains 'collection' -%},
    {%- render 'schema-node-collection', collection: collection -%}
    {%- endif -%}
    {%- if breadcrumbs_list != blank -%},
    {%- render 'schema-node-breadcrumb', crumbs: breadcrumbs_list -%}
    {%- endif -%}
  ]
}
</script>

The comma placement in that template is genuinely fiddly and it is where these break. My rule: every conditional node opens with its own leading comma inside the if, and the last unconditional node never has a trailing one. Get it wrong and you emit invalid JSON that no validator catches until you look, because Search Console reports it as "Parsing error: Missing ',' or '}'" three days later with no indication of which template.

The harder Shopify problem is apps. A review app, a currency converter, and a "rich snippets" app will each inject their own JSON-LD via script tag, and you cannot stop them from the theme. What you can do is audit after every install and disable their schema output in their settings, which most of them offer and none of them default to off. Put "check for duplicate ld+json" in whatever passes for your app installation checklist.

Next.js and headless

Server components make this clean. One function per node type, one assembler per route, rendered server-side into the document.

// lib/schema/graph.ts
type Node = Record<string, unknown> & { '@id'?: string };

export function buildGraph(nodes: (Node | null | undefined)[]) {
  const seen = new Map<string, Node>();
  const anonymous: Node[] = [];

  for (const node of nodes) {
    if (!node) continue;
    const id = node['@id'];
    if (typeof id === 'string') {
      // Last contribution wins, matching the server-rendered pattern.
      seen.set(id, node);
    } else {
      anonymous.push(node);
    }
  }

  return {
    '@context': 'https://schema.org',
    '@graph': [...seen.values(), ...anonymous],
  };
}

// The one and only serialiser. Escaping "</" prevents a description
// containing a closing script tag from breaking out of the block.
export function graphScript(graph: unknown): string {
  return JSON.stringify(graph).replace(/<\//g, '\\u003c/');
}
// app/products/[handle]/page.tsx
import { buildGraph, graphScript } from '@/lib/schema/graph';
import { organizationNode, websiteNode, breadcrumbNode, webPageNode } from '@/lib/schema/nodes';
import { productSchema } from '@/lib/schema/product';

export default async function ProductPage({ params }: { params: { handle: string } }) {
  const product = await getProduct(params.handle);
  const url = `https://alderwoodhome.co.uk/products/${params.handle}`;

  const graph = buildGraph([
    organizationNode(),
    websiteNode(),
    webPageNode({ url, name: product.title }),
    breadcrumbNode(url, product.breadcrumbs),
    productSchema(product),
  ]);

  return (
    <>
      <script
        type="application/ld+json"
        // Server-rendered, so it is present in the initial HTML rather than
        // written after hydration — which is the difference between a crawler
        // seeing it reliably and seeing it sometimes.
        dangerouslySetInnerHTML={{ __html: graphScript(graph) }}
      />
      <ProductView product={product} />
    </>
  );
}

The dangerouslySetInnerHTML is unavoidable and is the reason the escaping in graphScript is not optional. React will not escape inside it, which is the point — JSON escaped as HTML is not valid JSON — so the </ replacement is your only defence.

One thing I would warn about specifically on Next.js: if you generate structured data inside a client component, or inside a component below a Suspense boundary that streams late, you are betting on Google's renderer. It usually pays out. It pays out less reliably on a large catalogue with a constrained render budget, and when it fails it fails silently across thousands of URLs. Server-render the graph.

10. Content Security Policy and the Ways a Block Gets Mangled

A few environmental things break JSON-LD in ways that look like markup bugs.

CSP. A script-src policy without unsafe-inline and without a nonce blocks inline scripts — but application/ld+json is a data block, not executable script, and browsers do not execute it. Chrome does not block it under script-src. Some scanners will still flag it and some over-eager implementations add a nonce anyway, which is harmless. What is not harmless is a CSP report-only rollout that causes someone to "fix" the JSON-LD by moving it to an external file. Google does fetch external JSON-LD referenced correctly, but the pattern is fragile and the original problem did not exist. If you are working through a policy rollout, the CSP implementation article covers what actually needs a nonce.

HTML minifiers. Some strip whitespace inside script elements of unknown type, some do not, and a few older ones will remove what they think are comments. Test your minifier against a JSON-LD block containing a string with a double space in it.

CDN transforms. Cloudflare's automatic minification, Rocket Loader, and various "optimisation" features have all at some point interfered with inline scripts. Rocket Loader in particular has been observed adding type="text/rocketscript" to script tags, which changes the type and makes the block invisible to Google. Check the delivered HTML from the edge, not the origin.

Consent management. The worst one. A consent platform in "block until consent" mode that operates by rewriting all script tags will rewrite yours. Googlebot does not consent to anything, so it sees the blocked version. I have seen a full catalogue lose product markup this way, and the symptom in Search Console — everything valid one week, everything gone the next, no deploy — looks exactly like a Google-side change.

11. Validation That Runs Without You

Manual validation catches the bug you already suspect. What you want is a check that catches the bug nobody is looking for, which means it runs on every build.

Three layers, cheapest first.

Unit tests on the serialiser

If the graph comes from one assembler, you can test it with fixtures and no network. This is by far the highest-value check per unit of effort.

import { describe, it, expect } from 'vitest';
import { buildGraph } from '@/lib/schema/graph';
import { breadcrumbNode } from '@/lib/schema/nodes';

describe('graph assembly', () => {
  it('never emits two nodes with the same @id', () => {
    const url = 'https://example.com/p/x';
    const graph = buildGraph([
      breadcrumbNode(url, [{ name: 'Home', url: 'https://example.com/' }]),
      breadcrumbNode(url, [{ name: 'Sale', url: 'https://example.com/sale/' }]),
    ]);
    const ids = graph['@graph'].map((n: any) => n['@id']);
    expect(new Set(ids).size).toBe(ids.length);
  });

  it('numbers breadcrumb positions from 1 with no gaps', () => {
    const node: any = breadcrumbNode('https://example.com/p/x', [
      { name: 'Home', url: 'https://example.com/' },
      { name: 'Lighting', url: 'https://example.com/lighting/' },
      { name: 'Classic Oak Table Lamp' },
    ]);
    const positions = node.itemListElement.map((i: any) => i.position);
    expect(positions).toEqual([1, 2, 3]);
  });

  it('omits item on the final crumb', () => {
    const node: any = breadcrumbNode('https://example.com/p/x', [
      { name: 'Home', url: 'https://example.com/' },
      { name: 'Classic Oak Table Lamp' },
    ]);
    expect(node.itemListElement.at(-1)).not.toHaveProperty('item');
  });
});

Extraction against rendered pages

The unit tests prove the serialiser is right. They do not prove the output reached the page, survived the minifier, or is the only block present. That needs a fetch.

#!/usr/bin/env python3
"""Fail the build if a page emits duplicate or malformed structured data."""
import json
import re
import sys
import urllib.request

BLOCK = re.compile(
    r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
    re.S | re.I,
)

def nodes_for(url):
    with urllib.request.urlopen(url, timeout=20) as r:
        html = r.read().decode("utf-8", "replace")
    out = []
    for raw in BLOCK.findall(html):
        try:
            data = json.loads(raw)
        except json.JSONDecodeError as e:
            raise SystemExit(f"FAIL {url}: unparseable JSON-LD block: {e}")
        # A block is either a node, a list of nodes, or a @graph wrapper.
        if isinstance(data, dict) and "@graph" in data:
            out.extend(data["@graph"])
        elif isinstance(data, list):
            out.extend(data)
        else:
            out.append(data)
    return out

def check(url, required):
    nodes = nodes_for(url)
    types = [n.get("@type") for n in nodes]
    problems = []

    for t in required:
        count = types.count(t)
        if count == 0:
            problems.append(f"missing {t}")
        elif count > 1:
            problems.append(f"{count}x {t} (should be 1)")

    for n in nodes:
        if n.get("@type") == "BreadcrumbList":
            got = [i.get("position") for i in n.get("itemListElement", [])]
            if got != list(range(1, len(got) + 1)):
                problems.append(f"breadcrumb positions {got}")

    if problems:
        print(f"FAIL {url}: " + "; ".join(problems), file=sys.stderr)
        return 1
    print(f"ok   {url}: {', '.join(sorted(set(types)))}")
    return 0

BASE = sys.argv[1].rstrip("/")
failures = 0
failures += check(f"{BASE}/", ["Organization", "WebSite"])
failures += check(f"{BASE}/lighting/table-lamps/", ["BreadcrumbList", "ItemList"])
failures += check(f"{BASE}/products/oak-lamp-classic", ["BreadcrumbList", "Product"])
sys.exit(1 if failures else 0)

Run it against a preview deployment in CI and against production on a schedule. The scheduled run is the one that catches app installs, CDN setting changes, and consent-platform updates — none of which come through your pipeline.

The Rich Results Test, deliberately

Google's own tool is the only thing that tells you about eligibility rather than validity, and it is not automatable at any sensible volume. Use it as a gate on template changes rather than a continuous monitor: when the product template changes, test one product URL live before merging. That is enough.

12. Monitoring In Search Console Without Drowning

Search Console's enhancement reports are the only view you get of what Google actually extracted at scale, and reading them well is a skill.

Each feature is a separate report — Breadcrumbs, Merchant listings, Product snippets, Review snippets, Videos, Sitelinks searchbox for as long as it lingers. They fail independently. A site can have perfect breadcrumbs and zero valid merchant listings, and the summary page will not make that obvious.

What the reports are good at: the shape of a failure. Read the item count against the total. Thousands of items failing identically is one code change and one fix. Forty items failing is bad data in forty products, and the export button gives you the list. A slow climb over weeks is usually content drift — products being added without a required attribute — and the fix is validation at the point of entry, not markup.

What they are bad at: timing and precision. Data lags by two to four days, the reports sample, and "Valid" means "we extracted it," not "we showed it." I have watched people chase a 3% wobble in valid items that was entirely recrawl scheduling.

Two habits worth forming. First, record the valid and invalid counts weekly in a spreadsheet, because the in-tool history is limited and you will want a year of it during an argument. Second, set the comparison to the day of any template deploy so you can see whether the line moved. The API makes both trivial to automate:

# Weekly snapshot of the breadcrumb enhancement report, appended to a CSV.
# Requires an OAuth token with webmasters.readonly scope.
curl -sS \
  -H "Authorization: Bearer ${GSC_TOKEN}" \
  -H "Content-Type: application/json" \
  "https://searchconsole.googleapis.com/webmasters/v3/sites/${SITE}/searchAnalytics/query" \
  -d '{
    "startDate": "'"$(date -d '28 days ago' +%F)"'",
    "endDate": "'"$(date -d 'yesterday' +%F)"'",
    "dimensions": ["page"],
    "rowLimit": 25000
  }' > gsc-pages.json

python3 - <<'PY' >> schema-history.csv
import json, datetime
rows = json.load(open("gsc-pages.json")).get("rows", [])
products = [r for r in rows if "/products/" in r["keys"][0]]
clicks = sum(r["clicks"] for r in products)
impressions = sum(r["impressions"] for r in products)
ctr = clicks / impressions if impressions else 0
print(f"{datetime.date.today()},{len(products)},{clicks},{impressions},{ctr:.4f}")
PY

The reason I track product-page CTR alongside enhancement validity is that validity is the input and CTR is the outcome. A week where valid items go up and CTR does not is a week where you built markup Google chose not to use, and knowing that early stops you building more of it.

13. A Worked Consolidation, With Numbers

The garden distributor. Magento 2.4.6, roughly 18,000 SKUs across 340 categories, about £6.8m annual revenue, a heavily customised theme and eleven third-party extensions.

What we found. Nine distinct sources of JSON-LD across the site. Three breadcrumb emitters as described. Two Organization nodes with different names — one from the theme using the store name, one from an extension using a value someone had typed into a config field in 2021 and misspelled. A WebSite node with a SearchAction pointing at a search URL that had been changed during a replatform and 404'd. FAQ markup on 1,100 category pages, generated from a template that produced the same six questions with the category name substituted in. And, my favourite, a LocalBusiness node on every page marking their distribution warehouse as a shop with opening hours, added by an agency chasing a map pack listing for a business that did not sell to the public.

Baseline. Breadcrumbs report: 16,200 valid, 1,800 invalid, and the valid ones were largely wrong in content rather than structure — which no report will ever tell you. Product-page CTR: 2.8%. Site name in results: showing as the bare domain rather than the brand.

Week one — inventory. Not code. A spreadsheet of every extension and template that emitted application/ld+json, found by grepping the codebase and then fetching twenty representative URLs and diffing what actually appeared. The grep found seven sources; the fetch found nine. The two extra were injected by JavaScript from apps whose code lived in a CDN bundle.

Weeks two and three — the assembler. Built the registry and emitter above, moved breadcrumb, organisation, website and webpage nodes into it, and disabled schema output in six extensions via their own configuration. Two extensions had no such setting and were patched with a plugin that returned an empty string from their block. That is uglier than I would like and it is what was available.

Week four — the deletions. Removed the LocalBusiness node, the SearchAction, and the templated FAQ markup. The FAQ removal was an argument: it had been sold to them as a major win and there was a slide deck. What settled it was pulling up Google's August 2023 announcement and then checking the SERP for ten of their category terms, where no competitor showed FAQ results either. Removing markup nobody sees is not a loss, but you have to show people rather than tell them.

What went wrong. Two things, and the first was mine. The emitter went into head.additional in the first deploy, which meant it rendered before the product block contributed its node, so for four days every product page emitted a graph containing organisation, website and breadcrumb but no product. Search Console's Merchant listings report dropped by 14,000 items and I got a very reasonable phone call. Moving it to before.body.end fixed it, and a smoke test that asserted a Product node on a product URL would have caught it before the deploy. That test exists now.

The second: we did not check the breadcrumb trail against merchandising expectations before shipping. The correct primary category for about 400 products was genuinely contested — a robotic mower is arguably in Mowers or in Robotic, and different people in the business had different views. We picked the deepest category in the URL path, which was defensible and which surfaced a disagreement that had been latent for years. It cost two meetings that should have happened in week one.

Results at week ten. Breadcrumbs report: 17,850 valid, 150 invalid, and correct in content. Product-page CTR: 3.4%, from 2.8%, against roughly flat impressions — which on their volume was about 34,000 additional sessions a quarter. Site name resolved to the brand roughly three weeks after the WebSite node consolidated. Total JSON-LD payload per product page fell from 41KB to 6KB, which was not a goal but is not nothing on mobile.

Effort. Eleven engineering days over six weeks. Four of those were the extension patching, which is the tax you pay for a codebase with eleven extensions.

14. The Failures I See Most Often

More than one node of a type per page. Almost always from layering: theme plus extension plus bespoke. The assembler pattern is the structural fix; the diagnostic is fetching the URL and counting.

Breadcrumb trails that do not match the URL. Usually a template reusing the wrong category source. Visible only if you read the JSON.

@id values that are not URLs. "@id": "organization" is valid JSON-LD and useless for resolution. Use absolute URLs with fragments.

Organisation name disagreeing with itself. Between the Organization node, og:site_name, the WebSite name, and the title tag suffix. Pick one string and use it in all four.

Client-side injection on a large catalogue. Works in the Rich Results Test, which renders patiently, and fails at scale where render budget is finite.

Markup for content that is not on the page. A templated FAQ block that renders no visible questions. This is the one that attracts manual actions rather than merely being useless.

Absolute URLs pointing at the wrong host. Staging URLs baked into @id values, or http where the site is https. Both survive validation and both break resolution.

15. Questions I Get Asked

"Do we need @graph, or are separate blocks fine?" Separate blocks are fine for Google. The graph is better for you, because it forces a single assembly point and single assembly is what prevents duplicates. I choose it for the engineering property, not for a ranking one.

"Should we remove our FAQ schema?" Not urgently. It produces no feature and costs nothing to leave, unless the FAQ content itself was written purely for the markup and is not visible on the page — in which case remove the content and the markup together, because that combination is a policy problem rather than a wasted opportunity.

"Our extension adds schema. Is that good enough?" Sometimes, if it is the only source and you have checked what it emits. The problem is never the first extension; it is the third. Audit what is actually on the page rather than trusting the feature list.

"Can structured data get us into AI overviews?" There is no documented mechanism and I would not sell it as one. What I will say is that the descriptive nodes — who publishes this, what is this page, what does it sell — are the ones that have survived every feature withdrawal, and they are cheap. Building them because they might help downstream is defensible. Building them because someone promised an AI overview is not.

"How do we stop apps adding their own?" On Shopify, disable it in each app's settings and re-check after every install; there is no platform-level control. On Magento, the config setting if it exists, a plugin returning empty if it does not. On a headless build, you control everything, which is the strongest argument for headless I can make on this particular topic.

"The Rich Results Test passes but Search Console shows errors." They test different things at different times. The test fetches now with a patient renderer; Search Console reports what was extracted at the last crawl, possibly weeks ago, possibly by a renderer under budget pressure. If the test passes today and the report is bad, wait for recrawl before changing anything.

"Does more structured data mean better rankings?" No. It has never meant that, and the volume of markup on a page is not a quality signal. A page with a correct Product node and a correct breadcrumb beats a page with fourteen node types describing things nobody can see.

"Should the JSON-LD be in the head or the body?" Google does not care. Put it wherever your architecture can guarantee it renders after everything that contributes to it, which in most server-rendered stacks means the end of the body.

16. What I Would Do First

Inheriting a site tomorrow, in this order:

One. Fetch five representative URLs — homepage, category, product, blog post, contact page — and count the application/ld+json blocks in each. Not in the codebase. In the delivered HTML, from the edge, with JavaScript disabled and then with it enabled. The difference between those two numbers is itself a finding.

Two. Read every node you find, by eye, once. It is tedious and it takes an hour and it is where the misspelled organisation name and the warehouse-as-a-shop live. No tool will flag either.

Three. Check the breadcrumb trail on ten products against their URLs and their visible trail. Any mismatch is a bug with a real SERP consequence.

Four. Consolidate to one emitter before improving anything. Adding a correct node to a page that already has two wrong ones does not help. Delete first, then build.

Five. Get Organization right and identical everywhere: one name, one logo asset that meets the size rules, a short honest sameAs list, and identifiers if you have them.

Six. Add breadcrumbs everywhere they are missing, sourced from the same data as the visible trail.

Seven. Put the extraction check in CI and on a schedule, asserting node counts and breadcrumb positions on three URL types. This is the step that stops you doing all of the above again in eighteen months.

Eight. Only then look at the product node itself — offers, availability, ratings, merchant listing extensions — which is a deep enough subject to have its own article. It is the highest-value markup on an ecommerce site and it is worth nothing if the page around it is emitting three contradictory descriptions of where that product sits.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: