— TRADES · SCHEMA

Schema graphs for trades: the one-graph-per-page pattern

How to wire LocalBusiness, Service, Review, and Person schema into one @id-connected JSON-LD graph that wins Google rich results and AI citations.

Picture two roofing contractors shipping identical-looking service pages in the same zip code. Same hero photo style. Same “Get a free quote” CTA. Same five trust badges. Both get the same Google Lighthouse score. In that scenario, one keeps showing up in Perplexity recommendations and Google’s AI Overviews for “roof replacement” queries while the other doesn’t, and nobody on the losing team can figure out why. The difference is almost always in the JSON-LD. The first site emits one connected schema graph per page with @id-linked entities. The second emits five loose schema blocks that look right in isolation and tell the AI engine nothing about how the entities relate.

This pillar is the JSON-LD reference for trades businesses. It covers the structural pattern (one graph per page, not loose blocks), the subtype selection that pins your business to the right Schema.org branch, the node-by-node walkthrough of what a complete trades-business graph contains, and the validation toolchain that catches the silent errors. Code samples are real and copy-pasteable. The trade names in the examples are interchangeable. Pick the one closest to your business and substitute.

One graph, not loose blocks — the structural mistake most trades sites make

The default WordPress + Yoast pattern, and the default for most page-builder sites, is multiple <script type="application/ld+json"> blocks scattered across the page. Yoast emits one for Organization, the theme emits another for WebSite, a plugin emits a third for Article, and a review-import plugin emits a fourth for AggregateRating. Each block is technically valid. Each entity is well-formed. The page passes the Rich Results Test.

The problem is that the four entities don’t know about each other. A search crawler reading the page sees four separate islands. It has no way to know that the Article was published by the same Organization declared in the first block, that the AggregateRating belongs to the same business, or that the WebSite’s publisher is the Organization. To the crawler, your page describes four loosely-related entities, not one coherent business.

The fix is the @graph pattern. A single <script type="application/ld+json"> block in the page’s <head> wraps everything in a @graph array, and every entity inside the array gets a stable @id that other entities reference instead of duplicating data:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "RoofingContractor",
      "@id": "https://watson-roofing.com/#org",
      "name": "Watson Roofing Co."
    },
    {
      "@type": "WebSite",
      "@id": "https://watson-roofing.com/#website",
      "url": "https://watson-roofing.com",
      "publisher": { "@id": "https://watson-roofing.com/#org" }
    },
    {
      "@type": "WebPage",
      "@id": "https://watson-roofing.com/services/replacement/#webpage",
      "url": "https://watson-roofing.com/services/replacement/",
      "isPartOf": { "@id": "https://watson-roofing.com/#website" },
      "about": { "@id": "https://watson-roofing.com/services/replacement/#service" }
    }
  ]
}
</script>

Three nodes, three @ids, two cross-references. The WebSite.publisher points to #org instead of repeating the RoofingContractor data. The WebPage.isPartOf points to #website instead of repeating the WebSite data. The crawler now reads this as one connected entity graph instead of three disconnected ones, and every page across the site that uses the same #org and #website @ids contributes to the same coherent graph.

That last point matters more than the rest of this section combined. Sitewide entities like Organization and WebSite keep the same @id across every page on the site. Page-specific entities like WebPage and Article include the page path in the @id so they’re unique per URL. That convention is what lets Google and the AI engines deduplicate your business correctly across hundreds of crawled pages.

Pick the deepest LocalBusiness subtype Schema.org offers for your trade

Schema.org’s LocalBusiness is the root type for any place-based business. Underneath it sits HomeAndConstructionBusiness, which is the branch trades businesses live on. Underneath that sit the named subtypes that match specific trades:

TradeSchema.org typeInheritance chain
Roofing contractorRoofingContractorLocalBusiness > HomeAndConstructionBusiness > RoofingContractor
PlumberPlumberLocalBusiness > HomeAndConstructionBusiness > Plumber
ElectricianElectricianLocalBusiness > HomeAndConstructionBusiness > Electrician
HVACHVACBusinessLocalBusiness > HomeAndConstructionBusiness > HVACBusiness
General contractorGeneralContractorLocalBusiness > HomeAndConstructionBusiness > GeneralContractor
PainterHousePainterLocalBusiness > HomeAndConstructionBusiness > HousePainter
Moving companyMovingCompanyLocalBusiness > HomeAndConstructionBusiness > MovingCompany
LocksmithLocksmithLocalBusiness > HomeAndConstructionBusiness > Locksmith

