GEO, schema and JSON-LD to get cited by LLM engines

By Brenner Cruvinel • 11 minutes read

I wrote this guide in May 2026, after putting what I knew about schema next to what the GEO (generative engine optimization) papers were measuring. On June 19 I applied it to this blog in three changes: the single @graph, the BreadcrumbList, and a bundle with speakable, a TL;DR block and a robots.txt that allows AI crawlers. So the example in this post is no longer a made-up example.com. It is the JSON-LD this page serves right now.

The idea that ties it all together: clean JSON-LD is no longer just crawler food. It is what generative engines read to decide who gets credit for an answer, and it is the format NLWeb consumes and returns.

@graph and @id

The difference that matters is between loose JSON-LD blocks and a single connected graph. Each entity is declared once, with a stable @id, and everything else points to it with { "@id": "..." }. One @graph block per page, not several scripts fighting each other.

The anchoring rule I follow, in line with the W3C JSON-LD best practices:

I declare site entities before page entities. The W3C does not require it. It is hygiene: a linear parser resolves the nodes in the right order.

The three bugs that show up most when auditing schema, all of which go away with disciplined @id values:

The third one is the strongest argument for generating schema in one place. Here that place is templates/partials/schema.html, a Zola partial that builds the whole graph from config.toml and the post front matter.

The other rule is triple agreement: visible text, semantic HTML and JSON-LD saying the same thing. The GEO-16 paper audited 1,100 URLs cited by Brave Summary, Google AI Overviews and Perplexity, and the pillars most correlated with citation were metadata and freshness (r = 0.68), semantic HTML (r = 0.65) and structured data (r = 0.63). In other words, semantic HTML weighed as much as schema. Schema that describes something not on the page gets ignored, at best.

The @graph this blog serves

This is the graph for the AirPods Max post, without the long fields (description, image, word count):

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": "https://brennercruvinel.blog/#organization",
      "name": "Brenner Cruvinel",
      "url": "https://brennercruvinel.blog/",
      "sameAs": ["https://github.com/brennercruvinel"],
      "knowsAbout": ["artificial intelligence", "mental health tech", "product design"]
    },
    {
      "@type": "WebSite",
      "@id": "https://brennercruvinel.blog/#website",
      "url": "https://brennercruvinel.blog/",
      "inLanguage": "pt-BR",
      "publisher": { "@id": "https://brennercruvinel.blog/#organization" }
    },
    {
      "@type": "Person",
      "@id": "https://brennercruvinel.blog/#person",
      "name": "Brenner Cruvinel",
      "sameAs": ["https://github.com/brennercruvinel"]
    },
    {
      "@type": "BlogPosting",
      "@id": "https://brennercruvinel.blog/blog/meu-airpods-max-voltou-do-congelador/#article",
      "isPartOf": { "@id": "https://brennercruvinel.blog/#website" },
      "headline": "Meu AirPods Max morreu e voltou do congelador",
      "datePublished": "2026-10-10T00:00:00+0000",
      "author": { "@id": "https://brennercruvinel.blog/#person" },
      "publisher": { "@id": "https://brennercruvinel.blog/#organization" },
      "speakable": {
        "@type": "SpeakableSpecification",
        "cssSelector": [".tldr", "h1", "h2"]
      }
    },
    {
      "@type": "BreadcrumbList",
      "@id": "https://brennercruvinel.blog/blog/meu-airpods-max-voltou-do-congelador/#breadcrumb",
      "itemListElement": [
        { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://brennercruvinel.blog/" },
        { "@type": "ListItem", "position": 2, "name": "Writings", "item": "https://brennercruvinel.blog/blog/" }
      ]
    }
  ]
}

Read it as an entity database, not as a list of tags. Organization, WebSite and Person exist once. The BlogPosting points to them. speakable says which part of the HTML is good for direct extraction, here the TL;DR block at the top of every post.

The template can also generate a Dataset node and a DefinedTermSet when a post's front matter declares extra.dataset or extra.terms. No post uses that yet.

What is still missing here, and is commented out in config.toml: the sameAs to Wikidata (I do not have an entry there) and the SearchAction (the blog has no URL-based search).

Which types to use

The base shows up on almost every site: Organization or LocalBusiness for the brand, WebSite, WebPage per page, Person for authors and BreadcrumbList. On top of that:

ActivityMain typesWorth adding
Blog, editorial siteArticle, BlogPostingSpeakableSpecification, Claim
E-commerceProduct, Offer, ReviewDataset for the catalog
Local businessLocalBusinessDefinedTerm for services
SaaS, softwareSoftwareApplication, ServiceDataset for the changelog
DocumentationTechArticle, HowToDefinedTermSet
ResearchArticle, PersonClaim, ClaimReview

The types I think are underused:

Where schema lives on each platform

