Structured Data That Actually Earns Rich Results
Which schema types produce visible rich results, which are just semantics, and the JSON-LD patterns that survive validation. With full working examples.
Most structured data on the web does nothing. Not because it's invalid — it validates fine — but because it describes a type Google has no rich result for, or because it describes things the page doesn't actually show. The gap between "my markup passes the validator" and "my listing looks different in search" is where almost all the wasted effort lives. This is a practical map of which types earn a visual payoff today, the JSON-LD patterns that hold up in production, and the one rule that gets more markup penalised than any other.
Why JSON-LD, and why not the alternatives
Schema.org vocabulary can be expressed in three syntaxes: microdata (attributes sprinkled through your HTML), RDFa (similar, from the semantic-web lineage), and JSON-LD (a single <script> block of JSON). All three are parsed by Google. JSON-LD is the one Google explicitly recommends, and the reasons are engineering reasons rather than ranking ones.
Microdata couples your data model to your DOM. The itemprop for a price must live on the element that renders the price, so any redesign that moves it silently breaks the markup. Nesting is expressed by DOM containment, so a valid Product requires its Offer to be a descendant element — a constraint that fights every component-based framework ever written.
JSON-LD is a detached blob. It can be generated server-side from the same object your template renders from:
export default function ProductPage({ product }) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
offers: {
"@type": "Offer",
price: product.price.toFixed(2),
priceCurrency: product.currency,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProductDetail product={product} />
</>
);
}
One source of truth, no DOM coupling, and it can be unit tested. The tradeoff is that nothing forces it to stay in sync with what's rendered — which is exactly the failure mode discussed later.
Two mechanical notes. Use JSON.stringify, never a template literal with interpolated strings, or a product name containing " will produce invalid JSON. And if your Content Security Policy blocks inline scripts, application/ld+json still needs a nonce or hash — it's a <script> tag as far as CSP is concerned, even though it never executes.
Which types actually produce something visible
This is the part worth internalising before you write any markup. Schema.org has hundreds of types. Google supports rich results for a few dozen, and that list shrinks over time — Google has retired several features, including the FAQ and HowTo rich results for most sites.
Types with a reliable visual payoff in Google today:
| Type | What you get |
|---|---|
Product with Offer |
Price, availability and currency in the snippet; eligibility for merchant listings |
Product with AggregateRating / Review |
Star ratings — but only for products, recipes, books, movies, software and a few other allowed types |
Recipe |
Image carousel, cook time, ratings, calories |
Event |
Date, venue, ticket link |
BreadcrumbList |
The path replacing the raw URL under the title |
VideoObject |
Thumbnail, key moments, duration |
JobPosting |
Google Jobs box inclusion |
LocalBusiness |
Feeds knowledge panel and local pack data |
Organization |
Knowledge panel logo, name and social profiles |
SoftwareApplication |
Rating, price, OS |
Types that are correct semantics with little or no direct visual result:
Article, NewsArticle and BlogPosting no longer produce a distinct snippet treatment for most publishers — the top stories carousel has its own eligibility rules and is not simply a function of the markup. WebPage, WebSite, Person, ImageObject and SiteNavigationElement produce nothing you can screenshot. FAQPage markup is still valid but Google restricted the FAQ rich result to a small set of authoritative government and health sites; on a typical commercial page it will validate and show nothing.
That does not make the second group pointless. Entity markup — Organization, Person, sameAs links, @id references — is how you make explicit which real-world thing your page is about, and that clarity is plausibly useful to any system that consumes the page, including LLM-based retrieval. But be honest about what's known here: Google has never confirmed that structured data influences ranking, and no major AI search system has published a specification saying it reads schema.org markup for citation selection. Anyone telling you that Article markup gets you into ChatGPT's citations is guessing. Mark it up because it's cheap, correct and machine-legible — not because someone promised a lift. If you're optimising specifically for LLM retrieval, getting cited by AI search engines covers what's actually observable.
Working examples
Article
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Redirect Chains: How to Find Them and Why They Cost You",
"description": "How redirect chains form, what they cost, and how to flatten them.",
"image": ["https://example.com/img/redirects-16x9.jpg",
"https://example.com/img/redirects-4x3.jpg",
"https://example.com/img/redirects-1x1.jpg"],
"datePublished": "2026-08-19T09:00:00+00:00",
"dateModified": "2026-08-19T09:00:00+00:00",
"author": {
"@type": "Person",
"name": "Jane Okonkwo",
"url": "https://example.com/authors/jane-okonkwo"
},
"publisher": {
"@type": "Organization",
"name": "Example Media",
"logo": {
"@type": "ImageObject",
"url": "https://example.com/logo.png"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://example.com/blog/redirect-chains"
}
}
Details that matter: headline should match the visible <h1> and Google truncates it around 110 characters. Supply image as an array of at least three crops (16:9, 4:3, 1:1) — Google picks per surface. Dates must be ISO 8601 with a timezone offset, and dateModified earlier than datePublished is a validation error. Give author a url pointing at a real author page; the bare-string form is legal but says nothing about which Jane you mean.
BreadcrumbList
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Blog",
"item": "https://example.com/blog"
},
{
"@type": "ListItem",
"position": 2,
"name": "Technical SEO",
"item": "https://example.com/blog/category/technical-seo"
},
{
"@type": "ListItem",
"position": 3,
"name": "Redirect Chains"
}
]
}
The final item has no item property — it's the current page, and omitting the URL is the documented pattern. position starts at 1 and must be contiguous. Breadcrumbs are one of the highest-value-per-line types on this list: they're trivial to generate from your routing, they render for most sites, and they replace an ugly URL with a readable path.
Product
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Merino Crew Sweater",
"image": ["https://example.com/img/sweater-1x1.jpg"],
"description": "Machine-washable 100% merino wool crew neck.",
"sku": "MCS-NAVY-M",
"gtin13": "0761234567890",
"brand": { "@type": "Brand", "name": "Northmoor" },
"offers": {
"@type": "Offer",
"url": "https://example.com/products/merino-crew-sweater",
"priceCurrency": "GBP",
"price": "89.00",
"priceValidUntil": "2027-01-31",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition",
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": "4.95",
"currency": "GBP"
},
"shippingDestination": {
"@type": "DefinedRegion",
"addressCountry": "GB"
}
}
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.6",
"reviewCount": "218"
}
}
price must be a plain decimal string — no currency symbol, no thousands separator. "1,299.00" fails; "1299.00" passes. availability takes the full schema.org URL form; the bare token InStock is accepted by Google's parser but the URL form is the specification. aggregateRating requires at least one of reviewCount or ratingCount, and if the number of reviews is zero you must omit the whole object rather than emit "reviewCount": "0", which is an error.
Organization
{
"@context": "https://schema.org",
"@type": "Organization",
"@id": "https://example.com/#organization",
"name": "Northmoor",
"url": "https://example.com/",
"logo": {
"@type": "ImageObject",
"url": "https://example.com/logo-600x60.png",
"width": 600,
"height": 60
},
"sameAs": [
"https://www.linkedin.com/company/northmoor",
"https://github.com/northmoor",
"https://en.wikipedia.org/wiki/Northmoor"
],
"contactPoint": {
"@type": "ContactPoint",
"contactType": "customer support",
"email": "[email protected]"
}
}
sameAs is the entity-disambiguation property: it's how you say "the Northmoor on this page is that Northmoor." Point it at profiles you control and, where one exists, an authoritative reference like Wikidata or Wikipedia. Don't pad it with directory listings you don't own.
FAQPage
Include it if the content is genuinely a Q&A block, but with clear expectations:
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Does a redirect lose PageRank?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. Google has stated that PageRank is not reduced by 30x redirects."
}
}
]
}
Every question and answer here must appear in the rendered HTML, verbatim in substance. text accepts a limited set of HTML tags (<p>, <br>, <ol>, <ul>, <li>, <a>, <b>, <em>) — anything else should be stripped, and the whole value needs HTML-escaping inside the JSON string.
The @graph pattern
A page usually describes several connected things: the site, the organisation behind it, the specific web page, and the article on it. Emitting four separate <script> blocks works, but nothing then states that the article's publisher is the organisation described elsewhere. @graph plus @id fixes that.
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://example.com/#organization",
"name": "Example Media",
"url": "https://example.com/"
},
{
"@type": "WebSite",
"@id": "https://example.com/#website",
"url": "https://example.com/",
"name": "Example Media",
"publisher": { "@id": "https://example.com/#organization" }
},
{
"@type": "WebPage",
"@id": "https://example.com/blog/redirect-chains#webpage",
"url": "https://example.com/blog/redirect-chains",
"isPartOf": { "@id": "https://example.com/#website" },
"breadcrumb": { "@id": "https://example.com/blog/redirect-chains#breadcrumb" }
},
{
"@type": "BreadcrumbList",
"@id": "https://example.com/blog/redirect-chains#breadcrumb",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Blog",
"item": "https://example.com/blog" }
]
},
{
"@type": "Article",
"@id": "https://example.com/blog/redirect-chains#article",
"headline": "Redirect Chains: How to Find Them and Why They Cost You",
"isPartOf": { "@id": "https://example.com/blog/redirect-chains#webpage" },
"mainEntityOfPage": { "@id": "https://example.com/blog/redirect-chains#webpage" },
"publisher": { "@id": "https://example.com/#organization" },
"author": { "@id": "https://example.com/authors/jane#person" }
}
]
}
The mechanics: @id is an arbitrary but stable identifier — by convention an absolute URL with a fragment. Anywhere a property would take a nested object, you can instead supply { "@id": "..." } as a reference to a node defined elsewhere in the graph. The consumer resolves it.
Two rules make this work. @id values must be globally unique and stable across deploys — if your build regenerates them per release, every reference silently dangles. And the node a reference points to must exist somewhere in a graph the parser has seen; a reference to #organization on a page that never defines it resolves to nothing. Keep site-wide nodes (Organization, WebSite) in a layout-level component so every page emits them, and page-specific nodes in the page component.
Note that @id here has nothing to do with the page's canonical URL, though using the canonical as the base makes them easier to reason about. If you're unsure which URL is canonical in the first place, that's a prerequisite problem — see canonical tag mistakes.
Required versus recommended
Google's documentation splits properties into required and recommended, and the distinction has teeth. Missing a required property means no eligibility at all — the Rich Results Test reports an error and the feature simply doesn't apply. Missing a recommended property means eligibility survives but the result may be sparser, or Google may prefer a competitor's more complete markup for a limited slot.
The pattern to avoid is emitting only the required set. Product requires little more than name plus one of offers/review/aggregateRating. A block carrying only those is technically eligible and practically useless: no image, no brand, no GTIN, so nothing can match it against a product catalogue.
Conversely, don't invent properties. Schema.org will happily accept an unrecognised key without complaint — JSON-LD ignores terms not in the context — so a typo like datepublished (lowercase p) fails silently. The validator will flag it as a missing required field rather than as a typo, which is a confusing way to find out.
The rule that gets markup penalised
Structured data must describe content a user can actually see on the page. This is not a style guideline; it is the core of Google's structured data policy, and violations are the main cause of manual actions in this area.
Concrete violations, all of which happen constantly:
aggregateRatingon a page with no visible reviews, or with a rating computed from data displayed nowhere.FAQPagemarkup with questions that only exist in the JSON-LD, not in the HTML — this is the single most common structured-data manual action.Productofferswith a price that differs from the price on the page, usually because the markup renders from a cached list-price field while the page renders a live discounted price.Recipemarkup on a category page listing many recipes rather than on the recipe itself.- Markup for content hidden behind a login, a paywall or an interaction the crawler cannot perform.
The consequence is a manual action for spammy structured markup, visible in Search Console. Its effect is that rich results are disabled for the affected pages — sometimes for the whole site — until the markup is fixed and reconsideration is granted. That process takes weeks. It is a far worse outcome than never having marked anything up.
Content in tabs or accordions is fine, provided it's in the initial HTML and merely hidden with CSS. Content injected by client-side JavaScript is a grey area: Google renders JavaScript, so it usually works, but it makes the markup dependent on rendering succeeding. Emit structured data server-side if you can.
Validation
Three tools, three jobs:
- Google's Rich Results Test — fetches and renders the live URL, reports which Google features the page is eligible for. It only knows types Google supports, so valid
Personmarkup shows "no rich results detected", which is correct rather than a failure. - Schema Markup Validator (
validator.schema.org) — validates against the schema.org vocabulary with no opinion about Google. Use it for unsupported types and vocabulary errors. - Search Console's Enhancements reports — the only view of what Google saw at real crawl time across the whole site, and the only place a manual action appears.
Validate the rendered HTML, not your template. The quickest sanity check on a live page:
curl -s https://example.com/products/merino-crew-sweater \
| grep -oP '(?<=<script type="application/ld\+json">).*?(?=</script>)' \
| python3 -m json.tool
If that errors, your JSON is malformed and every consumer silently discards the whole block — one stray trailing comma kills the entire script tag, not just the property it follows. That failure is invisible in the browser and invisible in your logs.
SEO Fix Agent includes structured-data presence and parse validity among the roughly thirty checks it runs against a URL, and compiles whatever fails into a fix prompt for Claude Code or Cursor — useful when the fix is "regenerate this JSON-LD component with the missing properties" rather than a one-line edit.
What markup can and cannot do
Correct structured data makes a page eligible for a rich result. It does not entitle you to one. Google decides per query whether to show an enhanced snippet, and it routinely doesn't — because the query intent doesn't call for it, because the SERP layout has no room, because the site lacks the quality signals Google wants behind an enhanced listing, or because the feature was quietly retired. Pages with flawless markup frequently show a plain blue link.
Set expectations accordingly. Structured data is cheap to add, cheap to maintain when generated from real data, and occasionally produces a meaningfully better listing. It is not a ranking lever, and treating it as one produces the over-marked-up pages that earn manual actions. Mark up what your page genuinely is, keep it synchronised with what renders, and stop there.
Audit your page, then ship the fix
SEO Fix Agent runs 30+ technical, content and AI-search checks on a page, then compiles every finding into a severity-ordered prompt your coding agent can execute. 75 free credits, no card.
Start free →