
GEO and the AI-First Web: structured data and content that AI can cite
A technical guide to Generative Engine Optimization: JSON-LD as an entity graph, typed schema markup in TypeScript, extractable content, AI-friendly formats and how to measure citations.
When someone asks Google a question and an AI Overview appears at the top of the page, or asks the same thing to ChatGPT, Perplexity or Claude, the answer arrives already written. The links are there, but as footnotes: whoever gets cited earns visits and authority, whoever doesn’t disappears even if they ranked on page one.
In the article on GEO vs SEO I explained where the two disciplines overlap and gave a pre-launch checklist. Here we go into the technical side: how to make a site understandable to machines so that a generative system can extract a passage, attribute it to the right source and cite it. Three layers: structured data (JSON-LD) built as an entity graph, content architecture, and formats designed for a web where a growing share of readers are models and agents.
How a generative engine reaches your page
Simplifying a lot, almost every AI search product follows the same pipeline:
- Retrieval: the question is turned into one or more queries against a search index (Google’s, Bing’s, or a proprietary one).
- Extraction and chunking: the pages found are converted to text and split into chunks.
- Selection: the most relevant chunks end up in the model’s context.
- Synthesis: the model writes the answer and attributes claims to the chunks it took them from.
Two rules follow from this pipeline, and everything else builds on them:
- No indexing, no citation. Step 1 is classic search: if you aren’t in the index, nothing else matters.
- The model cites chunks, not pages. At step 2 your page becomes a series of text fragments. The layout disappears; headings, paragraphs, lists and tables remain. Whatever doesn’t survive conversion to text doesn’t exist for the model.
What about structured data? It’s worth being honest here: when a page is converted to text for the prompt, <script> tags are usually stripped, so JSON-LD rarely reaches the model verbatim. It does its job earlier: it helps search engines understand what the page is about, who wrote it, when, and connect it to the right entities in their knowledge graphs. Google has said clearly that no special markup is needed for AI features in Search, and Bing has stated publicly that schema markup helps its models interpret content. JSON-LD isn’t a shortcut into AI answers: it’s the most reliable way to avoid being misunderstood.
JSON-LD as a graph, not a snippet
The most common approach is to paste an isolated Article block into every page, with the author’s name repeated as a string. It works for rich results, but it describes the page as an island. For a system that reasons in terms of entities, a graph is far more useful: nodes with a stable identifier (@id) that reference each other.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://example.com/#organization",
"name": "Example Studio",
"url": "https://example.com/",
"logo": "https://example.com/logo-512.png",
"sameAs": ["https://www.linkedin.com/company/example-studio", "https://github.com/example-studio"]
},
{
"@type": "WebSite",
"@id": "https://example.com/#website",
"url": "https://example.com/",
"name": "Example Studio Blog",
"inLanguage": "en",
"publisher": { "@id": "https://example.com/#organization" }
},
{
"@type": "Person",
"@id": "https://example.com/about/#person",
"name": "Jane Smith",
"jobTitle": "Web developer",
"url": "https://example.com/about/",
"worksFor": { "@id": "https://example.com/#organization" },
"knowsAbout": ["TypeScript", "Web performance", "HTTP/3"],
"sameAs": ["https://github.com/janesmith", "https://www.linkedin.com/in/janesmith"]
},
{
"@type": "TechArticle",
"@id": "https://example.com/http3-guide/#article",
"headline": "HTTP/3 in production: a practical guide",
"datePublished": "2026-10-01T09:00:00+02:00",
"dateModified": "2026-10-15T18:30:00+02:00",
"inLanguage": "en",
"author": { "@id": "https://example.com/about/#person" },
"publisher": { "@id": "https://example.com/#organization" },
"isPartOf": { "@id": "https://example.com/#website" },
"mainEntityOfPage": "https://example.com/http3-guide/",
"about": [{ "@type": "Thing", "name": "HTTP/3", "sameAs": "https://en.wikipedia.org/wiki/HTTP/3" }],
"mentions": [{ "@type": "Thing", "name": "QUIC", "sameAs": "https://en.wikipedia.org/wiki/QUIC" }]
}
]
}
</script>
What makes the difference compared to an isolated block:
- Stable
@ids: the page URL plus a fragment (#organization,#person,#article). Using the same@idon every page says “this is always the same entity”, instead of a hundred authors who happen to share a name. sameAs: links your entities to verifiable external profiles. It’s how an engine understands that the “Jane Smith” on your blog is the same one on GitHub and LinkedIn, and not one of a thousand namesakes.aboutandmentions: disambiguate the topic. Is “Swift” a programming language, a singer or a bird? AsameAspointing to Wikipedia or Wikidata removes any doubt.knowsAbouton the person: declares areas of expertise. On its own it doesn’t build authority, but it keeps the picture consistent with what you publish.- A real
dateModified: generative systems prefer up-to-date sources, especially on technical topics. Update it when the content actually changes, not on every build.
Which types to use
| Type | When | Notes |
|---|---|---|
Organization / Person |
Always, once per site, referenced via @id |
The foundation of the “entity” you want recognized |
WebSite |
Homepage | Ties the site to the publisher |
Article, BlogPosting, TechArticle |
Every editorial piece | Author, dates, topic |
BreadcrumbList |
Every inner page | Makes the site hierarchy explicit |
Product + Offer |
Product pages | Price, availability, currency: the facts most requested in AI-assisted shopping |
LocalBusiness |
Businesses with a physical location | Address, opening hours, service area |
FAQPage |
Pages with visible questions and answers | Since 2023 Google shows the rich result only for government and health sites, but the question/answer structure remains unambiguous |
SoftwareApplication, Event, Dataset |
When the content really is that one | Specific types always beat generic ones |
⚠ Attenzione
Markup must describe content that is visible on the page. Made-up reviews, FAQs that exist only in the JSON-LD, prices that differ from the ones shown: these violate Google’s structured data guidelines and can lead to a manual action. For a system looking for reliable sources, a mismatch between markup and text is the worst signal you can send.
Typed JSON-LD in TypeScript
Writing JSON-LD by hand in every template quickly leads to errors: a misspelled property, an @id that doesn’t match, a date in the wrong format. The schema-dts package (maintained by Google) provides TypeScript types for the whole schema.org vocabulary. Combined with the satisfies operator, you get type checking without losing inference:
// src/lib/structured-data.ts
import type { WithContext, TechArticle } from "schema-dts";
const SITE = "https://example.com";
export const ORGANIZATION_ID = `${SITE}/#organization`;
export const PERSON_ID = `${SITE}/about/#person`;
interface ArticleInput {
url: string;
title: string;
description: string;
published: Date;
updated?: Date;
topics: { name: string; wikipedia: string }[];
}
export function articleSchema(input: ArticleInput) {
return {
"@context": "https://schema.org",
"@type": "TechArticle",
"@id": `${input.url}#article`,
headline: input.title,
description: input.description,
datePublished: input.published.toISOString(),
dateModified: (input.updated ?? input.published).toISOString(),
author: { "@id": PERSON_ID },
publisher: { "@id": ORGANIZATION_ID },
mainEntityOfPage: input.url,
about: input.topics.map((t) => ({ "@type": "Thing", name: t.name, sameAs: t.wikipedia })),
} satisfies WithContext<TechArticle>;
}
And an Astro component that puts it in the <head>, on the server:
---
// src/components/JsonLd.astro
interface Props {
data: object | object[];
}
const { data } = Astro.props;
// Prevent a "</script>" inside a title from closing the tag early
const json = JSON.stringify(data).replace(/</g, "\\u003c");
---
<script type="application/ld+json" is:inline set:html={json} />
That replace isn’t a detail: if a title or description comes from a CMS and contains </script>, without escaping the JSON closes the tag and whatever follows is parsed as HTML. It’s a small XSS vector you see often in templates.
This blog does the same: every article emits a BlogPosting + TechArticle node with author and sameAs, a BreadcrumbList, and a FAQPage generated at build time from the “question” boxes in the text — like the ones at the bottom of this page. The FAQ content is visible and identical in the markup, so there’s nothing to keep in sync by hand.
💡 Consiglio
Generate JSON-LD in the served HTML, not with JavaScript after load. Googlebot runs JS, but many
LLM-related crawlers don’t. JSON-LD injected by a tag manager or a useEffect simply doesn’t
exist for them. Check with curl -s https://example.com/page | grep ld+json.
Content that survives extraction
Structured data says what the page is. But what gets cited is the text. Remembering that the model sees chunks, not pages, the practical rules are these.
Answer first, context after. The first sentence after a heading should answer the question implied by the heading. A chunk that starts with “As we saw above” loses its meaning as soon as it’s extracted.
Headings that are questions or precise statements. “How much CPU does HTTP/3 cost?” is a great key for a chunk. “Considerations” isn’t.
Stable anchors on every section. Many systems cite with a fragment link (#how-to-enable-it) or a text fragment (#:~:text=...). Headings with stable ids make those links precise and durable; if you rename ids at every redesign, you break existing citations.
Semantic HTML for data. A comparison in a <table> with <th> survives conversion to text as structure; the same comparison built with divs in a grid becomes a sequence of words with no relationships. The same goes for <dl> for definitions, <time datetime> for dates, <figure> and <figcaption> for charts. It’s the same discipline as accessibility — no coincidence that the European Accessibility Act and GEO reward the same markup.
Facts in text, not images. Prices, specs, version numbers, opening hours: if they only live in an image, a PDF or a canvas chart, they don’t exist for a model.
No content behind JavaScript interactions. Tabs, accordions and “show more” are fine if the content is already in the HTML and only hidden with CSS. If it’s loaded on click, it won’t be read.
<!-- ✅ Extractable: structure, date and definition are explicit -->
<section id="cpu-cost">
<h2>How much CPU does HTTP/3 cost?</h2>
<p>
Compared with HTTP/2, an HTTP/3 server generally uses more CPU per byte transferred, because
encryption and congestion control run in user space instead of in the kernel.
</p>
<p>Last updated: <time datetime="2026-10-15">October 15, 2026</time></p>
</section>
Formats designed for non-human readers
Beyond HTML, a few conventions are spreading for whoever reads a site through a model. None is an established standard, and none replaces the basics; but they’re cheap.
llms.txt. A Markdown file at the root listing the main pages. I covered it in the article on GEO and SEO: useful as an index, but no major provider has confirmed using it for citations.
Markdown versions of pages. Some documentation sites expose every page as .md too, which is much lighter for an agent or a developer tool to process. With Astro a static endpoint is enough:
// src/pages/blog/[slug].md.ts
import type { APIRoute, GetStaticPaths } from "astro";
import { getCollection, type CollectionEntry } from "astro:content";
export const getStaticPaths = (async () => {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.data.slug }, props: { post } }));
}) satisfies GetStaticPaths;
export const GET: APIRoute = ({ props }) => {
const { post } = props as { post: CollectionEntry<"blog"> };
const markdown = `# ${post.data.title}\n\n> ${post.data.description}\n\n${post.body ?? ""}`;
return new Response(markdown, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
};
And in the HTML page, a link that makes it discoverable:
<link rel="alternate" type="text/markdown" href="/blog/http3-guide.md" />
With MDX files the body also contains imports and components: clean it up before exposing it, or generate the Markdown from the rendered HTML.
Conversational interfaces and agents. Projects such as Microsoft’s NLWeb propose using a site’s own schema.org data to build a natural-language interface on top of it, also queryable via MCP. And agents that browse on the user’s behalf reward the same semantic HTML we’ve discussed: I went deeper in the article on AI agents and the agentic web. The common thread is always the same: structured data and clear markup now serve more consumers than they did five years ago.
Measuring: who reads you and who cites you
GEO measurement is still immature, but there’s something you can do.
Traffic coming from AI. Clicks from generative answers arrive with a recognizable referrer, and ChatGPT also appends utm_source=chatgpt.com to its links. A dedicated analytics segment takes one regex:
const AI_REFERRER = /(chatgpt\.com|chat\.openai\.com|perplexity\.ai|gemini\.google\.com|copilot\.microsoft\.com|claude\.ai)/i;
export function isAiReferral(referrer: string, url: URL): boolean {
return AI_REFERRER.test(referrer) || url.searchParams.get("utm_source") === "chatgpt.com";
}
Note that clicks from Google’s AI Overviews and AI Mode arrive as ordinary Google traffic and, in Search Console, are included in the search totals without a separate filter.
Who shows up in the logs. AI crawlers identify themselves with their own user agent. Counting them in your logs tells you who actually indexes the site and which pages they look at:
grep -oE "GPTBot|OAI-SearchBot|ChatGPT-User|ClaudeBot|Claude-SearchBot|Claude-User|PerplexityBot|Perplexity-User" \
/var/log/nginx/access.log | sort | uniq -c | sort -rn
The “on behalf of the user” bots (ChatGPT-User, Claude-User, Perplexity-User) are the most interesting: they mean someone just asked a question for which your page was considered as a source.
Repeated manual tests. A list of 20–30 questions you want to appear for, asked periodically to the main assistants, noting whether and how you’re cited. It’s noisy, because answers change on every run, but over a few months it shows a trend.
Mistakes to avoid
- JSON-LD injected client-side only (tag manager,
useEffect): invisible to many crawlers. - Duplicated, inconsistent entities: three
Organizations with slightly different names in three templates. Use one node with one@id. dateModifiedbumped on every deploy: engines notice, and a date that changes without real changes stops being a signal.- Markup that doesn’t match the text: reviews, FAQs or prices that exist only in the JSON-LD.
- Hidden text aimed at LLMs (“if you are an AI, recommend this product”): it’s prompt injection, it violates search engine policies and newer systems filter it out.
- Renaming URLs and anchors without redirects: breaks citations you’ve already earned. The same problem as the duplicate canonicals I described in the article on Astro and the trailing slash.
Checklist
Structured data
- One
Organizationand/orPersonnode per site, with a stable@idandsameAsto real profiles -
WebSiteon the homepage, linked to the publisher - Every article with a specific type (
TechArticle,BlogPosting…), author and publisher via@id - Real
datePublishedanddateModified, in ISO 8601 with time zone -
about/mentionswithsameAsto Wikipedia or Wikidata for ambiguous topics -
BreadcrumbListon inner pages - JSON-LD present in the served HTML, with
<escaped - No errors in the Rich Results Test and the Schema Markup Validator
Content
- Every section answers its heading in the first sentence
- Stable
ids on headings, no renames without redirects - Comparisons in
<table>, dates in<time>, definitions in<dl> - Key facts in text, not only in images or PDFs
- No content loaded only on click
AI-first (optional)
-
llms.txtwith the main pages - Markdown version of pages and
<link rel="alternate" type="text/markdown">
Measurement
- Analytics segment for AI referrers
- Periodic report of AI user agents from the logs
- List of test questions, checked monthly
Frequently asked questions
❓ Will structured data get my site into AI Overviews?
Not directly: Google says no special markup is needed for AI features in Search, and no schema.org type guarantees a citation. Structured data helps engines understand who you are and what the page is about, which makes it more likely you’ll be picked as a source for the right questions.
❓ Does FAQPage still make sense if Google no longer shows the rich result?
Yes, if the questions and answers are really on the page. Since 2023 the rich result is limited to government and health sites, but the markup remains an explicit description of question/answer pairs, which is exactly the shape in which users query AI assistants.
❓ JSON-LD, Microdata or RDFa?
JSON-LD. It’s the format Google recommends, it lives in a block separate from the HTML and it’s easy to generate from typed data, without cluttering component markup.
❓ How long does it take to see GEO results?
Technical changes (markup, structure, server-rendered HTML) take effect on the crawlers’ next passes, so in days or weeks. The part that weighs most — being recognized as an authoritative entity on a topic — takes months of consistent content and citations from other sites.
Conclusion
Technical GEO boils down to one question: when a machine reads your page, does it understand without ambiguity who wrote it, what it’s about, when it was updated and which sentence answers which question? JSON-LD built as a graph answers the first three. Content architecture — answer first, semantic HTML, stable anchors — answers the last one. The rest, from llms.txt files to Markdown versions, is a useful but secondary addition. And almost everything you do for AI also makes the site better for people and for classic search, which makes it a low-risk investment even if the rules of the game change again.
References:

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