Identity Pillar · Implementation Pattern
Declare structured data once per page, consistently — when content is composed from many components and data sources.
Whether structured data is declared once per page, in one consolidated block, in the initial HTML — generated from the same data that the visible components render.
A site that has Organization schema on its homepage passes I1. A site whose schema is spread across many per-component blocks, fragments at scale, drifts from the visible content, or relies on JavaScript to render — fails this pattern, even if the homepage looks fine in isolation.
Agents prefer to parse one structured graph per page. Fragmented schema across multiple script tags is harder to read; properties get merged or dropped inconsistently by different parsers.
Consolidated schema, generated from the same source as visible content, stays consistent for users of all kinds — screen readers, agents, and search engines see the same story.
When schema and visible content drift apart, agents discount the schema as potentially cloaked or stale. Consolidation reduces the surface area where drift can occur.
Schema.org is the vocabulary (the words: Organization, openingHours). There are three syntaxes that express it in HTML:
Use JSON-LD exclusively. Reasons:
itemprop through every component, which becomes brittle as the template evolves.The legitimate Microdata argument, and how to address it without Microdata
Microdata is bound to visible content, so schema cannot drift from what humans see. The same anti-drift outcome is achieved by requiring every schema'd field to also render in initial HTML — see R2 — Initial HTML completeness. JSON-LD plus the initial-HTML rule gives the same guarantee with fewer moving parts.
Components own visible HTML. The page owns the schema block.
Both read from the same data, so they cannot drift. One consolidated <script type="application/ld+json"> per page, in <head>, in initial HTML.
For each top-level node in your @graph, write a pure function that takes the relevant data object and returns the JSON-LD fragment. These live in a dedicated module (for example schema/builders.ts) and are independent of any component.
// schema/builders.ts — pure functions, one per @graph node
export function buildOrgNode(location) {
return {
"@type": "LocalBusiness",
"name": location.name,
"url": location.url,
"address": {
"@type": "PostalAddress",
"streetAddress": location.address,
"addressLocality": location.city,
"addressRegion": location.state,
"postalCode": location.zip,
},
"telephone": location.phone,
}
}
export function buildHoursNode(location) {
return {
"@type": "LocalBusiness",
"openingHoursSpecification": location.hours.map(h => ({
"@type": "OpeningHoursSpecification",
"dayOfWeek": h.days,
"opens": h.opens,
"closes": h.closes,
}))
}
} Builder functions are easy to unit-test, easy to audit, and reusable across pages that share schema (for example, every location page declares the same Organization shape).
The page fetches data, calls each builder, and emits one consolidated block. Visible components receive the same data as props and render HTML only — no fetching inside components, no schema inside components.
<!-- pages/locations/[slug].vue -->
<script setup>
const { data: location } = await useFetch('/api/locations/' + slug)
const { data: services } = await useFetch('/api/services/' + slug)
const { data: reviews } = await useFetch('/api/reviews/' + slug)
useHead({
script: [{
type: 'application/ld+json',
innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@graph": [
buildOrgNode(location.value),
buildHoursNode(location.value),
buildServicesNode(services.value),
buildAggregateRating(reviews.value),
]
})
}]
})
</script>
<template>
<HeroBlock :location="location" />
<ServicesBlock :services="services" />
<ReviewsBlock :reviews="reviews" />
</template> When the page is composed from a large library of CMS blocks (Storyblok, Sanity, Contentful with 20+ block types), the page cannot know which schema each block needs. Use a contribution registry: each block component declares its schema fragment to a shared store; the page emits the combined block at the end.
This pattern adds indirection — use it only when the block library is genuinely large. Default to Step 2 unless you have 10+ block types ship to the same page.
<script type="application/ld+json"> block in <head>@graph parses cleanly❌ Per-component <script> tags
Fragments the @graph; Google merges overlapping @type properties inconsistently; harder to audit.
❌ useEffect injecting <script type="ld+json">
Schema ends up outside initial HTML. LLM-based agents that do not run JavaScript miss it entirely.
❌ next/script strategy="lazyOnload" wrapping JSON-LD
Defeats the purpose of structured data — the script loads after the agent has already left.
❌ Schema reads from CMS, visible HTML reads from a different API
Two sources of truth for the same content. Drift is a matter of when, not if.
❌ Mixing JSON-LD with Microdata on the same page
Google explicitly recommends picking one. Mixed syntaxes can confuse parsers.
A regional housing nonprofit operates 30+ resource center locations. Each location page is composed from CMS blocks — a hero, a services list, hours, contact, testimonials. Early in the build, each block component rendered its own JSON-LD fragment.
❌ Fragmented — per-component schema
Each block ships its own JSON-LD block
<!-- Three components each rendering their own JSON-LD block -->
<HoursSchema location={location} />
<ServicesSchema services={services} />
<ReviewsSchema reviews={reviews} />
<!-- Result in <head>: three separate <script> tags,
each declaring overlapping @type properties.
Agents see a fragmented graph; some properties
get merged inconsistently, some get dropped. -->
Result: each parser merged the three @type blocks differently. Some properties got dropped silently; Rich Results Test flagged duplicate declarations.
✓ Consolidated — one block per page
Page composes from builder helpers; components stay schema-free
<!-- pages/locations/[slug].vue -->
<script setup>
const { data: location } = await useFetch('/api/locations/' + slug)
const { data: services } = await useFetch('/api/services/' + slug)
const { data: reviews } = await useFetch('/api/reviews/' + slug)
useHead({
script: [{
type: 'application/ld+json',
innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@graph": [
buildOrgNode(location.value),
buildHoursNode(location.value),
buildServicesNode(services.value),
buildAggregateRating(reviews.value),
]
})
}]
})
</script>
<template>
<HeroBlock :location="location" />
<ServicesBlock :services="services" />
<ReviewsBlock :reviews="reviews" />
</template>
Result: one clean @graph. All 30 location pages now declare the same Organization shape, sourced from the same builder. Schema audits run in minutes instead of hours.
schema/builders.ts — reused across all 30 location pages and the homepageTechnically yes — schema.org allows multiple blocks. In practice, one consolidated block using @graph is easier to maintain, easier to audit, and parses more reliably across agents. Reserve multiple blocks for special cases like a separate BreadcrumbList schema that has historical precedent for living standalone.
They can work — RSC schema components render server-side and end up in the initial HTML. But the fragmentation problems remain (overlapping @type properties, harder audits). The consolidation pattern still wins in maintainability, even when fragmentation does not break initial-HTML.
Use the registry pattern (Step 3). Each block component calls a contribute(node) function during render; the page emits the combined block at the end. Set up a build-time test that asserts every expected @type appears in the graph, so a forgotten contribute() does not silently break.
Both work, agents and parsers find it either way. <head> is conventional and ensures the block is in the initial HTML response even if the page body is large. Put it in <head>.
The consolidation pattern only works if the page renders server-side. If the page builds the JSON-LD client-side at hydration, it ends up outside initial HTML and fails R2. Both rules apply together — see the R2 guide.
Microdata is valid schema.org, but it carries the same caching and maintenance trade-offs we just covered. If a team has strong Microdata investment, you can ship valid Microdata — just do not mix it with JSON-LD on the same page. For new builds, JSON-LD is the better default.
Use this to confirm the consolidation pattern is implemented before moving on.