A trade that doesn’t have a named subtype (drywall, masonry, gutter specialists, foundation repair, etc.) inherits the next level up. Use HomeAndConstructionBusiness for those. Don’t fall back further to bare LocalBusiness, and definitely don’t use plain Organization. The whole point of the subtype hierarchy is the tighter topical signal it sends to crawlers.

The full RoofingContractor node, in production form:

{
  "@type": "RoofingContractor",
  "@id": "https://watson-roofing.com/#org",
  "name": "Watson Roofing Co.",
  "image": "https://watson-roofing.com/images/logo.png",
  "url": "https://watson-roofing.com",
  "telephone": "+1-225-555-0100",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "1234 Main Street",
    "addressLocality": "Watson",
    "addressRegion": "LA",
    "postalCode": "70786",
    "addressCountry": "US"
  },
  "geo": {
    "@type": "GeoCoordinates",
    "latitude": 30.5089,
    "longitude": -90.9123
  },
  "openingHoursSpecification": [
    {
      "@type": "OpeningHoursSpecification",
      "dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
      "opens": "07:00",
      "closes": "18:00"
    }
  ],
  "areaServed": [
    { "@type": "City", "name": "Watson" },
    { "@type": "City", "name": "Baton Rouge" },
    { "@type": "City", "name": "Denham Springs" }
  ],
  "priceRange": "$$",
  "knowsAbout": [
    "Asphalt shingle roof replacement",
    "Metal roofing systems",
    "Storm-damage insurance claims"
  ],
  "sameAs": [
    "https://g.page/r/CXXX...",
    "https://www.facebook.com/watsonroofing",
    "https://www.linkedin.com/company/watson-roofing",
    "https://www.bbb.org/us/la/watson/profile/.../"
  ]
}

Schema.org’s own description of what a LocalBusiness is, verbatim:

A particular physical business or branch of an organization. Examples of LocalBusiness include a restaurant, a particular branch of a restaurant chain, a branch of a bank, a medical practice, a club, a bowling alley, etc.

Schema.org , official type definition for LocalBusiness

The phrase “particular physical business” is doing the work in that definition. A roofer with no fixed shopfront, who operates out of a garage and meets customers at the job site, is still a particular physical business with a verifiable address. That qualifies for LocalBusiness and the subtype hierarchy. A purely-remote consulting practice is not a LocalBusiness. Different schema scope. Different rich-results entitlements.

The @id is the wire that connects every node in your graph

@id is the JSON-LD property that gives an entity a stable identifier. Other nodes in the graph reference that identifier instead of duplicating data, and crawlers across the web merge entities that share the same @id. Get the @id convention right and every page on your site reinforces a single coherent business entity. Get it wrong and you fragment your business into orphan nodes that Google and the AI engines can’t connect.

Three rules cover the discipline:

  1. Use IRI form with a #fragment. The @id should look like a URL: https://watson-roofing.com/#org. The #org fragment names the entity within the page. Common fragments: #org, #website, #webpage, #article, #person, #service, #breadcrumbs.

  2. Sitewide entities keep the same @id everywhere. The RoofingContractor node on your homepage, your service pages, your about page, and every blog post all share @id: "https://watson-roofing.com/#org". That’s how the entity stays singular across the crawl.

  3. Page-specific entities include the page path. A WebPage node uses @id: "https://watson-roofing.com/services/replacement/#webpage". An Article node uses @id: "https://watson-roofing.com/blog/roof-lifespan/#article". The page path makes each @id unique per URL.

Cross-references inside the graph point to @ids instead of repeating data:

{
  "@type": "Article",
  "@id": "https://watson-roofing.com/blog/roof-lifespan/#article",
  "headline": "How long does an asphalt roof actually last?",
  "author":    { "@id": "https://watson-roofing.com/team/mike-watson/#person" },
  "publisher": { "@id": "https://watson-roofing.com/#org" },
  "isPartOf":  { "@id": "https://watson-roofing.com/blog/roof-lifespan/#webpage" }
}

