SEO & AI Search

Schema Markup for AI Search: What Actually Helps a Crawler

Most schema advice is a list of types to add. The harder questions are which ones a machine can actually use, and what has to be true of your HTML before any of it counts.

SEO & AI SearchMohsin Ali, CTO at DevfinixBy Mohsin AliPublished 13 min read
A reader searching a library card catalogue, structured records a machine can use

Schema markup is not a ranking lever for AI search, and the first honest thing to say about schema markup for AI search is that Google says so itself. Its guide to optimising for generative AI features states plainly that "structured data isn't required for generative AI search, and there's no special schema.org markup you need to add." What structured data does buy you is narrower and more reliable: it removes ambiguity about who published a page, who wrote it, what the organisation behind it is, and how the pages relate to one another. That is worth doing. It is not the thing most articles on this topic claim it is.

This piece uses one real codebase as the worked example — the site you are reading. Every snippet below is the code that produces the JSON-LD on this page.

What schema markup for AI search does, and what it does not

Three things it genuinely does:

  • Makes rich results possible. This is the only documented, mechanical payoff. Google's introduction to structured data ties specific types to specific SERP features, and nothing else gets you those.
  • States facts a parser would otherwise infer. Publication date, author identity, publisher, article section, breadcrumb position. In prose these are scattered across a byline, a footer and a URL. In JSON-LD they are typed fields.
  • Connects pages into one graph. This is the part covered further down, and the part most implementations skip.

Four things it does not do:

  • It does not rank you, in classic search or in an AI answer.
  • It does not make thin content substantial. A Service node describing a page with two paragraphs on it describes a page with two paragraphs on it.
  • It does not earn a citation. No public documentation from any assistant vendor says "we prefer pages with JSON-LD", and you should be sceptical of anyone who tells you otherwise without a source.
  • It does not work if the fact is not on the page. Marking up a rating, a price or an answer that a visitor cannot see is a structured data policy violation, and it is a common reason sites lose rich results.

That last point has a strict corollary we enforce in this repo: no AggregateRating and no Review markup anywhere, because there is no review platform behind a rating claim yet. Schema that asserts something you cannot back is worse than no schema. Rating markup is also the thing most often lost by accident: review data that does not survive a platform move takes the stars in the search result with it, which is one of the traps in the WooCommerce to Shopify migration checklist.

The types that earn their place

Seven types cover almost every page on a normal agency or SaaS site. The temptation is to add more. Resist it — a page with nine schema types and no clear primary entity is harder to parse, not easier.

Organization and WebSite

These are declared once, in the root layout, and never repeated. They are the anchor nodes everything else references. Here is the real block from src/app/layout.tsx:

const organizationSchema = {
  "@context": "https://schema.org",
  "@type": "ProfessionalService",
  "@id": ORGANIZATION_ID,
  name: SITE.name,
  url: SITE.url,
  logo: `${SITE.url}/images/logo.png`,
  image: `${SITE.url}/og-image.png`,
  description: SITE.description,
  foundingDate: "2022",
  founder: { "@type": "Person", name: SITE.founder },
  telephone: SITE.phone,
  email: SITE.email,
  areaServed: AREA_SERVED,
  sameAs: [
    "https://www.facebook.com/devfinix.official",
    "https://www.instagram.com/devfinix.official/",
    "https://www.linkedin.com/company/devfinix",
  ],
  knowsAbout: [
    "Web Development",
    "Shopify Development",
    "WordPress Development",
    "Mobile App Development",
    "Search Engine Optimization",
    "Answer Engine Optimization",
    "Google Ads",
    "Meta Ads",
  ],
}

const websiteSchema = {
  "@context": "https://schema.org",
  "@type": "WebSite",
  "@id": WEBSITE_ID,
  url: SITE.url,
  name: SITE.name,
  publisher: { "@id": ORGANIZATION_ID },
}

Two details matter more than the type name. sameAs should only ever contain profiles that exist — every URL in it is a claim that this entity and that profile are the same thing, so a wrong one is a false entity claim. And WebSite points at the organisation by reference rather than restating it. That reference is the pattern the rest of the graph copies.

Service

Service pages get a Service node whose provider is a pointer, not a copy:

