Diego Betto
Illustration about website audit, with a magnifying glass and a checklist

Diego Betto · ·9 min di lettura

A GEO audit of a real site: six common structured data mistakes and how to fix them

What turns up when you apply a GEO checklist to a real site: duplicate entities, unescaped JSON-LD, missing dates, ambiguous topics, no Markdown and AI crawlers blocked by the CDN.

Condividi:XLinkedInFacebookWhatsApp

A GEO checklist is easy to write and hard to follow. In the posts on what GEO is, GEO vs SEO and structured data and the AI-first web I listed what a generative engine needs to understand and cite a site. This post takes the next step: what happens when that checklist is actually applied to an existing site, and which mistakes come up.

The site started in good shape: server-generated HTML, JSON-LD on articles, a sitemap with real dates, hreflang for languages, an llms.txt. Even so, six things didn’t add up. They’re mistakes you find almost everywhere, because they come from a project’s normal growth, not from carelessness.

The six problems at a glance

# Problem Why it matters
1 Author redefined on every page, with no @id To an engine that’s many people with the same name, not one entity
2 Publisher with the author’s name but a different type A second entity that adds confusion instead of clarity
3 JSON-LD written into the page without escaping A </script> in a title or an answer closes the tag
4 dateModified present only on updated articles Google recommends always providing it
5 Topic expressed only as a tag “React” or “Swift” are strings, not disambiguated entities
6 No clean text version of pages Agents and tools have to extract text from HTML, layout and noise included

A seventh joined them, one no code review would have found: the CDN in front of the site responded differently depending on the client.

1 and 2: a fragmented identity

Every page type had its own JSON-LD block, written at different times: home, “about”, services, articles. The same person appeared with different job titles, sometimes with sameAs profiles, sometimes without. And on articles the publisher was an Organization carrying the person’s name: two different entities with the same name.

No block was wrong on its own. Together they described an inconsistent identity, which is exactly what an entity-based system struggles to piece together.

The fix is to define the site’s entities once, with a stable @id, and have pages reference them:

{
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "Person", "@id": "https://example.com/about/#person", "name": "Jane Doe", "sameAs": ["https://github.com/janedoe"] },
    { "@type": "WebSite", "@id": "https://example.com/#website", "publisher": { "@id": "https://example.com/about/#person" } },
    {
      "@type": "BlogPosting",
      "@id": "https://example.com/post/#article",
      "author": { "@id": "https://example.com/about/#person" },
      "publisher": { "@id": "https://example.com/about/#person" },
      "isPartOf": { "@id": "https://example.com/#website" }
    }
  ]
}

A personal blog’s publisher is the person, and schema.org allows a Person as publisher. If a company publishes, the publisher is the Organization, again with its own @id. What matters is that the choice reflects reality and is the same everywhere.

A useful detail: if a page lists extra profiles (a “links” page, for example), you don’t need a second Person. Just add sameAs to the same @id: JSON-LD processors merge nodes that share an identifier.

3: the escape that’s almost always missing

The most common way to put JSON-LD in a template is to serialize an object and write it inside <script type="application/ld+json">. But JSON.stringify doesn’t escape <: if a title, a description or an FAQ answer extracted from the text contains </script>, the tag closes early and the rest is parsed as HTML.

const json = JSON.stringify(data).replace(/</g, "\\u003c");

< is valid JSON and every parser reads it as <, but to the browser it’s no longer the start of a tag. The risk is low when you write all the content yourself, but it becomes real as soon as it comes from a CMS, from multiple authors or from articles that are about HTML and security.

4: dates always present, never invented

Always provide dateModified. If a piece of content has never been updated, it equals datePublished. What to avoid is the opposite: using the date of the last deploy. It changes on every release even without real changes, and engines soon stop treating it as a signal.

5: from tags to entities

Tags are already structured data, but they’re still strings. Linking them to a unique entity with about and sameAs removes the ambiguity:

"about": [
  { "@type": "Thing", "name": "React", "sameAs": "https://en.wikipedia.org/wiki/React_(software)" }
]

A small tag → Wikipedia or Wikidata lookup table is enough, limited to tags that really correspond to something specific. Leave generic tags like “errors”, “news” or “tools” out: a wrong about is worse than none.

6: a text version for readers that aren’t browsers