Three references, zero duplicated data. The Article knows its author is the Person declared elsewhere in the graph, its publisher is the sitewide RoofingContractor, and it belongs to the WebPage declared in the same graph. The crawler walks those references and reconstructs the relationships without any guesswork.

Service nodes — one per offering, linked back to your business

Every distinct service your business offers should emit its own Service node. A roofer offering replacement, repair, gutter installation, and emergency tarp service emits four Service nodes. Each one links back to the RoofingContractor via provider:

{
  "@type": "Service",
  "@id": "https://watson-roofing.com/services/replacement/#service",
  "serviceType": "Roof replacement",
  "name": "Asphalt shingle roof replacement",
  "description": "Full tear-off and replacement of asphalt shingle roofs in Greater Baton Rouge. Includes decking inspection, ice and water shield, GAF Timberline HDZ shingles, 50-year manufacturer warranty.",
  "provider": { "@id": "https://watson-roofing.com/#org" },
  "areaServed": [
    { "@type": "City", "name": "Watson" },
    { "@type": "City", "name": "Baton Rouge" }
  ],
  "offers": {
    "@type": "Offer",
    "priceSpecification": {
      "@type": "PriceSpecification",
      "minPrice": 7500,
      "maxPrice": 18000,
      "priceCurrency": "USD"
    }
  }
}

The provider reference is the load-bearing part. Without it, the Service node is an orphan. With it, the AI engine knows that this specific roof-replacement offering is provided by the Watson Roofing entity declared in the same graph, which in turn has a verified address, a sameAs array linking to BBB and LinkedIn, and an aggregateRating from real Google reviews. The whole chain of credibility flows from the Service back to the RoofingContractor through the single @id reference.

Service schema also matters for AI Overviews specifically. When Google’s AI Overview is summarizing options for “roof replacement Cleveland,” it pulls candidate businesses by querying the entity graph for RoofingContractor (or subtype) entities with Service nodes whose serviceType matches the query. A roofer with four well-formed Service nodes shows up four ways. A roofer with no Service nodes at all only shows up through the parent business entity, which is a weaker match.

Person + worksFor — how to schema-credit your crew and leadership

Trades businesses get a credibility boost from named, verifiable team members. The Person schema is how you give a crew member, a foreman, or a leadership team member their own entity in the graph. The relevant properties for a trades-business team member:

{
  "@type": "Person",
  "@id": "https://watson-roofing.com/team/mike-watson/#person",
  "name": "Mike Watson",
  "jobTitle": "Owner & Lead Estimator",
  "image": "https://watson-roofing.com/team/mike-watson.jpg",
  "worksFor": { "@id": "https://watson-roofing.com/#org" },
  "knowsAbout": [
    "Insurance claim adjudication",
    "Storm-damage assessment",
    "Asphalt shingle systems"
  ],
  "hasCredential": [
    {
      "@type": "EducationalOccupationalCredential",
      "name": "GAF Master Elite Certified Contractor",
      "credentialCategory": "Manufacturer certification"
    }
  ],
  "sameAs": [
    "https://www.linkedin.com/in/mike-watson-roofing/",
    "https://www.facebook.com/mike.watson.roofing"
  ]
}

Two properties are doing most of the work. worksFor links the person back to the business entity, so the crawler knows this is a team member and not an unrelated Person. knowsAbout lists the topics this person has expertise in, which the AI engine uses for topic matching when deciding whose content to cite.

The hasCredential array is the trades-specific power move. Master Elite from GAF, Owens Corning Platinum Preferred, NRCA membership, OSHA certifications, state contractor licenses, anything a customer or AI engine could independently verify. Each credential becomes a topical signal. A roofer whose owner has hasCredential: ["GAF Master Elite"] outranks a roofer with no credentials on queries where the AI is evaluating expertise depth.

One Person node per real team member who appears publicly on the site. Leadership goes first. Crew foremen who appear in case studies or guides come next. The whole roster doesn’t need schema, just the named individuals the customer might evaluate.

Review vs Quotation — the schema choice nobody gets right

The two schemas for embedding testimonials look interchangeable. They aren’t. They feed different surfaces and serve different goals.

Review is Google’s preferred type for the rich-results star-rating snippet in the SERP. It powers the in-result star display and feeds the aggregateRating calculation. The required properties are minimal but strict:

