Skip to content

SEO Structured Data

Badger puts schema.org structured data (JSON-LD) in the <head> of storefront pages so search engines can identify the site, its owner and its search box, and show breadcrumb trails in results.

Markup Where Built by
WebSite + Organization (+ SearchAction) Every storefront page: home, CMS pages, collections, products, search, basket and checkout SiteStructuredDataService
BreadcrumbList Product and collection pages (Nova family) BreadcrumbJsonLdRenderer

This page covers the site-wide graph. Breadcrumbs are covered by the breadcrumb fragment (templates/nova/fragment/breadcrumb.html).

Site-wide graph

Each page carries one <script type="application/ld+json"> holding an @graph with two nodes that point at each other by @id:

  • WebSite (<siteUrl>#website): the site name, its canonical URL, and publisher pointing at the Organization. When search is available it also has a potentialAction SearchAction, which is what Google uses for the sitelinks search box.
  • Organization (<siteUrl>#organization): the name, the URL, and then each of the following that is set:
    • logo
    • sameAs: the social profiles
    • legalName, address, identifier (company number) and vatID

Example, for a site with search available, a logo, two social accounts and legal entity details:

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "WebSite",
      "@id": "https://acme.example.com#website",
      "name": "Acme Outdoors",
      "url": "https://acme.example.com",
      "publisher": { "@id": "https://acme.example.com#organization" },
      "potentialAction": {
        "@type": "SearchAction",
        "target": {
          "@type": "EntryPoint",
          "urlTemplate": "https://acme.example.com/search?q={search_term_string}"
        },
        "query-input": "required name=search_term_string"
      }
    },
    {
      "@type": "Organization",
      "@id": "https://acme.example.com#organization",
      "name": "Acme Outdoors",
      "url": "https://acme.example.com",
      "logo": "https://cdn.example.com/media/logo.png",
      "sameAs": [
        "https://www.facebook.com/acmeoutdoors",
        "https://www.instagram.com/acmeoutdoors"
      ],
      "legalName": "Acme Outdoors Limited",
      "address": "1 High Street, Buxton, SK17 6AA",
      "identifier": { "@type": "PropertyValue", "propertyID": "Company number", "value": "01234567" },
      "vatID": "GB123456789"
    }
  ]
}

Where each value comes from

Property Source
url, the @ids https:// + the site's primary domain (domainURL). Other domains are not used, because they redirect to the primary one.
name The site name (Site Identity).
logo The site logo (Site Identity). CDN URLs that start with // get https:, and site-relative paths get the site URL.
sameAs The social accounts (Site Identity). A bare handle becomes a profile URL using the same prefixes as the Social Links extension (Facebook, Instagram, Twitter/X, LinkedIn company, YouTube channel, Pinterest, TikTok). A value that is already a URL is used as it is. A handle for an unknown network is dropped.
legalName legal-entity-name, otherwise giftaid-org-name, so a charity that has set up Gift Aid doesn't have to enter its name twice.
address legal-entity-registered-address. It is stored as one line, so it is output as text and not split into a PostalAddress.
identifier legal-entity-company-number.
vatID legal-entity-vat-number.

The legal entity keys are the ones an admin confirms for Subject Access Requests (see Subject Access Requests).

When the SearchAction appears

The /search?q= page is always mapped, but its results come from Typesense. The SearchAction is therefore added only when Typesense is configured for the site, using the same check (TypesenseClientFactory.isConfigured) the indexer uses. A site that has explicitly set useSearch to false is treated as having opted out. The default value of useSearch is ignored, because the Nova and Brock themes don't read that key.

Configuration

Key Type Default Category Effect
structured-data-enabled Boolean true seo-config Turn it off to remove the WebSite/Organization graph from every page, for example on a site that adds its own Organization markup. Breadcrumbs are not affected.

Changes appear within five minutes. The graph is cached per site and catalogue in the per-pod in-memory cache.

How it is wired

SiteStructuredDataService (commerce-core, seo package)
  builds the graph, serialises it with the app's ObjectMapper, escapes it, caches it per site
        |
SiteStructuredDataInterceptor (web-mvc, registered in WebMvcConfiguration)
  postHandle: puts it on every rendered view as `siteStructuredDataJsonLd`
        |
templates/bootstrap/fragment/structuredData.html :: siteJsonLd
  prints it; included from fragment/head.html in nova, brock, pop, depot, spec, atelier and bootstrap
  • Built in Java. Templates only print the attribute. They never build JSON.
  • Escaped for <script>. After serialising, the service writes <, > and & as <, > and &. These characters only occur inside JSON strings, where the escapes mean the same thing, so a site name such as </script> can't close the element. This is why the fragment can use th:utext.
  • One fragment for every theme. The fragment is in the bootstrap theme, the last leg of template resolution, so every theme picks it up. Each theme's head has a single th:replace line for it.
  • An interceptor, not an extension. It covers every page, including those rendered outside the extension pipeline, without each site having to place an extension. Like StorefrontChromeInterceptor, it skips redirects and leaves the attribute off if anything fails. It never fails a page.
  • Not on error pages. Exception-handler views don't run interceptor postHandle, so error pages have no site graph.

Testing

  • SiteStructuredDataServiceImplTest: the graph with search on and off (including an explicit useSearch=false), social accounts to sameAs, missing logo, legal entity details and the Gift Aid fallback, the disabled flag, a missing domain, and </script> escaping.
  • SiteStructuredDataInterceptorTest: adds the attribute, skips redirects and views without a model, and never fails a page.
  • SiteStructuredDataHeadRenderTest: renders each theme's real fragment/head and checks the script is inside <head>, exactly once.

Check a live page with Google's Rich Results Test or the Schema Markup Validator.