JSON-LD for AI Search: 7 Mistakes That Block Citations A developer's structured-data audit of sites that pass validation but never earn citations in ChatGPT or Perplexity answers identifies seven recurring JSON-LD failures, led by using a bare WebPage block instead of an @graph of Organization and SoftwareApplication entities linked by @id and sameAs. The writeup also flags client-side injection of schema via useEffect as invisible to most AI crawlers, recommending server-rendered JSON-LD verified with a curl check of the raw HTML response. Most structured data audits end the same way: the validator says zero errors , the Rich Results Test passes, and six months later the site has still never appeared in a single ChatGPT or Perplexity answer. I have gone through this with enough sites now to say it plainly — the problem is almost never syntax. It is that the markup describes a page when the retrieval system is looking for an entity, and the page's actual content never reaches the crawler in the first place. Here are the seven failures I see repeatedly, in the order they cost you the most. The default pattern is a single WebPage block. That tells a machine "this is a page of the type WebPage" — which is roughly as useful as telling it "this is a file." Retrieval and grounding systems need to resolve who you are , not what tag wraps the URL. There is no path from WebPage to a knowledge graph node. Before { "@context": "https://schema.org", "@type": "WebPage", "name": "Acme Analytics — Product Page", "url": "https://acme.dev/product" } After { "@context": "https://schema.org", "@graph": { "@type": "Organization", "@id": "https://acme.dev/ organization", "name": "Acme Analytics", "url": "https://acme.dev/", "logo": { "@type": "ImageObject", "url": "https://acme.dev/logo-512.png" }, "sameAs": "https://github.com/acme-analytics", "https://www.crunchbase.com/organization/acme-analytics", "https://en.wikipedia.org/wiki/Acme Analytics" }, { "@type": "SoftwareApplication", "@id": "https://acme.dev/product app", "name": "Acme Analytics", "applicationCategory": "BusinessApplication", "operatingSystem": "Web", "publisher": { "@id": "https://acme.dev/ organization" }, "description": "Privacy-first web analytics with cookieless session tracking.", "url": "https://acme.dev/product" }, { "@type": "WebPage", "@id": "https://acme.dev/product webpage", "url": "https://acme.dev/product", "isPartOf": { "@id": "https://acme.dev/ website" }, "about": { "@id": "https://acme.dev/product app" } } } The @id values are the actual work here. They turn three disconnected blocks into one addressable graph, so any node can be referenced from anywhere on the site without repeating the data. sameAs , or sameAs is the cheapest disambiguation signal that exists and it is the one most often skipped. An LLM asked "what is Acme Analytics" has no way to know your domain is the authority on Acme Analytics. It does know what a GitHub org, a Crunchbase entry, and a Wikidata item are. sameAs is how you hand it the bridge. Rules that matter here: This is the quiet killer on Next.js, Nuxt, React SPAs, and anything using a head-management library. If this is how your schema gets onto the page: js // ❌ invisible to most AI crawlers useEffect = { const script = document.createElement 'script' ; script.type = 'application/ld+json'; script.text = JSON.stringify schema ; document.head.appendChild script ; }, schema ; ...then the crawler has to execute JavaScript and wait for hydration and wait for your effect to flush before the markup exists. Googlebot will sometimes manage it. Most AI crawlers will not. The fix is boring: serialize the JSON server-side and render it as static text in the document. js // ✅ present in the raw HTML response const jsonLd = JSON.stringify schema, null, 2 ; return