{
  "@type": "Review",
  "@id": "https://watson-roofing.com/services/replacement/#review-sarah-m",
  "itemReviewed": { "@id": "https://watson-roofing.com/#org" },
  "author": { "@type": "Person", "name": "Sarah M." },
  "datePublished": "2026-04-12",
  "reviewRating": {
    "@type": "Rating",
    "ratingValue": "5",
    "bestRating": "5",
    "worstRating": "1"
  },
  "reviewBody": "Watson Roofing replaced our roof in two days. The crew showed up at 7am sharp, cleaned up everything before leaving, and we came in $400 under the original estimate."
}

Quotation is the schema AI engines reach for when they’re pulling verbatim text into a recommendation answer. It carries the quote text, the creator (the person who said it), and a citation link back to the verifiable source. Schema.org’s intent for Quotation is broader than testimonials, but it’s the right type for any attributable verbatim text you want an AI to extract:

{
  "@type": "Quotation",
  "text": "Watson Roofing replaced our roof in two days. The crew showed up at 7am sharp, cleaned up everything before leaving, and we came in $400 under the original estimate.",
  "creator": { "@type": "Person", "name": "Sarah M." },
  "citation": "https://g.page/r/CXXX..."
}

For a high-value testimonial that you want both ways (SERP star snippet + AI citation), emit both. They don’t conflict, they reference the same underlying quote text, and the dual surface increases your odds of getting picked up in either context. For routine reviews you’re displaying in a slider widget, Review alone is usually enough.

Where aggregateRating actually goes, and where it doesn’t

AggregateRating is the property most trades sites deploy hoping for star displays in Google results, so set expectations honestly first: since Google’s 2019 self-serving-reviews update, review stars no longer display for LocalBusiness or Organization markup about your own business, no matter how clean the markup is. The property still earns its place. A well-formed rating block that public platforms corroborate feeds the entity-confidence layer AI engines use when they cross-check whether a business is what its schema claims. It just doesn’t buy SERP stars anymore, and anyone promising otherwise is selling you 2018.

Placement still matters for correctness. aggregateRating belongs on the LocalBusiness subtype node (your RoofingContractor or Plumber), not on a bare Organization. If your RoofingContractor and Organization share the same @id (the correct setup for a trades business), the rating attached to the subtype declaration is what validators and parsers read.

The correct placement:

{
  "@type": "RoofingContractor",
  "@id": "https://watson-roofing.com/#org",
  "name": "Watson Roofing Co.",
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.9",
    "reviewCount": 132,
    "bestRating": "5",
    "worstRating": "1"
  }
}

A few rules that protect the rating from getting denied or marked invalid:

Do this

Source aggregateRating from reviews you actually collect and display on your own site, paired with visible Review or Quotation content a reader can see. Keep the numbers consistent with what a customer would find on your public profiles; if your site claims 4.9 from 132 reviews and your Google Business Profile shows something different, that mismatch is an integrity signal working against you.

Use reviewCount (the integer total) plus ratingValue (the average to one decimal). Don't fabricate. Google's spam team catches mismatched aggregates at scale, and an enforcement action removes your rich-results eligibility across the entire site, not just the one page.

Avoid

aggregateRating copied or summed from third-party platforms (Google reviews, BBB) into your own markup. Google's review-snippet guidance requires ratings to come from reviews collected by the site itself; importing platform numbers into self-markup violates it.

Counting on stars in the SERP. Self-serving review markup has been excluded from star display since 2019; treat the property as entity corroboration, not a rich-result play.

The page-level nodes: BreadcrumbList, WebPage, Article

Beyond the business-level entities, every page in your graph needs page-level nodes. Three types cover most cases.

BreadcrumbList describes the navigation path to the current page. It powers the visible breadcrumb trail in Google search results. The node lists the path items in order:

{
  "@type": "BreadcrumbList",
  "@id": "https://watson-roofing.com/services/replacement/#breadcrumbs",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "https://watson-roofing.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "Services",
      "item": "https://watson-roofing.com/services/"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "Roof Replacement",
      "item": "https://watson-roofing.com/services/replacement/"
    }
  ]
}

WebPage is the wrapper for the current URL. It connects to the sitewide WebSite via isPartOf and points at the main content entity (a Service, Article, or whatever the page is primarily about) via about or mainEntity:

