An answer engine reads your page like a hurried human: it extracts meaning, with a margin of error. It reads your JSON-LD like a database: with none. That is the whole value of structured data in the assistant era — not a ranking bonus, but a way to assert your facts in a form that cannot be misread.
One graph, stable identifiers
The most common failure is not missing JSON-LD: it is having three blocks — one from the theme, one from an SEO plugin, one from a reviews widget — that contradict each other. An engine that meets two different company names on the same page does not pick one: it becomes cautious.
The pattern that fixes this is the graph. A single <script type="application/ld+json"> block, a @graph key, and inside it every node carries a stable @id — a URL with a fragment — that other nodes reference instead of duplicating the information.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "Organization",
"@id": "https://example.ma/#organization",
"name": "Atlas Cargo SARL",
"url": "https://example.ma/" },
{ "@type": "WebSite",
"@id": "https://example.ma/#website",
"url": "https://example.ma/",
"publisher": { "@id": "https://example.ma/#organization" },
"inLanguage": "en" },
{ "@type": "WebPage",
"@id": "https://example.ma/pricing#webpage",
"url": "https://example.ma/pricing",
"isPartOf": { "@id": "https://example.ma/#website" },
"about": { "@id": "https://example.ma/#organization" } },
{ "@type": "BreadcrumbList",
"@id": "https://example.ma/pricing#breadcrumb",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.ma/" },
{ "@type": "ListItem", "position": 2, "name": "Pricing" }
] }
]
}
</script>Three properties of this pattern deserve understanding rather than copying. The @id values must be stable over time: that is what lets the same entity be recognised across pages and across visits. References replace duplication: the company name is written once, so it cannot drift. And the last ListItem in a breadcrumb has no item: the current page does not link to itself.
The types that actually matter
| Type | What it does | Properties not to miss |
|---|---|---|
| Organization | Identify the entity | name, url, logo, sameAs, contactPoint |
| LocalBusiness | A business with a physical address | address, geo, openingHoursSpecification, telephone, areaServed, priceRange |
| Service / Product | What you sell | name, description, provider, offers, areaServed |
| Offer | Price and its validity | price, priceCurrency, availability, validThrough |
| FAQPage | Real questions and answers | mainEntity, acceptedAnswer |
| Article / BlogPosting | Editorial content | headline, datePublished, dateModified, author |
| BreadcrumbList | Position within the site | itemListElement, position |
A note on FAQPage. Google heavily restricted the matching rich result, and it no longer displays for most sites. The markup still earns its place here for a different reason: it presents your questions and answers pre-segmented, in exactly the shape an answer engine reuses. The rich result went away; the extractability did not.
The consistency rule — the one nearly everyone breaks
Markup must describe what the page shows. A price displayed as 4,500 MAD and an Offer saying 3,900; an address in Casablanca in the footer and in Rabat in the JSON-LD; opening hours matching no visible text — each of those turns an asset into a negative signal. Google withholds rich results for markup that is not representative of the content, and a model that spots a contradiction simply stops treating you as a reliable source.
Apply the same rule to yourself. sameAs should hold only profiles you genuinely control and that actually answer: your LinkedIn page, your business listing, your Instagram account. An invented or abandoned profile is worse than an empty array.
A complete example: a Moroccan local business
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": "https://example.ma/#business",
"name": "Atlas Cargo SARL",
"description": "Freight and customs clearance between Casablanca, Tanger Med and Europe.",
"url": "https://example.ma/",
"telephone": "+212522000000",
"email": "contact@example.ma",
"priceRange": "$$",
"address": {
"@type": "PostalAddress",
"streetAddress": "45 boulevard Zerktouni",
"addressLocality": "Casablanca",
"postalCode": "20250",
"addressCountry": "MA"
},
"geo": { "@type": "GeoCoordinates", "latitude": 33.589886, "longitude": -7.633076 },
"areaServed": [
{ "@type": "City", "name": "Casablanca" },
{ "@type": "City", "name": "Tangier" },
{ "@type": "Country", "name": "Morocco" }
],
"openingHoursSpecification": [
{ "@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
"opens": "08:30", "closes": "18:00" },
{ "@type": "OpeningHoursSpecification",
"dayOfWeek": "Saturday", "opens": "09:00", "closes": "13:00" }
],
"availableLanguage": ["ar", "fr", "en"],
"sameAs": ["https://www.linkedin.com/company/example-ma"]
}Four things make the difference in a Moroccan context. The phone number is written in international format with no spaces: that is what makes it comparable across sources. addressCountry takes the code MA, not the word "Morocco". areaServed is worth spelling out whenever you work beyond your own city — it is the property that answers "who does this in Tangier?". And availableLanguage genuinely matters here: the question may arrive in Arabic, French or English, and you have just said you answer in all three.
The WordPress case: three sources, one truth
On a typical WordPress site the markup comes from three places at once: the theme emits some, the SEO plugin emits more, and a reviews or booking add-on contributes its own. Nobody decided this, and nobody sees it — until the audit. The procedure is always the same: view the page source, search every occurrence of application/ld+json, and count.
curl -sS https://your-domain.com/ | grep -c 'application/ld+json'
# More than 1: work out who emits what, keep one source, switch the others off.Then choose the source that stays — usually the SEO plugin, because it survives a theme change — and disable the others in their settings. If an add-on refuses to go quiet, a filter in the child theme is still better than three contradictory graphs. The goal is not the richest markup: it is one piece of markup that is correct.
What voids the whole block
- Invalid JSON. One trailing comma, one curly quote pasted from a word processor, and the entire block is ignored — silently.
- Invented ratings. An
aggregateRatingwith no real reviews visible on the page is a direct policy violation, and the easiest infraction to detect automatically. - Images that answer 404. A dead
logoorimagedisqualifies the node carrying it. - Unstable
@idvalues. If they change on every deploy, the entity is recreated at every visit and never accumulates anything. - Competing blocks. Theme plus SEO plugin plus reviews widget: three sources, three truths. Consolidate into one graph.
- Markup for content that is not on the page. A JSON-LD FAQ whose questions appear nowhere visually is non-representative markup.
Validate, then verify
Two free tools, two different jobs. validator.schema.org checks conformance to the vocabulary: types, properties, structure. The Rich Results Test answers a different question — whether Google can render a special display from it. Perfectly valid markup may qualify for no rich result at all; that is not an error, the two goals are separate.
Neither tool checks what matters most: that your claims are true and consistent with the page. That check stays human — or automated with an explicit rule, like ours: every published URL is fetched before publication, and anything other than a 200 blocks the file from shipping.