ehsan.blog
~/blog/how-to-add-blogposting-jsonld-structured-data-in-astro — zsh
cat how-to-add-blogposting-jsonld-structured-data-in-astro.md

How to add BlogPosting and BreadcrumbList JSON-LD to Astro posts

·5 min read

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.

astro
---
// 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:

astro
<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:

astro
{
  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 @graph lets 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)} and is:inline so Astro leaves the JSON untouched.
  • Make it an optional prop so only posts emit it, and validate the output with a rich-results tester.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.