{
  "@type": "WebPage",
  "@id": "https://watson-roofing.com/services/replacement/#webpage",
  "url": "https://watson-roofing.com/services/replacement/",
  "name": "Roof Replacement in Watson, LA",
  "isPartOf": { "@id": "https://watson-roofing.com/#website" },
  "about":    { "@id": "https://watson-roofing.com/services/replacement/#service" },
  "breadcrumb": { "@id": "https://watson-roofing.com/services/replacement/#breadcrumbs" },
  "datePublished": "2026-05-28",
  "dateModified": "2026-05-28"
}

Article (or BlogPosting) applies to long-form content like guides and blog posts. It carries the headline, byline, publish date, modification date, and content references:

{
  "@type": "Article",
  "@id": "https://watson-roofing.com/blog/roof-lifespan/#article",
  "headline": "How long does an asphalt roof actually last in Louisiana?",
  "author":    { "@id": "https://watson-roofing.com/team/mike-watson/#person" },
  "publisher": { "@id": "https://watson-roofing.com/#org" },
  "datePublished": "2026-05-15",
  "dateModified": "2026-05-15",
  "mainEntityOfPage": { "@id": "https://watson-roofing.com/blog/roof-lifespan/#webpage" }
}

For pages that contain a Q&A section, add FAQPage. For step-by-step how-to content, add HowTo. Both are well-documented in Google Search Central, both unlock real rich-results entitlements, and both are extracted aggressively by AI engines for citation answers.

Expand: a complete trades-business graph for a single service page
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "RoofingContractor",
      "@id": "https://watson-roofing.com/#org",
      "name": "Watson Roofing Co.",
      "image": "https://watson-roofing.com/images/logo.png",
      "url": "https://watson-roofing.com",
      "telephone": "+1-225-555-0100",
      "address": {
        "@type": "PostalAddress",
        "streetAddress": "1234 Main Street",
        "addressLocality": "Watson",
        "addressRegion": "LA",
        "postalCode": "70786",
        "addressCountry": "US"
      },
      "geo": { "@type": "GeoCoordinates", "latitude": 30.5089, "longitude": -90.9123 },
      "openingHoursSpecification": [
        {
          "@type": "OpeningHoursSpecification",
          "dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
          "opens": "07:00",
          "closes": "18:00"
        }
      ],
      "areaServed": [
        { "@type": "City", "name": "Watson" },
        { "@type": "City", "name": "Baton Rouge" }
      ],
      "priceRange": "$$",
      "aggregateRating": {
        "@type": "AggregateRating",
        "ratingValue": "4.9",
        "reviewCount": 132,
        "bestRating": "5",
        "worstRating": "1"
      },
      "sameAs": [
        "https://g.page/r/CXXX...",
        "https://www.facebook.com/watsonroofing",
        "https://www.linkedin.com/company/watson-roofing",
        "https://www.bbb.org/us/la/watson/profile/.../"
      ]
    },
    {
      "@type": "WebSite",
      "@id": "https://watson-roofing.com/#website",
      "url": "https://watson-roofing.com",
      "publisher": { "@id": "https://watson-roofing.com/#org" }
    },
    {
      "@type": "WebPage",
      "@id": "https://watson-roofing.com/services/replacement/#webpage",
      "url": "https://watson-roofing.com/services/replacement/",
      "name": "Roof Replacement in Watson, LA",
      "isPartOf": { "@id": "https://watson-roofing.com/#website" },
      "about":    { "@id": "https://watson-roofing.com/services/replacement/#service" },
      "breadcrumb": { "@id": "https://watson-roofing.com/services/replacement/#breadcrumbs" }
    },
    {
      "@type": "BreadcrumbList",
      "@id": "https://watson-roofing.com/services/replacement/#breadcrumbs",
      "itemListElement": [
        { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://watson-roofing.com/" },
        { "@type": "ListItem", "position": 2, "name": "Services", "item": "https://watson-roofing.com/services/" },
        { "@type": "ListItem", "position": 3, "name": "Roof Replacement", "item": "https://watson-roofing.com/services/replacement/" }
      ]
    },
    {
      "@type": "Service",
      "@id": "https://watson-roofing.com/services/replacement/#service",
      "serviceType": "Roof replacement",
      "name": "Asphalt shingle roof replacement",
      "description": "Full tear-off and replacement of asphalt shingle roofs in Greater Baton Rouge.",
      "provider": { "@id": "https://watson-roofing.com/#org" },
      "areaServed": [
        { "@type": "City", "name": "Watson" },
        { "@type": "City", "name": "Baton Rouge" }
      ],
      "offers": {
        "@type": "Offer",
        "priceSpecification": {
          "@type": "PriceSpecification",
          "minPrice": 7500,
          "maxPrice": 18000,
          "priceCurrency": "USD"
        }
      }
    }
  ]
}

