Updated 2026-10-21
Add JSON-LD structured data to an Astro or Docusaurus site
Structured data tells search engines what a page is: an article with an author and a date, a position in a breadcrumb trail, the organization behind the site. Google reads it to show rich results, such as breadcrumbs instead of a bare URL or an article's date and image, and recommends JSON-LD as the format (Google Search Central: structured data). It's a <script type="application/ld+json"> block in the page's HTML, so on a static site it belongs in the build output.
Which types fit a docs site or blog
| Page | Type | What it can give you |
|---|---|---|
| Blog post, changelog entry | BlogPosting or Article | Headline, date, image and author understood correctly (Article) |
| Any page below the home page | BreadcrumbList | A breadcrumb trail in desktop results (Breadcrumb) |
| Home page | Organization, WebSite | Your logo and the site name shown in results |
| Product or tool page | SoftwareApplication | Price and rating details, if the page shows them |
Skip FAQPage and HowTo for rich results: Google no longer shows HowTo rich results and limits FAQ rich results to a small set of authoritative sites. Whatever you mark up has to be visible on the page; markup that describes content readers can't see is against Google's guidelines.
Astro
A small component keeps the escaping in one place. JSON inside a <script> isn't HTML-escaped, so a string containing </script> would end the tag early; replacing < with its Unicode escape prevents that and stays valid JSON.
---
// src/components/JsonLd.astro
const { data } = Astro.props;
const json = JSON.stringify(data).replace(/</g, '\\u003c');
---
<script type="application/ld+json" set:html={json} />
Astro leaves a <script> with any attribute other than src unprocessed, so this ships exactly as written. Use it in a blog post layout, building absolute URLs from site in astro.config.mjs:
---
// src/layouts/BlogPost.astro
import JsonLd from '../components/JsonLd.astro';
const { title, description, pubDate, updatedDate, heroImage, author } = Astro.props;
const url = new URL(Astro.url.pathname, Astro.site).href;
---
<head>
<JsonLd data={{
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: title,
description,
datePublished: pubDate.toISOString(),
...(updatedDate && { dateModified: updatedDate.toISOString() }),
...(heroImage && { image: new URL(heroImage, Astro.site).href }),
author: { '@type': 'Person', name: author },
mainEntityOfPage: url,
}} />
</head>
A breadcrumb trail for a page two levels down looks like this; the last item can leave out item, and Google uses the page's own URL:
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Docs', item: 'https://example.com/docs/' },
{ '@type': 'ListItem', position: 2, name: 'Guides', item: 'https://example.com/docs/guides/' },
{ '@type': 'ListItem', position: 3, name: 'Deploy to Netlify' },
],
}
Starlight
For a single page, the head front matter field takes the same tag format as the config option:
---
title: Deploy to Netlify
head:
- tag: script
attrs:
type: application/ld+json
content: '{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Guides","item":"https://docs.example.com/guides/"},{"@type":"ListItem","position":2,"name":"Deploy to Netlify"}]}'
---
For every page, override Starlight's Head component and build the data from the route. Register it with components: { Head: './src/components/Head.astro' } in the Starlight config:
---
// src/components/Head.astro
import Default from '@astrojs/starlight/components/Head.astro';
import JsonLd from './JsonLd.astro';
const { entry, lastUpdated } = Astro.locals.starlightRoute;
---
<Default />
<JsonLd data={{
'@context': 'https://schema.org',
'@type': 'Article',
headline: entry.data.title,
...(entry.data.description && { description: entry.data.description }),
...(lastUpdated && { dateModified: lastUpdated.toISOString() }),
}} />
lastUpdated is only set when you enable lastUpdated in the Starlight config.
Docusaurus
Site-wide data, such as your organization, goes in headTags in docusaurus.config.js (Docusaurus: SEO):
export default {
headTags: [
{
tagName: 'script',
attributes: { type: 'application/ld+json' },
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Example',
url: 'https://example.com/',
logo: 'https://example.com/img/logo.png',
}),
},
],
};
That block is added to every page, so keep it to data that's true everywhere. For one page, put a <head> block in the Markdown or MDX file:
<head>
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'Example CLI',
operatingSystem: 'macOS, Linux, Windows',
applicationCategory: 'DeveloperApplication',
})}
</script>
</head>
Check your build before adding breadcrumbs or article markup: recent Docusaurus 3 releases already emit BreadcrumbList on docs pages and BlogPosting on blog posts. A second, slightly different copy only adds noise.
Common mistakes
- Invalid JSON. Hand-written blocks break on trailing commas, smart quotes and unescaped quotes in titles. Build the object in code and serialize it with
JSON.stringify; Google ignores a block it can't parse. - Relative URLs.
"image": "/hero.png"isn't a URL a crawler can resolve reliably. Use full URLs from your site setting. - Missing
@contextor@type. Without"@context": "https://schema.org"the block means nothing to search engines. - Dates without a time zone. Use ISO 8601 (
toISOString()gives UTC), not "October 21". - Data that isn't on the page. Ratings, prices or authors in the markup must also appear in the visible content.
- Duplicates. A theme, a plugin and your own component each emitting an
Articlefor the same page, with different headlines.
Validate it
List built pages with no JSON-LD at all (Astro and Starlight build to dist, Docusaurus to build):
grep -rL --include='*.html' 'application/ld+json' dist
Then test a few pages of each template. The free structured data checker takes a URL or pasted markup and checks each block against the properties Google requires and recommends. Google's Rich Results Test shows which rich results a page qualifies for, and the Schema Markup Validator checks the markup against schema.org. The free SEO checker lists a live page's JSON-LD types and flags blocks that aren't valid JSON or lack a @context or @type. After deploying, Search Console's rich result reports show errors across the whole site.
Automating it with Headlark
Hand-written markup works for a few templates. On a site with hundreds of different pages, Headlark's build step can write it from each page's content after your build:
- One to three JSON-LD blocks per page, using a type Google supports for rich results when the content fits (such as
Article,BreadcrumbList,Organization,Product,SoftwareApplication), otherwiseWebSiteorWebPage. NeverFAQPageorHowTo. - Only properties whose values appear on the page, with URLs made absolute from your
--site-url. A block that isn't valid JSON or has no@typeis dropped, not written. - A type you already emit is kept as it is and never added twice, so it works alongside the Docusaurus defaults or your own component.
Run your key templates through the Rich Results Test once; it takes minutes when the markup is already there.
Try it
The free plan covers one site and 25 pages. Add your site, copy its secret key, and your next build ships with SEO.