The principle does not change: the @graph is a data artifact, not a UI artifact. It is serialized once from the source of truth and has to be where the consumer looks before any JavaScript runs. On the web, the consumer is a crawler reading the initial HTML, so JSON-LD comes from the server, never from a useEffect.

The source of truth is the domain modeled as entities, with types validated in the backend language: Pydantic in Python, Zod or schema-dts in TypeScript, serde in Rust, Codable in Swift, kotlinx.serialization in Kotlin. Each entity has a stable internal identifier that becomes the @id, and a single module generates the JSON-LD. That module is what kills duplication.

In Next.js with the App Router, schema goes in a Server Component. schema-dts is Google's types package, types only, with no runtime cost, and it had 3,127,725 downloads in the week of October 5 to 11, 2026, according to the npm API. Site entities go in the root layout; page entities in the page component. For a blog, SSG or ISR beats plain SSR, because the schema comes out at build time.

// app/blog/[slug]/page.tsx, Server Component
import type { Graph, WithContext } from 'schema-dts'
export default async function ArticlePage({ params }) {
  const post = await getPost(params.slug)
  const schema: WithContext<Graph> = {
    '@context': 'https://schema.org',
    '@graph': [{
      '@type': 'Article',
      '@id': `https://example.com/blog/${params.slug}#article`,
      headline: post.title,
      author: { '@id': 'https://example.com/#person' },
      publisher: { '@id': 'https://example.com/#organization' },
      datePublished: post.publishedAt,
      dateModified: post.updatedAt,
    }],
  }
  return (
    <script type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
  )
}

In a pure SPA, with Vite or CRA, the problem has no reliable solution: most AI crawlers do not run JavaScript. Static content goes in index.html; dynamic content needs prerendering or a meta-framework with SSR. Rails, Django, Laravel and Go are the easy case: the server template prints the script.

Rust on the server (axum, actix, Leptos with SSR) is the cleanest case, because the source of truth and the serializer are the same typed language:

#[derive(serde::Serialize)]
struct Article<'a> {
    #[serde(rename = "@type")] kind: &'a str,
    #[serde(rename = "@id")] id: String,
    headline: &'a str,
    author: IdRef<'a>,     // { "@id": "..." }
    publisher: IdRef<'a>,
    #[serde(rename = "datePublished")] published: &'a str,
}
// serde_json::to_string(&graph) becomes the body of <script type="application/ld+json">

Rust compiled to WebAssembly in the browser falls into the same hole as the SPA. If the wasm builds the page on the client, the crawler sees nothing. The article The Future of SEO with WebAssembly, from the agency GTECH, argues the same: server-rendered HTML with text, headings and links on first load, and wasm only on top, for the heavy work.

In a native app, Swift or Kotlin, there is no crawler reading the screen. Putting JSON-LD in a SwiftUI view gives you no visibility at all. What makes the content discoverable is the backend: it serves the same graph as HTML to crawlers and through a structured endpoint to agents, and the app consumes that backend. Firebase App Indexing, which indexed app content, is on Firebase's list of deprecated libraries; Android App Links and Universal Links are still useful, but they are routing, not discovery.

PlatformWhere schema livesHow it gets there
Next.js, Nuxt, SvelteKit, Astro, ZolaServer component, SSR or SSGScript in the initial HTML
Rails, Django, Laravel, GoServer templateScript in the initial HTML
Pure SPAindex.html or prerenderServed HTML
Rust on the serverserde in the handlerScript in the initial HTML
Rust/wasm on the clientPrerender or SSRNever client-only
Swift, Kotlin, desktopBackend the app consumesEndpoint, /ask and /mcp

Schema breaks silently. A structural change that corrupts an @id only shows up weeks later, when the citations disappear. That is why it pays to validate before every deploy: the Schema Markup Validator checks against the vocabulary, the Rich Results Test checks the rich results that still exist, and schema-dts catches errors at compile time.

FAQPage after May 7, 2026

Google removed FAQ rich results in stages, announced in a notice in the documentation, with no blog post. On May 7, 2026 they stopped showing in search. On June 15 Google removed the feature's documentation, as the Search Central changelog records. The same announcement planned to drop support in the Search Console API in August.

What remains: FAQPage is still a valid schema.org type. Google turned off the display, not the markup, and other crawlers keep reading it. What always did the work was the content in question-and-answer form.

One number helps explain why. Ahrefs analyzed 863,000 queries and 4 million URLs cited in AI Overviews, in a study published in March 2026 and summarized by Search Engine Journal: only 38% of the cited pages were in the top 10 for the same query. In the July 2025 version it was 76%, but Ahrefs itself warns that it improved citation detection between versions. Even with that caveat, what earns citations is not only what earns rankings.

My decision: I keep FAQPage where the question and the answer are real and visible on the page. I remove it only where the section existed to decorate the SERP.

NLWeb and MCP

