How to add BlogPosting and BreadcrumbList JSON-LD to Astro posts
Meta tags — the kind emitted by a reusable BaseHead alongside RSS and a sitemap — tell a search engine the title and description of a page. They do not tell it what the page is — that this is an article, written by a person, with a publish date, sitting one level under the homepage. Structured data does that, and it is how you become eligible for the fancy search results with dates, breadcrumbs, and author names. Here is how I add it to every post on this blog.
What is JSON-LD and schema.org?
Schema.org is a shared vocabulary of types — Article, Person,
BreadcrumbList, and hundreds more — that Google, Bing, and others understand.
JSON-LD (JSON for Linking Data) is the format you write that vocabulary in: a
plain JSON object inside a <script type="application/ld+json"> tag. The reader
never sees it; crawlers do.
The payoff is rich results — the enhanced listings that can show a publish date, a breadcrumb trail, or an author under your link. You do not get them automatically; you have to describe the page.
Building the graph in the post layout
Every post on this blog renders through PostLayout.astro. That is where I build
the JSON-LD, because that is where I have the post data. I use a @graph, which
just means “several linked things in one script” — here, the article itself plus
its breadcrumb trail.
---
// blog/src/layouts/PostLayout.astro
import { SITE } from "@/consts"
const { post } = Astro.props
const canonical = new URL(`/${post.id}`, SITE.url).href
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "BlogPosting",
"@id": `${canonical}#article`,
headline: post.data.title,
description: post.data.description,
datePublished: post.data.date.toISOString(),
dateModified: (post.data.updatedDate ?? post.data.date).toISOString(),
keywords: post.data.tags.join(", "),
inLanguage: "en",
isPartOf: { "@id": `${SITE.url}/#blog` },
mainEntityOfPage: { "@type": "WebPage", "@id": canonical },
image: new URL(SITE.ogImage, SITE.url).href,
author: { "@type": "Person", "@id": SITE.authorId, name: SITE.author },
publisher: { "@type": "Person", "@id": SITE.authorId, name: SITE.author },
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Blog", item: SITE.url + "/" },
{ "@type": "ListItem", position: 2, name: post.data.title, item: canonical },
],
},
],
}
---Let me unpack the important fields.
"@context" points every type at the schema.org vocabulary. "@type" says what
each object is — a BlogPosting (schema.org’s type for a blog article) and a
BreadcrumbList.
"@id" is a globally unique identifier for a node. I use #article appended to
the canonical URL so this specific article node can be referenced unambiguously.
(The next post digs into why @id is so powerful for linking
identities across sites.)
The dates come straight from the post’s frontmatter — real Date objects thanks to the Zod-validated content collection schema. datePublished uses the
post’s date; dateModified uses updatedDate if the post was edited,
otherwise it falls back to the original date via post.data.updatedDate ?? post.data.date.
Both are converted to ISO 8601 strings with .toISOString(), which is the format
schema.org expects.
mainEntityOfPage tells the engine which URL is the primary page for this
article, and image is the absolute OG image URL. author and publisher both
point at a Person — note the @id there references an identity that lives on
the portfolio site, not the blog. That is deliberate, and it is the whole subject
of the next article.
The BreadcrumbList is a simple ordered list: position 1 is the blog home,
position 2 is this post. That is what makes a breadcrumb trail eligible to appear
in search results instead of a bare URL.
flowchart TD G["@graph"] --> A["BlogPosting #article"] G --> B["BreadcrumbList"] A --> P["author / publisher → Person @id"] B --> H["1. Blog"] B --> T["2. This post"]
Injecting it into the page
Building the object is only half the job — it has to reach the HTML. The layout
passes the jsonLd object down to BaseLayout, which forwards it to BaseHead:
<BaseLayout
title={`${post.data.title} — ${SITE.author}`}
description={post.data.description}
type="article"
publishedTime={post.data.date}
modifiedTime={post.data.updatedDate}
tags={post.data.tags}
jsonLd={jsonLd}
>BaseHead.astro accepts an optional jsonLd prop and renders it only when it is
present:
{
jsonLd && (
<script
type="application/ld+json"
set:html={JSON.stringify(jsonLd)}
is:inline
/>
)
}Two Astro details matter here. set:html writes the string as raw HTML instead
of escaping it — without it, the quotes in the JSON would get HTML-escaped and
break the script. is:inline tells Astro to leave the script exactly where it
is and not try to bundle or process it, which is what you want for a plain
ld+json block.
Because the prop is optional, listing pages that do not pass jsonLd simply emit
nothing — no empty tag, no errors.
Verifying it
After building, view the page source and search for application/ld+json. Paste
the JSON into Google’s Rich Results Test or the schema.org validator; both will
tell you whether the BlogPosting and BreadcrumbList parse cleanly and which
rich-result types you qualify for.
What to remember
- JSON-LD is a schema.org description of your page inside a
<script type="application/ld+json">— invisible to readers, read by crawlers. - A
@graphlets you ship several linked nodes (the article and its breadcrumbs) in one script. - Build dates with
.toISOString()and fall back to the publish date when there is no update date. - Inject it with
set:html={JSON.stringify(jsonLd)}andis:inlineso Astro leaves the JSON untouched. - Make it an optional prop so only posts emit it, and validate the output with a rich-results tester.