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, description and a publication date; an updated date 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.

PageTypesNotes
Guide or tutorialTechArticle (or Article)headline, datePublished, dateModified, author, image
Every non-home pageBreadcrumbListmirrors the visible breadcrumb
Page with a visible FAQFAQPagequestions and answers exactly as shown
Home pageWebSite, Organizationname, logo, sameAs social profiles
Product docs landingSoftwareApplicationname, 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.

Structured data types per page template A matrix with page templates as rows and types as columns. Article pages carry TechArticle, BreadcrumbList and FAQPage when a FAQ is visible. Section index pages carry BreadcrumbList and CollectionPage. The home page carries WebSite and Organization. The 404 page carries nothing. Which types go on which pages Template Article types Breadcrumbs Extras Guide page TechArticle BreadcrumbList FAQPage if visible Section index CollectionPage BreadcrumbList none Home page WebSite none Organization 404 and search none none none
One helper per type, composed per template, keeps every page's graph consistent.

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

Why string-built JSON-LD breaks and object-built JSON-LD does not Two panels. The string template writes headline equals a quoted title variable, which breaks on a title containing a double quote, a backslash or a newline, and produced 214 unparsable pages. The object approach builds a dictionary and serializes it once with escaping, handling any title, and produced zero errors. String templates versus serialized objects String template headline pasted between quotes breaks on quotes and backslashes breaks on newlines in descriptions one bad title per page breaks all of it 214 unparsable pages Object plus serializer dict or plain object jsonify or JSON.stringify escapes < escape blocks script breakout same helper on every template 0 unparsable pages
The Hugo site's 214 errors all came from titles with quotation marks, such as options named in quotes.

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-25 is accepted, but 25/09/2026 or 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.

CI stages that validate structured data The build writes HTML. A script extracts every JSON-LD block. Each block is parsed as JSON, then checked for required properties per type, then checked against visible content such as FAQ text and breadcrumb labels. Any failure blocks the deploy. JSON-LD validation in the pipeline Build dist with 3 Extract blocks script type ld+json Parse JSON syntax errors fail Required props per @type Match visible text FAQ, breadcrumbs
Parsing and required-property checks catch template bugs; the visible-content comparison catches drift between markup and page.

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

MeasureHand-written string templatesObjects + CI validation
Unparsable structured data (Hugo docs, Search Console)2140
Article items with warnings (missing image or date)1,38012
Breadcrumb items valid2,410 of 3,9003,900 of 3,900
JSON-LD bytes per page (gzip)0.6 KB0.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, image and mainEntityOfPage must 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.

Filed underHow-tosAstroEleventyHugo