NLWeb was announced by Microsoft at Build 2025 and was conceived by R.V. Guha, the same person behind RSS, RDF and Schema.org. Among the first partners named are Shopify, Snowflake, O'Reilly, Tripadvisor, Eventbrite and Hearst. It turns a site into an MCP server with two endpoints:

The arguments are the same for both; only the response format changes. The reference implementation keeps no state: the conversation context comes back with each call, in the prev parameter or as an already decontextualized query.

POST https://example.com/ask
Content-Type: application/json

{ "query": "who wrote about GEO on this site?", "site": "example.com" }

The implication: a well-built @graph becomes a conversational API without rewriting the backend. It works best on sites already structured as lists of items, recipes, events, products. The repository moved from microsoft/NLWeb to nlweb-ai/NLWeb in July 2025. It is written in Python and now ships modules such as AskAgent, AgentFinder and DataFinder, with Postgres among the supported vector stores.

For a native app with no web version, exposing /ask and /mcp over the data is the way to be found by agents.

The robots.txt and the llms.txt

If the goal is to be cited by AI, LLM user agents stay allowed in robots.txt. Blocking is a strategic decision, as with paid content, knowing it takes the site out of that channel. This blog's robots.txt allows, among others:

User-agent: GPTBot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: CCBot
Allow: /

GPTBot and OAI-SearchBot belong to OpenAI, PerplexityBot to Perplexity, ClaudeBot to Anthropic. Google-Extended controls use in Gemini and CCBot is Common Crawl.

The llms.txt file is a markdown index of the important URLs. It blocks nothing and ranks nothing. Gary Illyes said in July 2025 that Google does not use it and does not plan to, and John Mueller compared the file to the keywords meta tag. On June 15, 2026 Google put this in its own AI optimization guide: the file is not needed for search and neither helps nor hurts rankings. This blog has no llms.txt, and I do not plan to prioritize one.

Four layers for an LLM-ready engine

When schema stops being just SEO and becomes data for an LLM, I think in four layers.

The first is content modeled as entities: types, fields, what null means, one stable identifier per entity, with source, update date and language recorded. It is the internal graph, independent of schema.org.

The second is public exposure: the internal graph translated into schema.org, injected into the initial HTML, with triple agreement guaranteed.

The third is RAG. Each chunk in the vector index carries the text, the entity's @id, the type, the date and the language. The same identifier vocabulary everywhere lets the LLM stitch together a coherent answer across the JSON-LD, the documents and the API.

The fourth is the application: tools with input and output schemas over the entities, the LLM in structured output, and the front end treating the answer as a view of the graph, always showing the source.

What ties the four together is the same entity model in the JSON-LD, the backend, the tools and the front-end types. And it is worth logging when the LLM cites an entity by @id, to compare with referrals from AI Overviews and ChatGPT after each schema change.

What the papers say helps

The original GEO paper, by Aggarwal and others, published at KDD 2024, tested content rewrites and measured visibility in the answers. The three methods that worked best were citing sources, adding direct quotations and adding statistics, with a relative gain of 30% to 40% on the position-adjusted word count metric. Citing other sources raises the chance that you get cited.

The rest is the basics done well: answer at the top, short paragraphs, descriptive headings, a single H1, real datePublished and dateModified, and primary sources with links.

Checklist

  1. A single @graph block per page
  2. Organization, WebSite and Person declared once, anchored at the root with #
  3. Page entities anchored at the URL, referencing site entities by @id
  4. Site entities before page entities
  5. sameAs to Wikidata on Organization and Person, when an entry exists
  6. knowsAbout with the real topics of authority
  7. SpeakableSpecification pointing to the summary and the headings
  8. Dataset when there is a data table, with license and temporalCoverage
  9. DefinedTerm for your own vocabulary
  10. dateModified updated on every real edit
  11. JSON-LD in the initial HTML; in a native app, through an endpoint
  12. Schema typed in the backend language
  13. Triple agreement between text, semantic HTML and JSON-LD
  14. AI user agents allowed in robots.txt
  15. Validation in the Schema Markup Validator and in CI
  16. NLWeb /ask and /mcp once the graph is stable
  17. llms.txt last, if at all

References

  1. Pranjal Aggarwal et al., GEO: Generative Engine Optimization, KDD 2024
  2. Arlen Kumar and Leanid Palkhouski, AI Answer Engine Citation Behavior: An Empirical Analysis of the GEO-16 Framework, 2025
  3. Mahe Chen et al., Generative Engine Optimization: How to Dominate AI Search, 2025
  4. Junwei Yu et al., Structural Feature Engineering for Generative Engine Optimization, 2026, which measured 17.3% more citations by changing only the structure of the content
  5. W3C, JSON-LD Best Practices
  6. Google, schema-dts
  7. nlweb-ai/NLWeb, the NLWeb reference implementation