This is the production shape. Substitute your business name, address, geo coordinates, services, and reviews. Validate with the Rich Results Test before deploying.

Validation is non-negotiable. Three tools, in this order.

A JSON-LD block that looks correct can still have silent errors that deny rich-results eligibility. Validate every page before deploying schema changes. Three tools, used in sequence:

1. Schema Markup Validator is Schema.org’s own validator. It catches structural errors (malformed JSON, missing required properties for a given type, wrong data types). Paste the page URL or the raw JSON-LD, and it returns a tree view of your graph plus a list of errors and warnings. Fix everything in the error list. The warnings are optional.

2. Google Rich Results Test validates against Google’s specific rich-results requirements, which are stricter than the underlying Schema.org spec. It tells you which rich-result entitlements your page qualifies for (review snippet, FAQ rich result, breadcrumb display, etc.) and surfaces errors that would deny those entitlements. Fix every error. Several warnings are okay. Some warnings indicate genuinely optional improvements; others (especially around missing recommended properties) are silent rich-results gates.

3. View the rendered JSON-LD in the live page source. Browser DevTools is fine. The point is to read the <script type="application/ld+json"> block as it actually appears in the HTML response, not just as you authored it. Build pipelines occasionally munge JSON-LD in ways that pass validation locally but break in production. A 30-second View Source check after deploy catches that class of error.

Run the cycle on every schema change. Don’t deploy a graph rewrite without validating each page that uses the new pattern. Schema regressions compound: a malformed LocalBusiness block silently drops your rich-results star display, then drops your AI-citation likelihood, then drops your Local Pack ranking signal over the following weeks. By the time the symptom shows up in your analytics, you’ve lost months of compounded ranking value.

What this pillar deliberately doesn’t cover

This pillar walks through the entity types and graph patterns that anchor a trades-business schema baseline. It deliberately stops short of a few adjacent topics that warrant their own dedicated spokes:

  • Multi-location architecture. A roofer with three offices in three metros needs a separate LocalBusiness node per location, each with its own address and geo, plus a parent Organization that owns all three. The @id discipline gets more interesting in that setup. Spoke guide planned.
  • Event schema for community sponsorships. Trades businesses sponsoring local events, hosting clinics, or appearing at trade shows can emit Event nodes that show up in Google’s event-search surface. Out of scope for the foundational pattern.
  • Product schema for materials you sell. Some trades businesses sell roofing materials, plumbing fixtures, or HVAC equipment alongside service work. Product schema applies and has its own rules. Separate topic.
  • Schema for service-area businesses with no physical address. Schema.org and Google have specific guidance for businesses that operate entirely at customer locations. The address handling differs from a fixed-shop business.

Each of those will assume the vocabulary this pillar establishes.

The trades-pillar schema cheatsheet

Print this and hand it to whoever is implementing the JSON-LD on your site. Most of the failure modes show up in the first three rules.

  • One <script type="application/ld+json"> per page, wrapping a single @graph array
  • Every entity has a stable @id in IRI form with a #fragment
  • Sitewide entities (Organization, WebSite) keep the same @id across every page
  • Page-specific entities (WebPage, Article) include the page path in the @id
  • Use the deepest LocalBusiness subtype Schema.org offers for your trade
  • LocalBusiness subtype node carries aggregateRating (not bare Organization)
  • One Service node per real, distinct offering, linked back via provider
  • Person nodes for named team members, with worksFor, knowsAbout, and hasCredential
  • sameAs array populated with at least 4 verified profile URLs (GBP, Facebook, LinkedIn, BBB)
  • BreadcrumbList on every non-homepage page, matching the visible breadcrumb
  • WebPage node tying everything together via isPartOf, about, and breadcrumb
  • Article node on guides and blog posts, linked to a Person author and an Organization publisher
  • Review and/or Quotation schema for high-value testimonials
  • Validated through Schema Markup Validator AND Google Rich Results Test
  • Final spot-check: View Source on the deployed page confirms the JSON-LD landed cleanly

