This is an implementation pattern that extends I1 — Organization Identity Declaration. I1 covers what to declare; this guide covers how to declare it consistently when a site has many pages, many components, and data from several sources.

What it measures

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.

Why it matters

For reach

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.

For access

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.

For trust

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.

Public-interest stakes: Significant. Mutual aid networks, regional housing nonprofits, and multi-site clinics often grow from one page to many. Without a consolidation pattern, the schema declared on page one drifts from page fifty within a year, and agents start to see contradictory identity claims across the same organization.

Why JSON-LD, not Microdata

Schema.org is the vocabulary (the words: Organization, openingHours). There are three syntaxes that express it in HTML:

Syntax Form Verdict
JSON-LD <script type="application/ld+json"> block in <head> Use this
Microdata itemscope, itemtype, itemprop attributes on visible HTML Do not mix with JSON-LD
RDFa vocab, typeof, property attributes on visible HTML Do not mix with JSON-LD

Use JSON-LD exclusively. Reasons:

  • Google explicitly recommends it as the preferred syntax. Mixing JSON-LD with Microdata on the same page can confuse parsers — Google guidance is "pick one."
  • Maintainable at scale. One script block per page, generated from one data source. Microdata requires weaving itemprop through every component, which becomes brittle as the template evolves.
  • Agent-friendly. LLM-based agents preferentially parse the JSON-LD block — clean structured object, no DOM traversal required.

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.

The stakes

5 One consolidated JSON-LD block per page, in <head>, in initial HTML. Schema generated server-side from data that visible components also consume. Builder helpers per @graph node, unit-testable.
4 One consolidated block per page, in initial HTML, hand-maintained. Risk of drift as the page evolves, but currently consistent with visible content.
3 Mixed — most schema in one block, but some components render their own schema fragments alongside. Inconsistencies likely.
2 Schema split across multiple per-component blocks. Overlapping @type declarations. Some blocks may be client-rendered.
1 No schema, or all schema injected via JavaScript after page load.

How to implement

The principle

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.

Step 1: Write schema-builder helpers

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).

Step 2: Compose at the page level

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>

Step 3: For sites with many CMS blocks, use a registry pattern

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.

Step 4: Verify

  1. View Page Source — confirm exactly one <script type="application/ld+json"> block in <head>
  2. Validate at Google Rich Results Test — confirm the consolidated @graph parses cleanly
  3. Spot-check every property declared in schema also appears in visible content on the page
  4. Disable JavaScript in DevTools, reload — both schema and visible content should still be present

Anti-patterns to avoid

❌ 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.

Real example

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.

What scaled the change

  • Builder functions in schema/builders.ts — reused across all 30 location pages and the homepage
  • Components became "dumb" — they receive data as props and render HTML, nothing else
  • A single unit-test suite covers all schema generation — no per-page schema regressions

FAQ

Can I have multiple JSON-LD blocks per page if they declare different types?

Technically 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.

Our framework uses React Server Components. Do per-component schema tags work?

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.

What about pages with content from many CMS blocks where the page does not know what will be on it?

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.

Should the JSON-LD block be in <head> or just before </body>?

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>.

How does this interact with R2 (initial HTML completeness)?

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.

What if our team prefers Microdata for accessibility or familiarity reasons?

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.

Checklist

Use this to confirm the consolidation pattern is implemented before moving on.