DEV Community

Cover image for Add JSON-LD structured data to an Astro or Docusaurus site
Headlark
Headlark

Posted on Originally published at headlark.com

Add JSON-LD structured data to an Astro or Docusaurus site

Originally published on headlark.com.

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} />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' },
  ],
}
Enter fullscreen mode Exit fullscreen mode

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"}]}'
---
Enter fullscreen mode Exit fullscreen mode

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() }),
}} />
Enter fullscreen mode Exit fullscreen mode

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',
      }),
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 @context or @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 Article for 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
Enter fullscreen mode Exit fullscreen mode

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), otherwise WebSite or WebPage. Never FAQPage or HowTo.
  • 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 @type is 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.

Top comments (0)