export function buildServiceSchema(opts: {
  serviceType: string
  name: string
  description: string
  path: string
}) {
  return {
    "@context": "https://schema.org",
    "@type": "Service",
    serviceType: opts.serviceType,
    name: opts.name,
    provider: { "@id": ORGANIZATION_ID },
    areaServed: AREA_SERVED,
    description: opts.description,
    url: `${BASE_URL}${opts.path}`,
  }
}

The provider line is four tokens doing the work a duplicated Organization block does badly. Our SEO and AEO service page renders exactly this node. On client work the same discipline carries the sector-specific type: GeoTagImg ships SoftwareApplication, FAQPage, Organization and WebSite nodes across fifteen locales, and the Schultz Inc. site ships LegalService schema beside a filterable portfolio of fifteen documented transactions.

BlogPosting and Person

An article has two entity claims worth making: who published it and who wrote it. The publisher is the organisation node again. The author is a Person that lives properly on the author profile page and is referenced from every article they wrote:

export const authorEntityId = (slug: string) => `${authorUrl(slug)}#person`

export function buildBlogPostingSchema(post: PostSummary, author: Author) {
  const url = postUrl(post.slug)
  return {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    "@id": `${url}#article`,
    headline: post.title,
    description: post.description,
    url,
    mainEntityOfPage: { "@type": "WebPage", "@id": url },
    isPartOf: { "@id": BLOG_ID },
    datePublished: post.publishedAt,
    dateModified: post.updatedAt ?? post.publishedAt,
    author: {
      "@type": "Person",
      "@id": authorEntityId(author.slug),
      name: author.name,
      url: authorUrl(author.slug),
    },
    publisher: { "@id": ORGANIZATION_ID },
    image: postImageUrl(post.slug),
    wordCount: post.wordCount,
    timeRequired: `PT${post.readingMinutes}M`,
    articleSection: category?.name ?? post.category,
    keywords,
    inLanguage: "en",
  }
}

The full Person node — job title, photo, knowsAbout, worksFor — is declared once on the author profile page, under the same @id. Everything else is a stub reference to it. The sameAs array on that Person is built only from verified profile links, and an author with none gets no sameAs key at all rather than an invented one.

FAQPage

Straightforward to generate, and easy to ship carelessly:

export function buildBlogFaqSchema(faq: Faq[]) {
  if (!faq.length) return null
  return {
    "@context": "https://schema.org",
    "@type": "FAQPage",
    mainEntity: faq.map(({ question, answer }) => ({
      "@type": "Question",
      name: question,
      acceptedAnswer: { "@type": "Answer", text: answer },
    })),
  }
}

The risk is not in the builder. It is in the accordion that renders the visible version, which the rendering section below deals with.

Cheap, and one of the few types that reliably changes what a SERP entry looks like. The blog version builds the trail and appends a leaf:

export function buildBlogBreadcrumbSchema(leaf?: { name: string; path: string }) {
  const trail = [
    { "@type": "ListItem", position: 1, name: "Home", item: BASE_URL },
    { "@type": "ListItem", position: 2, name: "Blog", item: `${BASE_URL}${blogPath}` },
  ]
  if (leaf) {
    trail.push({ "@type": "ListItem", position: 3, name: leaf.name, item: `${BASE_URL}${leaf.path}` })
  }
  return {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: trail,
  }
}

WebPage and its subtypes

Everything that is not a service or an article still deserves a page-level node saying what it is. One helper covers contact pages, listing pages, legal pages and the rest:

export function buildWebPageSchema(opts: {
  type?: "WebPage" | "ContactPage" | "CollectionPage" | "AboutPage"
  name: string
  description: string
  path: string
}) {
  return {
    "@context": "https://schema.org",
    "@type": opts.type ?? "WebPage",
    "@id": `${BASE_URL}${opts.path}#webpage`,
    name: opts.name,
    description: opts.description,
    url: `${BASE_URL}${opts.path}`,
    isPartOf: { "@id": WEBSITE_ID },
    about: { "@id": ORGANIZATION_ID },
    inLanguage: "en",
  }
}

Note isPartOf and about. Both are references. That is the third helper in a row doing the same thing, which is the actual argument of this article.

Entity linking beats adding more types