Hit all 15 and your trades-business schema graph is in the top 5% of sites in any US metro. Most of your competitors are still on multiple loose blocks with no @id discipline. That’s the gap this pillar exists to close.

First-party data

Every trades-vertical site Dynamic Promotion launched in 2026 ships with the one-graph-per-page pattern described here. The schema audit data from those builds (Rich Results Test pass rate, AggregateRating eligibility, Service node count per business) will be added to this guide as the first cohort accumulates 90 days of post-launch crawl data.

Frequently asked

Why is `@graph` better than multiple loose `<script>` blocks of JSON-LD?
A single `@graph` array lets every node reference every other node by `@id`. When Google or an AI engine merges entities across the web, it follows those `@id` references to build a coherent picture of your business. Multiple loose script blocks make each entity look standalone. The crawler doesn't know your `Article`'s `publisher` is the same `Organization` declared three blocks up the page. Same data, weaker entity graph, fewer rich-results entitlements.
Should I use `LocalBusiness` or a specific subtype like `RoofingContractor`?
Always the deepest subtype Schema.org offers for your trade. Schema.org has `RoofingContractor`, `Plumber`, `Electrician`, `HVACBusiness`, `GeneralContractor`, `MovingCompany`, and `HousePainter` under the `HomeAndConstructionBusiness` branch. Picking `RoofingContractor` over `LocalBusiness` tightens the topical signal and unlocks subtype-specific properties. If your trade isn't covered by a named subtype, fall back to `HomeAndConstructionBusiness`, then `LocalBusiness`. Don't use the generic `Organization` for a place-based service business. That's a different schema scope.
Where does `aggregateRating` actually go in the graph?
On the `LocalBusiness` subtype node (or the deepest subtype you chose, like `RoofingContractor`), not on the bare `Organization` node. Google's structured-data policy historically rejects star ratings attached to plain `Organization` schema for local businesses because the rich-results entitlement is scoped to place-based entities. Putting `aggregateRating` on `RoofingContractor` or `Plumber` works. Putting it on `Organization` alone risks a rich-results denial. If your `Organization` and `RoofingContractor` share an `@id`, the rating attached to the subtype declaration is what counts.
How many `Service` nodes should a trades site emit?
One per real, distinct offering. A roofer who does roof replacement, repair, gutter installation, and emergency tarp service emits four `Service` nodes, each linked to the `LocalBusiness` via `provider`. Don't blur them into one. AI engines extract service-specific passages, and a dedicated `Service` node tied to the corresponding service page gives the crawler a clean entity to cite. The shape stays small (serviceType, description, areaServed, provider), so the cost of adding more is low.
Do I need both `Review` and `Quotation` schema for testimonials?
They serve different purposes. `Review` is the schema Google uses for star ratings, review snippets in SERPs, and `aggregateRating` aggregation. `Quotation` is the schema AI engines pull verbatim into recommendation answers. A real Google review embedded on your site should get `Review` markup if you want the SERP entitlement, plus `Quotation` markup if you want AI engines to pick it up as an extractable passage. They coexist. Use both for high-value testimonials, just `Review` for routine ones.
How often do I need to revalidate schema after launch?
Every time the page changes substantively, plus a quarterly audit even if nothing changed. Schema.org updates its property definitions. Google updates its rich-results requirements (sometimes silently). A site that validated cleanly in 2024 may have new warnings in 2026 because of an evolved spec. The audit cost is low. Open Google's Rich Results Test, paste the URL, scan the output. If something flags, fix it before it compounds into a rich-results denial.
Is JSON-LD better than microdata or RDFa?
Yes, for almost every modern site. JSON-LD lives in a `<script>` tag in the head, so it doesn't get tangled with your visible markup. The W3C JSON-LD 1.1 spec is stable. Google explicitly recommends JSON-LD as the preferred format. Microdata still works for inline annotation on specific elements (useful for pull-quotes and review widgets where you want the visible element and the structured data to share a single node), but JSON-LD is the foundation. Pick JSON-LD for site-wide schema, microdata for specific extractable passages, and don't use RDFa unless a legacy system requires it.

Sources

Want this audited on your own site?

Free 1-page report — AEO compliance score, top 3 fixes, no obligation. Delivered within 7 days.