Agents, assistants and developer tools increasingly read pages directly. A Markdown version of every article, reachable at the same URL with .md, is far easier to process than the full HTML with menus, footers and scripts. Announce it in the head:

<link rel="alternate" type="text/markdown" href="/post.md" />

Three things to get right:

  • Actually clean the content. If the text is written with components (boxes, embeds, callouts), convert them to plain Markdown: a callout becomes a blockquote, an embed a link or nothing. Leave code blocks untouched.
  • Serve them with the right type, text/markdown. On a static site the hosting platform often picks the Content-Type from the extension, so configure it there.
  • Keep them out of the index. An X-Robots-Tag: noindex header stops search engines from seeing a copy of every page. The URL to cite is still the HTML one.

Finally, mention them in your llms.txt, if you have one.

7: the CDN that decides for you

A ten-second check revealed unexpected behavior: a request from a generic client got a 403, while the same requests with AI crawler user agents got a 200.

curl -s -o /dev/null -w '%{http_code}\n' -A "GPTBot" https://example.com/

In that case it was fine. But it’s the kind of setting a CDN or WAF can change for you: several providers offer one-click blocking of AI crawlers, and some propose it as the default for new sites. A perfect robots.txt is useless if the firewall answers 403 first.

A test with a spoofed user agent only shows how the rule treats the user agent, not how verified bots are handled based on their IP address. For that you need the CDN’s logs or analytics. But as a periodic check, it’s the cheapest one there is.

robots.txt: an explicit choice

Leaving Allow: / for everyone is a legitimate decision if you depend on being read and cited. If your content has direct commercial value, you can separate model training from search with citations instead:

User-agent: GPTBot
User-agent: ClaudeBot
User-agent: Google-Extended
Disallow: /

User-agent: OAI-SearchBot
User-agent: Claude-SearchBot
User-agent: PerplexityBot
Allow: /

Google-Extended has no effect on Google Search or AI Overviews: it only controls whether your content is used for Gemini models.

How to verify the result

  • Validators: the Schema Markup Validator and the Rich Results Test on one page per type (article, home, about, services).
  • An automated check after the build: every JSON-LD block must be valid JSON and no @id may appear twice in the same graph. It’s a few lines of script, and it catches mistakes before deploy.
  • Real access: the check with crawler user agents, repeated every so often.

Then comes the part that takes patience. Technical changes kick in on the next crawler passes; being recognized as an authoritative entity takes months. In the meantime, measure what you can: traffic with AI assistant referrers, a fixed list of questions asked every month, requests to the .md versions.

Audit checklist

  • One node per person and organization, with a stable @id, referenced by every page
  • A publisher that matches reality, the same across the site
  • JSON-LD with < escaped
  • dateModified always present and never tied to the build date
  • about with sameAs for topics that correspond to an entity
  • Markdown versions of the main pages, served as text/markdown with noindex
  • AI crawler access verified on the CDN, not just in robots.txt
  • Automated JSON-LD check in CI

Frequently asked questions

❓ Do I really need an @id if I already have JSON-LD on every page?

Per-page JSON-LD works for rich results, but without an @id every page describes its own copy of the author and publisher. With a stable @id every page talks about the same entity, and small differences between templates stop creating different identities.

❓ Do Markdown versions create duplicate content?

They can, if they get indexed. That’s why it’s worth serving them with an X-Robots-Tag noindex header: agents and tools can read them, but search engines don’t show them in results, and the URL to cite is still the HTML page.

❓ How do I check whether a CDN is blocking AI crawlers?

Send a request with the crawler’s user agent, for example GPTBot or ClaudeBot, and look at the status code. A 403 or a challenge page means a block. Then check the CDN’s bot settings, because some rules block verified crawlers by IP address, which a spoofed user agent test can’t detect.

❓ Which of these mistakes should I fix first?

The fragmented identity. A consistent author and publisher with a stable @id are the foundation everything else rests on, and the fix usually happens in a single place in the code. Escaping the JSON-LD is just as quick and closes a small security hole.

Conclusion

Technical GEO is mostly consistency, and consistency gets lost one template at a time. A site with solid foundations can still present a different author on every page, a forgotten escape and a CDN that decides who gets to read it. None of these fixes is complex: the hard part is noticing, which is why a periodic audit, run with the same checklist you’d recommend to others, is worth more than any one-off optimization.

References:

Condividi:XLinkedInFacebookWhatsApp
Diego Betto

Written by

Diego Betto

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.