Here is the failure mode almost every site has. Each page carries its own self-contained Organization block, copied out of a generator. Forty pages, forty Organization nodes, each with a name and a logo and no identifier. To a parser, that is not one company described forty times. It is forty documents that each mention a company with the same name, which is a resolution problem rather than a fact.

The fix is an identifier and a reference. This repo declares them as two constants:

export const ORGANIZATION_ID = "https://devfinix.com/#organization"
export const WEBSITE_ID = "https://devfinix.com/#website"

Those are URIs, not URLs — nothing has to resolve at /#organization. They only have to be stable and globally unique, which is why they are absolute and include the domain. Every other node then says provider: { "@id": ORGANIZATION_ID } or the equivalent instead of restating the organisation.

What you end up with is a connected graph rather than a pile of documents:

NodeLives onReferences
#organizationroot layout, so every page
#websiteroot layout, so every pagepublisher → #organization
/blog#blogblog indexisPartOf → #website, publisher → #organization
/blog/<slug>#articlearticle pageisPartOf → /blog#blog, author → #person, publisher → #organization
/blog/author/<slug>#personauthor profileworksFor → #organization
Service nodeservice pageprovider → #organization
CollectionPagecategory archiveisPartOf → /blog#blog, about → #organization

A catalogue drawer pulled open, one entity linked to the next

Walk that from any entry point and you can answer "who wrote this, who published it, what else have they written, what does that company do" without parsing a word of prose. That is the defensible version of the claim that schema helps machines understand your site. It is a disambiguation aid, not a ranking signal, and it costs almost nothing once the two constants exist.

One practical rule: a reference stub should carry the @id and very little else. If your stub repeats the full node, you have written the duplication problem back in with extra steps.

Schema is decoration if the crawler cannot see the content

This is the point missing from every page-one result for this term, and the one that actually decides outcomes.

JSON-LD describes content. If the content is not in the HTML the crawler receives, the schema is a set of claims about something that was never delivered. Google states it "is able to process content within JavaScript as long as it isn't blocked", while warning in the same guide that working with JavaScript frameworks is "generally more complex". Other fetchers make no rendering promise at all, and you cannot audit what they do.

So the rule we apply is blunt: if a fact appears in your JSON-LD, it must appear in the server-rendered HTML.

Markup on a monitor, the JSON-LD block itself

Three ways this breaks in practice, all of which we have hit.

Conditionally mounted FAQ answers. An accordion written so the answer element only exists while the item is open removes the answer text from the server-rendered HTML entirely. The FAQPage schema still validates — it is just JSON in a script tag — so nothing fails anywhere. Meanwhile the page a fetcher receives contains five questions and no answers. That exact bug shipped on this site's homepage FAQ once. The fix is to keep the element mounted and animate its height between zero and auto, never to mount conditionally. It is now a standing rule in our structured-data checklist, for the same reason it was a bug in the first place: it is invisible until someone diffs the raw HTML against the rendered page.

Animating the LCP element from zero opacity. A hero heading that starts fully transparent is at least present in the HTML, so this one is less severe, but it belongs to the same family: the rendered state and the delivered state disagree. On this site only transform is animated on the hero heading, and a unit test guards it. Why that mattered enough to test is in do Core Web Vitals affect rankings.

Client-only data fetching. If the article body, the product price or the FAQ arrives from a browser request after hydration, the initial HTML is a skeleton. Server-render it, or accept that some fetchers will only ever see the skeleton.

Checking this takes one command. Fetch the page the way a fetcher does, with no JavaScript engine involved:

curl -s https://example.com/some-page | grep -c 'application/ld+json'
curl -s https://example.com/some-page | grep -c 'an exact sentence from your FAQ answer'

If the second command returns 0 while the first returns 1 or more, your schema is describing a page that does not exist for anything that does not run JavaScript. In the Next.js App Router this usually means a component is a client component when it did not need to be, or data is being fetched in an effect. Both are fixable in an afternoon of ordinary front-end development, and both matter more than any type you could add. On a decoupled CMS every one of these tags becomes your front-end's responsibility, which is one of the underrated costs of going headless.

Rendering the markup

One component, used everywhere, so there is exactly one place where JSON-LD becomes a script tag:

type Props = {
  data: Record<string, unknown>
}

export function JsonLd({ data }: Props) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  )
}

