Most structured-data tutorials assume you have a CMS plugin: install Yoast or Rank Math, toggle some checkboxes, and hope the generated markup matches your content. Hugo has no plugins — and for schema, that is an advantage, not a gap. Your templates already know every fact about every page at build time: title, dates, author, section, custom front matter. JSON-LD becomes just another render target, generated deterministically, versioned in git, and shipped as static bytes with zero runtime dependency.
The difference shows up in reliability. Plugin-generated schema breaks silently when a plugin updates; template-generated schema breaks loudly at build time, before deploy, where you can fix it. And because the markup derives from the same front matter that renders the visible page, schema and content cannot drift apart — the failure mode that gets structured data ignored or flagged.
This guide builds the three partials a content site actually needs — Article, FAQPage, and a site-wide Organization node — with the escaping discipline and validation workflow that keeps them correct.
How Build-Time Schema Injection Works
The mechanism is simple: partials called from your <head> template emit <script type="application/ld+json"> blocks, populated from Hugo’s page variables. The one non-negotiable rule is every dynamic value goes through jsonify. It wraps the value in quotes and escapes internal quotes, newlines, and unicode — the first article title containing a " will corrupt hand-interpolated JSON, and corrupted JSON-LD is discarded whole by every parser.
Here is the Article partial, layouts/partials/schema-article.html:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": {{ .Title | jsonify }},
"description": {{ .Params.description | jsonify }},
"datePublished": {{ .Date.Format "2006-01-02T15:04:05Z07:00" | jsonify }},
"dateModified": {{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" | jsonify }},
"mainEntityOfPage": {{ .Permalink | jsonify }},
"author": { "@type": "Organization", "name": {{ .Site.Title | jsonify }} },
"publisher": { "@id": {{ printf "%s#organization" .Site.BaseURL | jsonify }} }
}
</script>Note the publisher references an @id rather than re-declaring the organization. That identifier is defined once, site-wide, in baseof.html — a single Organization node with name, url, logo, and sameAs links. Declaring your organization once and referencing it everywhere is the same disambiguation principle behind building a consistent brand entity across search systems: one entity, one node, many references.
Looping Front Matter into FAQPage Markup
If your articles carry a faqData list in front matter (as every MarketLens article does), a partial can transform it into FAQPage schema mechanically. The subtlety is comma placement — JSON forbids trailing commas, so emit the comma before every element except the first:
{{ with .Params.faqData }}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{{ range $i, $qa := . }}{{ if $i }},{{ end }}
{
"@type": "Question",
"name": {{ $qa.question | jsonify }},
"acceptedAnswer": {
"@type": "Answer",
"text": {{ $qa.answer | jsonify }}
}
}
{{ end }}
]
}
</script>
{{ end }}The {{ with }} wrapper means pages without FAQ data emit nothing — no empty schema shells. Be honest with yourself about the payoff: Google now shows FAQ rich results almost exclusively for government and health sites. The markup still earns its bytes because answer engines ingest it as pre-segmented question-answer pairs, the highest-extractability format that exists for generative retrieval.
Which Schema Belongs Where?
| Schema Type | Scope | Data Source | Priority |
|---|---|---|---|
Organization (with @id) | Site-wide, in baseof.html | Site config params | First |
| Article / BlogPosting | Every post | Page front matter | First |
| FAQPage | Posts with faqData | Front matter list | Second |
| BreadcrumbList | All pages | .Ancestors walk | Second |
| Product / LocalBusiness | Only matching page types | Custom front matter | As needed |
Resist the temptation to emit every type on every page. A blog post claiming to be simultaneously an Article, a WebPage, a WebSite, and a TechArticle reads as markup spam; parsers reward precision, not volume. The Organization node deserves the most care since it anchors your brand identity across every knowledge system — where the sameAs array should point is covered in our guide to anchoring an organization to a Wikidata Q-ID.
Wiring and Conditional Rendering
In layouts/_default/baseof.html (or your head partial):
{{ if .IsPage }}
{{ partial "schema-article.html" . }}
{{ partial "schema-faq.html" . }}
{{ end }}.IsPage restricts article schema to actual content pages — list pages, taxonomies, and the homepage should not claim to be Articles. If you migrated from WordPress, this is also the moment to confirm your old plugin’s markup didn’t survive the migration in copied HTML; duplicate conflicting schema blocks are a common artifact we find when auditing sites that followed the WordPress-to-Hugo migration path.
How Do You Keep Generated Schema Valid?
Three checks, in increasing order of automation:
- Spot-check with validators. Paste a production URL into Google’s Rich Results Test (eligibility) and validator.schema.org (syntax). Do this after any template change touching schema.
- Watch minification.
hugo --minifyprocesses inline JSON-LD; a malformed block that happened to work unminified can surface as a build error or corrupted output after minification. Validate the minified production output, not just your dev server. - Monitor Search Console. The Enhancements reports surface parsing errors across the whole site continuously — the safety net that catches the article whose front matter broke an assumption six months from now.
The complete implementation — three partials, one conditional block, an hour of work — gives every current and future page markup that stays truthful automatically. That durability is the real argument: schema you never have to think about again is schema that is always right.
MarketLens