Structured data written page by page is structured data that will be wrong by next quarter. Someone edits a product and forgets the block, and the two prices disagree. Someone redesigns a template and the block that lived in the old one never makes it into the new one. The pattern that survives both is to treat schema as an output of the template: built by one module that knows nothing about markup, with the sitewide nodes emitted by the layout and the page-type nodes emitted by whichever template renders that kind of page. This post describes the pattern, shows how this site implements it, and translates it for a CMS.
What B1 and B2 reward
The two checks this pattern feeds are worth five points each, both rated Medium effort.
B1, JSON-LD present and valid, awards three points if the homepage carries at least one application/ld+json block and every block on it parses, and two more if half or more of the crawled pages, homepage included, do the same. The scanner fetches up to six pages, so coverage usually means three of six. The evidence on the report gives homepageHasValidJsonLd, pagesWithValidJsonLd, pagesCrawled and pagesWithInvalidJsonLd, and the recommendation for a site with a good homepage and poor coverage says, in so many words, to add it at the template level rather than page by page. JSON-LD that parses covers the ways a block fails to parse; this post is about the ways it fails to be there.
B2, correct schema types for the site type, collects every @type it can find across all crawled pages into one set. It walks arrays, flattens @graph and descends into nested objects, so an Offer inside a Product counts. Two points if the set contains Organization or WebSite. Three points if it contains at least one of the types wanted for the detected site type: Product or Offer for e-commerce; SoftwareApplication, Article, FAQPage or Service for SaaS; Article, BlogPosting or FAQPage for content sites; and Article, FAQPage, Service or SoftwareApplication for everything else. Which schema types your site needs goes through the choice. The point here is that the types are counted across the crawl, so no single page has to carry everything.
The site type is detected from the homepage, reading its schema types and a few text heuristics. Product, Offer or OnlineStore in the homepage schema makes the site e-commerce, SoftwareApplication makes it SaaS, and Article or BlogPosting makes it a content site. What the layout and the homepage template emit therefore decides which list B2 scores you against.
The pattern
Four rules.
- One module builds every node. Typed functions take the fields a node needs and return a plain object. No template contains a JSON string and no page contains a hand-written block.
- The layout emits the sitewide nodes.
OrganizationandWebSiteare true of every page, so they are rendered by the thing every page shares, once, with stable@idvalues other nodes can point at. - Each template emits its own type. The article template adds
Article; the product template addsProductwith itsOffer; the service template addsService. Any template with a breadcrumb trail addsBreadcrumbList, because the trail already exists for the visible breadcrumbs and the node is the same data. - Every value comes from the field that renders the visible page. The
headlineis the same variable as theh1; thepriceis the same variable as the price beside the button. Schema that reads from its own fields drifts. Schema that reads from the page's fields cannot.
Under those rules a redesign changes components and stylesheets and leaves the schema call in each template where it was. A renamed field breaks the build in one module instead of silently emitting nothing in forty pages. And the @id references mean the organisation is described exactly once: a template that wants to say who published an article points at the id rather than restating the name, which is also what keeps check D5's identity comparison from finding a disagreement.
How this site does it
The builders live in one file, lib/schema.ts. Each is a small function returning an object, and one helper wraps any number of nodes in a single @graph and serialises it.
export const organisationId = absoluteUrl("/#organization");
export function articleSchema(input: {
headline: string;
description: string;
path: string;
published: string;
updated?: string;
}) {
return {
"@type": "Article",
headline: input.headline,
description: input.description,
url: absoluteUrl(input.path),
datePublished: input.published,
dateModified: input.updated ?? input.published,
author: { "@id": organisationId },
publisher: { "@id": organisationId },
isAccessibleForFree: true,
};
}
export function graph(...nodes: Record<string, unknown>[]): string {
return JSON.stringify({ "@context": "https://schema.org", "@graph": nodes });
}
The root layout, which wraps every route, renders the sitewide graph in the document head through a server component that emits one script element:
<JsonLd json={graph(organisationSchema(), websiteSchema())} />
The WebSite node carries the SearchAction that check C6 reads and a dateModified taken from the same route registry that feeds the sitemap, so the freshness signal check D4 wants is on every page rather than on whichever six a crawl happens to pick. The Organization node carries the address, contact point and sameAs that D2 reads.
Each template then adds its own nodes. The article template, the one rendering this post, emits Article and BreadcrumbList from the post's frontmatter and its trail:
<JsonLd
json={graph(
articleSchema({
headline: post.title,
description: post.description,
path,
published: post.publishedAt,
updated: post.updatedAt,
}),
breadcrumbSchema(trail),
)}
/>
The service template emits Service and BreadcrumbList; the homepage emits Service and FAQPage; the pricing page emits BreadcrumbList and FAQPage; every other page emits at least BreadcrumbList. On any given page that is two blocks, the layout's and the template's. The scanner does not care how many blocks a page has, only that each one parses and what types their union contains, and JSON-LD has no objection to two graphs in one document.
Because the builders are ordinary functions, they are tested like ordinary functions: call articleSchema with known input, assert on the object, parse the output of graph to confirm it round-trips. Page-by-page schema can never have that.
The same pattern in a CMS
Most content management systems render a page from a base layout plus a template chosen by content type, and most can include a partial from either. That is enough.
Put a sitewide partial in the base layout's head, containing Organization and WebSite built from the site's settings. Put one schema partial per template, next to the template: the single-post template gets Article, the product template gets Product and Offer, the plain page template gets BreadcrumbList at minimum. Each partial builds an associative array or dictionary from the same variables the template already has in scope, encodes it with the language's JSON encoder, and prints the result inside a script element using the unescaped output form, since ordinary escaping is what turns every quote into ".
layouts/
base -> includes partials/schema/site (Organization, WebSite)
single -> includes partials/schema/article (Article, BreadcrumbList)
product -> includes partials/schema/product (Product, Offer, BreadcrumbList)
page -> includes partials/schema/breadcrumb (BreadcrumbList)
Three things to hold to. The product partial reads price and availability from the product object, not from a separate schema field an editor can forget. The partials are part of the theme, so a redesign that starts from the same theme keeps them and a redesign that starts fresh has a short, named list of things to carry across. And nothing in the schema path runs in the browser: a partial rendered on the server is in the response body, and a tag manager is not.
A plugin that generates schema from a settings screen can be a reasonable first step. The failure mode to watch is a plugin that owns the sitewide nodes while a theme owns the page-type nodes, or two plugins that both emit Organization with slightly different names. The fix is one owner for each node, and that owner is a template.
Check your own site
Run the free scan and compare pagesWithValidJsonLd with pagesCrawled on B1, then read typesFound and missing on B2; together they show whether the gap is coverage, a missing sitewide node or a missing page type. Both checks are defined under understanding on the methodology page, and the layout and templates described above produce the result on our own report. If you would rather have the templates done for you, that is the implementation service.