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.
| Format | Where it lives | Maintainability | Google preference |
|---|---|---|---|
| JSON-LD | A <script> in <head> | High — one block | Recommended |
| Microdata | Inline DOM attributes | Low — coupled to markup | Supported |
| RDFa | Inline DOM attributes | Low — coupled to markup | Supported |
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.
Breadcrumb structured data
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
@contextofhttps://schema.organd choose the most specific@typeavailable (e.g.BlogPostingoverArticle). - Generate JSON-LD from content collection frontmatter so the structured data and the rendered content can never drift apart.
- Use
set:html={JSON.stringify(...)}withis:inlineto emit valid, unescaped JSON without Astro processing the script. - Use
Astro.siteto produce absolute URLs foritem,@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.