That is the whole implementation. It is a server component, so the script tag lands in the initial HTML rather than being injected on the client — which, per the previous section, is the entire point. Pages call it once per node:

<JsonLd data={buildBlogPostingSchema(post, author)} />
{faqSchema && <JsonLd data={faqSchema} />}

Multiple sibling ld+json blocks are valid and easier to reason about than one large graph array, because a broken node then fails alone instead of taking its neighbours with it.

Validating what you shipped

Two validators, two different jobs, and you want both:

  • validator.schema.org checks whether your markup is valid schema.org. It is vocabulary-level and vendor-neutral.
  • The Rich Results Test checks whether Google recognises a supported rich result type. Valid markup that Google has no feature for passes the first and shows nothing in the second, which is normal rather than a bug.

Neither of them checks the thing that breaks most often, so we assert that in CI instead. The audit script crawls every URL in sitemap.xml, parses every ld+json block, and fails if any of them do not parse or if a page carries no page-level schema at all. The sitewide Organization and WebSite nodes deliberately do not count towards that, because they are on every page by definition and tell you nothing about the one you are looking at.

Then add the check no tool will do for you: take a claim out of your JSON-LD, search the raw HTML for it, and confirm it is there.

Where to start

Adding structured data to an existing site, in the order that gets the most from the least work:

  1. Declare Organization and WebSite once with stable @id values, and delete every duplicated Organization block on individual pages.
  2. Give every page exactly one page-level type that says what the page is.
  3. Add BreadcrumbList everywhere except the homepage.
  4. Replace every duplicated entity in your existing markup with an @id reference.
  5. Only then look at FAQPage, Person and the rest — and before adding any of them, curl the page and confirm the content they describe is in the HTML.

Steps one to four are usually a day's work and are where the entity graph comes from. Step five is where most people start, which is why so many sites have twelve schema types and no coherent entity. If the same question is on your mind for AI crawlers specifically — how they find your pages, and whether a file can help them — the companion piece on implementing llms.txt in Next.js covers that side, including why the rendering question still matters more than the file does.

Frequently asked questions

Does schema markup help you rank in AI Overviews?
Not directly. Google's own generative AI optimisation guide states that structured data is not required for generative AI search and that there is no special schema.org markup to add for it. Schema still earns its place because it makes you eligible for rich results in Search, and because it states publisher, author and date facts unambiguously instead of leaving them to be inferred from prose.
Which schema types should every page have?
One sitewide Organization node and one WebSite node, declared once in the root layout, plus exactly one page-level type that describes what the page is: Service for a service page, BlogPosting for an article, ContactPage or CollectionPage for the rest. Add BreadcrumbList on everything except the homepage. That is the whole baseline, and more types rarely help.
Does the @id property matter, or can I repeat the same data?
It matters. Repeating an Organization block on forty pages describes forty organisations that happen to share a name. Declaring it once with a stable @id and referencing that @id everywhere else describes one organisation mentioned forty times. The second version is what lets a parser resolve the entity rather than guess at it.
Do I need FAQPage schema if the answers are already on the page?
The schema is optional, but the rule underneath it is not: every answer inside FAQPage markup must also exist in the HTML the crawler receives. A common accordion bug mounts the answer only when the item is open, which strips it from the server-rendered HTML and leaves the schema making claims the delivered page does not support.
Will schema markup work if my site renders client-side?
Sometimes, and that is the problem. Google states it can process content in JavaScript when it is not blocked, but it also warns that JavaScript sites are harder to get right, and other crawlers make no rendering promise at all. If your page is an empty shell until hydration, the schema is a set of claims about content the fetcher never saw.
Share

About the author

Mohsin Ali, CTO at Devfinix

Mohsin Ali

CTO

Devfinix's CTO. Writes about the engineering side of search — rendering, structured data and what crawlers can actually see.

Work with us

Want this done properly?

We build and market the things we write about. Tell us what you're working on and we'll give you a straight answer on how we'd approach it.

Start a Project

Newsletter

One useful email a month

What we learned shipping client work — the fixes that moved numbers and the ones that didn't. No pitches, and we stop the moment you ask.

By subscribing you agree to receive occasional emails from Devfinix. Reply to any email and we will remove you.

Rule the Web!

Request a Web Design and Marketing Proposal.