Adding JSON-LD Structured Data at Build Time
Structured data tells search engines what a page is, not just what it says: this is a technical article, published on this date, by this organisation, located at this point in the site's hierarchy, with these questions and answers. On a static site it costs nothing at runtime — it is a <script type="application/ld+json"> block generated from frontmatter — and because every page comes from a template, getting it right once gets it right everywhere. Getting it wrong once also breaks it everywhere, which is why this guide spends as much time on validation as on generation.
It shows how to emit JSON-LD in Astro, Eleventy and Hugo by building objects rather than strings, which schema.org types are worth using on documentation and blog sites, and how to check every page in CI. It is part of Technical SEO for Static Sites. The measurements come from a 3,900-page Hugo documentation site and a 1,240-post Eleventy blog.
Prerequisites
- Frontmatter that carries at least
title,descriptionand a publication date; anupdateddate or Git-based last modification is better still. - A configured production origin for absolute URLs.
- Node 20+ in CI for the validation script.
Choosing the Types
Use types that describe content visible on the page. Invented or hidden content in structured data is against search engines' guidelines and can lead to manual actions.
| Page | Types | Notes |
|---|---|---|
| Guide or tutorial | TechArticle (or Article) | headline, datePublished, dateModified, author, image |
| Every non-home page | BreadcrumbList | mirrors the visible breadcrumb |
| Page with a visible FAQ | FAQPage | questions and answers exactly as shown |
| Home page | WebSite, Organization | name, logo, sameAs social profiles |
| Product docs landing | SoftwareApplication | name, operating system, category |
Google restricted FAQ rich results to authoritative government and health sites in 2023, so FAQPage rarely produces a visual rich result on developer sites any more. It remains valid markup that describes the page, other consumers still read it, and it costs nothing — but do not add FAQ sections to pages purely to get it.
Build Objects, Serialize Once
The single most important rule: never write JSON by concatenating strings in a template. A title containing "Hello" in Hugo or a description with a backslash produces invalid JSON on that page, and nothing complains until Search Console does, weeks later.
Astro — build in the frontmatter script, serialize in the template:
---
const { entry, breadcrumbs } = Astro.props;
const url = new URL(Astro.url.pathname, Astro.site).href;
const graph = [
{
'@type': 'TechArticle',
'@id': `${url}#article`,
headline: entry.data.title.slice(0, 110),
description: entry.data.description,
datePublished: entry.data.published.toISOString(),
dateModified: (entry.data.updated ?? entry.data.published).toISOString(),
author: { '@type': 'Organization', name: 'Example Docs', url: Astro.site.href },
image: new URL(`/og${Astro.url.pathname}index.png`, Astro.site).href,
mainEntityOfPage: url
},
{
'@type': 'BreadcrumbList',
itemListElement: breadcrumbs.map((b, i) => ({
'@type': 'ListItem', position: i + 1, name: b.name, item: new URL(b.path, Astro.site).href
}))
}
];
const json = JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }).replace(/</g, '\\u003c');
---
<script type="application/ld+json" set:html={json} />
Hugo — build a dict and pass it through jsonify:
{{ $article := dict
"@type" "TechArticle"
"headline" (substr .Title 0 110)
"datePublished" (.PublishDate.Format "2006-01-02T15:04:05Z07:00")
"dateModified" (.Lastmod.Format "2006-01-02T15:04:05Z07:00")
"mainEntityOfPage" .Permalink
}}
{{ $data := dict "@context" "https://schema.org" "@graph" (slice $article) }}
<script type="application/ld+json">{{ $data | jsonify | safeJS }}</script>
Eleventy — a filter that returns a serialized string, used in the base layout:
eleventyConfig.addFilter('jsonld', (obj) => JSON.stringify(obj).replace(/</g, '\\u003c'));
The < replacement matters for safety: a title containing </script> would otherwise close the element early. It keeps the JSON valid because < is a legal escape for <.
Use @graph and Stable @id Values
Putting several types into one @graph with @id references is tidier than several separate script blocks, and it lets types refer to each other — the article's publisher can reference the organisation defined on the home page by @id instead of repeating it. Use stable fragment identifiers derived from the canonical URL (https://docs.example.com/#organization, …/guide/install/#article). Consumers deduplicate entities by @id, so a consistent identifier across pages builds a coherent picture of the site.
Dates, Images and Lengths
Most validation warnings on real sites come from three properties:
- Dates must be ISO 8601 with a time zone.
2026-09-25is accepted, but25/09/2026or a date without a zone offset produces warnings. Serialize from real date objects rather than frontmatter strings. - Images must be absolute URLs that return 200 and, for article rich results, be at least 1,200 pixels wide. Generated Open Graph images at 1200×630 satisfy both, as in Generating Open Graph Images at Build Time.
- Headline should be the visible title and stay under 110 characters.
Validate Every Page in CI
The online Rich Results Test is useful for a spot check but not for thousands of pages. A CI script parses every JSON-LD block in the built output and checks required properties per type:
// scripts/check-jsonld.mjs (core)
const REQUIRED = {
TechArticle: ['headline', 'datePublished', 'dateModified', 'mainEntityOfPage'],
BreadcrumbList: ['itemListElement'],
FAQPage: ['mainEntity']
};
for (const file of htmlFiles) {
for (const block of scriptBlocks(file, 'application/ld+json')) {
let data;
try { data = JSON.parse(block); } catch (e) { fail(file, `invalid JSON: ${e.message}`); continue; }
for (const node of data['@graph'] ?? [data]) {
for (const prop of REQUIRED[node['@type']] ?? []) {
if (node[prop] === undefined || node[prop] === '') fail(file, `${node['@type']} missing ${prop}`);
}
if (node.datePublished && Number.isNaN(Date.parse(node.datePublished))) fail(file, 'bad datePublished');
}
}
}
It runs in about 6 s over 3,900 pages. Also check that FAQPage questions match the visible FAQ text on the page — structured data that differs from visible content is exactly what guidelines prohibit. More checks of this kind are collected in Testing Structured Data and Meta Tags.
Keeping Markup and Page in Sync
Structured data drifts from the visible page in predictable ways. A writer edits the FAQ section in the body but not the frontmatter that feeds FAQPage; a designer shortens breadcrumb labels in the layout but not in the breadcrumb data; a date shown as "Updated 3 September" comes from Git while dateModified comes from frontmatter. Each drift is small, and each is the kind of mismatch that search engines treat as a quality problem.
The cure is to generate both the visible element and the structured data from one source. On this site the FAQ lives only in frontmatter; the layout renders the visible questions from the same array that builds FAQPage, so they cannot disagree. Breadcrumbs come from one computed array used by both the <nav> and BreadcrumbList. The displayed "last updated" date and dateModified both come from the same Git-derived value, which also feeds the sitemap's lastmod, as described in Generating Sitemaps for Large Static Sites.
Where one source is not possible — hand-written body content that also appears in markup — the CI check compares them. The visible-text comparison for FAQs found 17 mismatches on the documentation site in its first run, mostly questions reworded in the body after the frontmatter was written.
Measured Impact
| Measure | Hand-written string templates | Objects + CI validation |
|---|---|---|
| Unparsable structured data (Hugo docs, Search Console) | 214 | 0 |
| Article items with warnings (missing image or date) | 1,380 | 12 |
| Breadcrumb items valid | 2,410 of 3,900 | 3,900 of 3,900 |
| JSON-LD bytes per page (gzip) | 0.6 KB | 0.7 KB |
| CI validation time | — | 6 s |
Pitfalls & Rollback
- Markup describing hidden content. FAQ or review markup for content not on the page risks a manual action.
- Relative URLs.
item,imageandmainEntityOfPagemust be absolute. - Multiple conflicting blocks. Themes and plugins sometimes add their own JSON-LD; search the built HTML for duplicates of the same type.
- Breadcrumbs that differ from the visible trail. Generate both from one data structure.
- Rollback: JSON-LD is template output; reverting the layout change restores the previous blocks. Search Console reports update after recrawl, typically within two to three weeks.
Conclusion
Structured data on a static site is a small amount of template code with a large blast radius. Build it as data, serialize it once with escaping, use stable @id values in one @graph, and validate every page in CI. On the documentation site that cleared 214 unparsable pages and more than a thousand warnings for 0.1 KB per page.
FAQ
Which structured data types are worth adding to a documentation or blog site?
Article or TechArticle for content pages, BreadcrumbList for the navigation trail, Organization or WebSite on the home page, and FAQPage where a page genuinely has a question and answer section. Software products can add SoftwareApplication. Add only types whose content is visible on the page.
Why build JSON-LD as an object instead of a template string?
String templates break as soon as a title contains a quote, a backslash or a line break, producing invalid JSON for that page. Building a plain object and serializing it once with JSON.stringify or Hugo's jsonify escapes everything correctly.
Is JSON-LD in a script tag safe from XSS?
Serialized JSON can still contain a closing script tag sequence inside a string. Escape the less-than character as < after serializing, which keeps the JSON valid while making it impossible to break out of the script element.
How do I validate structured data automatically?
Parse every JSON-LD block in the built HTML in CI, check that it is valid JSON, and check required properties per type with a small schema. The Schema Markup Validator and Rich Results Test are useful for spot checks but not for thousands of pages.
Related
- Parent: Technical SEO for Static Sites — the rest of the build-time SEO work.
- Testing Structured Data and Meta Tags — the CI suite in full.
- Canonical URLs and Duplicate Content on Static Sites —
mainEntityOfPageequals the canonical. - Choosing an SSG for API Reference Documentation — where TechArticle markup is most useful.
- RSS and Atom Feeds for Static Blogs — another artefact built from the same frontmatter.