Skip to content
Astro as seo 4 min read

Structured Data & JSON-LD

Structured data is machine-readable metadata you embed in a page to describe its content using a shared vocabulary — almost always Schema.org. Search engines parse it to power rich results: article bylines, breadcrumb trails, FAQ accordions, product ratings, and more. Astro is an ideal place to author structured data because pages render to static HTML by default, so the JSON-LD ships to the crawler with zero client-side JavaScript and no hydration cost.

Why JSON-LD

Schema.org markup can be expressed three ways: Microdata, RDFa, and JSON-LD. Google explicitly recommends JSON-LD because it is decoupled from your visible markup — you drop a single <script type="application/ld+json"> block into the <head> instead of sprinkling itemprop attributes across your DOM. That separation makes it trivial to generate from content collection frontmatter and keeps your templates clean.

FormatWhere it livesMaintainabilityGoogle preference
JSON-LDA <script> in <head>High — one blockRecommended
MicrodataInline DOM attributesLow — coupled to markupSupported
RDFaInline DOM attributesLow — coupled to markupSupported

Because Astro components render on the server and emit static HTML, your JSON-LD is present in the initial response. Client-rendered SPAs often inject structured data after hydration, which crawlers may miss.

A reusable JSON-LD component

Create a small island-free component that serializes a JavaScript object to a JSON-LD script tag. Using set:html with JSON.stringify avoids HTML entity escaping that would otherwise corrupt your JSON.

---
// src/components/JsonLd.astro
interface Props {
  schema: Record<string, unknown>;
}

const { schema } = Astro.props;
---

<script type="application/ld+json" set:html={JSON.stringify(schema)} is:inline />

The is:inline directive tells Astro to leave the script exactly as written rather than processing or bundling it. Now any page or layout can pass a typed object.

Article structured data

For a blog post, emit an Article (or BlogPosting) object built from your content collection entry. This is what drives the headline, publish date, and author shown in rich results.

---
// src/pages/blog/[slug].astro
import { getCollection } from "astro:content";
import JsonLd from "../../components/JsonLd.astro";

export async function getStaticPaths() {
  const posts = await getCollection("blog");
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await post.render();
const canonical = new URL(`/blog/${post.slug}`, Astro.site).toString();

const schema = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  headline: post.data.title,
  description: post.data.description,
  datePublished: post.data.pubDate.toISOString(),
  dateModified: (post.data.updated ?? post.data.pubDate).toISOString(),
  author: {
    "@type": "Person",
    name: post.data.author,
  },
  mainEntityOfPage: {
    "@type": "WebPage",
    "@id": canonical,
  },
};
---

<html lang="en">
  <head>
    <title>{post.data.title}</title>
    <JsonLd schema={schema} />
  </head>
  <body>
    <article><Content /></article>
  </body>
</html>

Set the site option in astro.config.mjs so Astro.site resolves and your @id and URLs are absolute.

Breadcrumbs help Google render a navigation trail under your result. The BreadcrumbList type is an ordered array of ListItem entries.

// src/lib/breadcrumbs.ts
export function breadcrumbSchema(
  trail: { name: string; url: string }[]
) {
  return {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: trail.map((crumb, index) => ({
      "@type": "ListItem",
      position: index + 1,
      name: crumb.name,
      item: crumb.url,
    })),
  };
}

Pass the result to the same JsonLd component. You can include multiple JSON-LD blocks on one page — one for the article and one for the breadcrumbs.

FAQ structured data

An FAQPage can earn an expandable Q&A block directly in search results. Drive it from frontmatter so authors maintain it alongside content.

---
// inside a page component
import JsonLd from "../components/JsonLd.astro";

const faqs = [
  { q: "Is Astro free?", a: "Yes, Astro is open source under the MIT license." },
  { q: "Does it ship JavaScript?", a: "No JavaScript by default; you opt in with islands." },
];

const faqSchema = {
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: faqs.map((item) => ({
    "@type": "Question",
    name: item.q,
    acceptedAnswer: { "@type": "Answer", text: item.a },
  })),
};
---

<JsonLd schema={faqSchema} />

Validating your output

Build the site and inspect the generated HTML, then run the markup through Google’s tooling.

npx astro build
npx serve dist
# Then paste a page URL into the Rich Results Test:
# https://search.google.com/test/rich-results

Output:

<script type="application/ld+json">{"@context":"https://schema.org",
"@type":"BlogPosting","headline":"Structured Data in Astro", ... }</script>

Rich results are a feature, not a guarantee. Valid markup makes your page eligible; Google still decides when to display enhancements. Always validate, and only mark up content that is actually visible on the page — invisible or mismatched data risks a manual action.

Best Practices

  • Keep one canonical @context of https://schema.org and choose the most specific @type available (e.g. BlogPosting over Article).
  • Generate JSON-LD from content collection frontmatter so the structured data and the rendered content can never drift apart.
  • Use set:html={JSON.stringify(...)} with is:inline to emit valid, unescaped JSON without Astro processing the script.
  • Use Astro.site to produce absolute URLs for item, @id, and image fields — relative URLs are not valid in structured data.
  • Only describe content that genuinely appears on the page, and never mark up content the user cannot see.
  • Validate every template against the Rich Results Test and the Schema.org validator before shipping.
  • Combine multiple blocks (article + breadcrumbs + FAQ) freely; each is an independent <script> and crawlers merge them.
Last updated June 14, 2026
Was this helpful?