
How to add JSON-LD structured data in Astro?
To add JSON-LD structured data in Astro, build a JavaScript object that follows the schema.org vocabulary in your page's frontmatter, then output it in a <script type="application/ld+json"> tag using set:html={JSON.stringify(schema)}. Wrapping this in a small JsonLd.astro component keeps it reusable, and typing the objects with the schema-dts package means astro check catches misspelled properties. Because Astro renders on the server, the JSON-LD is in the HTML that search engines download, with no client-side JavaScript involved.
Structured data tells search engines what a page represents, such as an article with an author and publish date, a product with a price or a set of breadcrumbs, and makes the page eligible for rich results. In this article you'll learn why set:html is the right way to output JSON-LD in Astro, how to build a typed component, how to describe blog posts, breadcrumbs, your organisation and website, how to keep the data in sync with your content collections, and how to validate it.
What JSON-LD Is
JSON-LD (JSON for Linking Data) is a way of embedding structured data as JSON in a <script> element. Google recommends it over the older Microdata and RDFa formats because it sits apart from your visible HTML and is easy to generate.
A minimal example looks like this:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Moving my blog to Astro",
"datePublished": "2026-10-01T00:00:00.000Z"
}
</script>
- @context: always
https://schema.org, the vocabulary being used. - @type: the kind of thing being described, such as
BlogPosting,Product,OrganizationorBreadcrumbList. - Properties: the fields defined for that type in schema.org, such as
headlineanddatePublished.
The script type application/ld+json means browsers don't execute it. It's data, not code.
Why set:html Is the Right Tool
In an Astro template, you might expect to write the JSON inside the script tag with an expression. Astro's set:html directive is the cleaner option: it injects a string as the element's content without escaping, which is exactly what you need for JSON. It's covered in more depth in using set:html in Astro.
There's a second reason. A <script> tag with any attribute other than src is treated as inline by Astro, so it isn't bundled or processed as JavaScript. That's what you want: the content goes into the HTML exactly as written.
There's one security detail to handle. JSON.stringify doesn't escape <, so a string value containing </script>, such as a title or a comment pulled from a CMS, would close the tag early and could allow script injection. Replace < with its Unicode escape <, which is still valid JSON and parses to the same value.
A Reusable JsonLd Component
Install the schema-dts types, published by Google, which describe the whole schema.org vocabulary in TypeScript:
npm install -D schema-dts
Version 2.1.0 was current when I tested this. Then create the component:
---
// src/components/JsonLd.astro
import type { Thing, WithContext } from "schema-dts";
interface Props {
schema: WithContext<Thing> | WithContext<Thing>[];
}
const { schema } = Astro.props;
// Escape "<" so a value containing "</script>" can't end the tag early
const json = JSON.stringify(schema).replace(/</g, "\\u003c");
---
<script type="application/ld+json" set:html={json} />
The component accepts a single object or an array of them. An array lets you describe several things on one page, such as an article and its breadcrumbs, in one script tag. Multiple separate JsonLd components on a page work just as well.
Describing a Blog Post
For an article, BlogPosting (or the more general Article) is the type to use. Build it from the post's content collection data in the dynamic route:
---
// src/pages/blog/[id].astro
import { getCollection, render } from "astro:content";
import type { BlogPosting, BreadcrumbList, WithContext } from "schema-dts";
import BaseLayout from "../../layouts/BaseLayout.astro";
import JsonLd from "../../components/JsonLd.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
const url = new URL(Astro.url.pathname, Astro.site).href;
const image = new URL(post.data.image ?? `/og/${post.id}.png`, Astro.site).href;
const article: WithContext<BlogPosting> = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.data.title,
description: post.data.description,
image: [image],
datePublished: post.data.pubDate.toISOString(),
dateModified: (post.data.updatedDate ?? post.data.pubDate).toISOString(),
author: {
"@type": "Person",
name: "Sajjad",
url: new URL("/about/", Astro.site).href,
},
publisher: {
"@type": "Organization",
name: "Example Blog",
logo: {
"@type": "ImageObject",
url: new URL("/logo.png", Astro.site).href,
},
},
mainEntityOfPage: { "@type": "WebPage", "@id": url },
};
const breadcrumbs: WithContext<BreadcrumbList> = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: new URL("/", Astro.site).href },
{ "@type": "ListItem", position: 2, name: "Blog", item: new URL("/blog/", Astro.site).href },
{ "@type": "ListItem", position: 3, name: post.data.title },
],
};
---
<BaseLayout
title={post.data.title}
description={post.data.description}
image={post.data.image ?? `/og/${post.id}.png`}
type="article"
>
<JsonLd schema={[article, breadcrumbs]} />
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
</BaseLayout>
This assumes a collection schema with title, description, pubDate, an optional updatedDate and an optional image, and the site option set in astro.config.mjs, because structured data URLs must be absolute. The image falls back to the generated card from adding Open Graph images in Astro.
Building this in an Astro 7.4.2 project produced the following script in the page, shown here formatted for readability:
[
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Moving my blog to Astro",
"description": "Why I moved from a single-page app to Astro, and what changed for performance and SEO.",
"image": ["https://example.com/images/first-post.png"],
"datePublished": "2026-10-01T00:00:00.000Z",
"dateModified": "2026-10-01T00:00:00.000Z",
"author": {
"@type": "Person",
"name": "Sajjad",
"url": "https://example.com/about/"
},
"publisher": {
"@type": "Organization",
"name": "Example Blog",
"logo": { "@type": "ImageObject", "url": "https://example.com/logo.png" }
},
"mainEntityOfPage": { "@type": "WebPage", "@id": "https://example.com/blog/first-post/" }
},
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.com/" },
{ "@type": "ListItem", "position": 2, "name": "Blog", "item": "https://example.com/blog/" },
{ "@type": "ListItem", "position": 3, "name": "Moving my blog to Astro" }
]
}
]
The last breadcrumb has no item URL. Google's breadcrumb documentation allows this for the final item, which represents the current page.
Typed Objects Catch Mistakes
The WithContext<BlogPosting> annotation is what makes this robust. When I deliberately misspelled headline as headlin, astro check failed with:
error ts(2561): Object literal may only specify known properties, but 'headlin' does not exist in type 'BlogPostingLeaf & { "@context": "https://schema.org"; }'. Did you mean to write 'headline'?
Without types, a typo like that silently produces structured data that search engines ignore. The TypeScript props guide covers typing components more generally.
Site-Wide Schema: Organization and WebSite
Some structured data describes the site rather than a single page. Put it on the home page, or in your base layout if you prefer it everywhere:
---
// src/pages/index.astro
import type { Organization, WebSite, WithContext } from "schema-dts";
import BaseLayout from "../layouts/BaseLayout.astro";
import JsonLd from "../components/JsonLd.astro";
const organization: WithContext<Organization> = {
"@context": "https://schema.org",
"@type": "Organization",
name: "Example Blog",
url: new URL("/", Astro.site).href,
logo: new URL("/logo.png", Astro.site).href,
sameAs: ["https://github.com/example", "https://www.linkedin.com/company/example"],
};
const website: WithContext<WebSite> = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "Example Blog",
url: new URL("/", Astro.site).href,
};
---
<BaseLayout title="Example Blog" description="Articles about building fast websites with Astro.">
<JsonLd schema={[organization, website]} />
<h1>Example Blog</h1>
</BaseLayout>
- Organization: your name, logo and official profiles in
sameAs. It helps search engines connect your site with your brand. - WebSite: the site's name, which Google can use as the site name shown in search results.
If you're an individual rather than a company, use Person with the same sameAs idea.
Other Useful Types
Depending on your content, these are worth knowing:
| Type | Use for | Key properties |
|---|---|---|
BlogPosting / Article | Posts, news, guides | headline, image, datePublished, dateModified, author |
BreadcrumbList | Page hierarchy | itemListElement with position, name, item |
Product | Product pages | name, image, offers with price and priceCurrency |
FAQPage | Question and answer pages | mainEntity of Question items with acceptedAnswer |
HowTo | Step-by-step instructions | step items |
Event | Events | startDate, location, offers |
SoftwareApplication | Apps and tools | operatingSystem, offers |
Google limits which types produce rich results and has restricted some over time; FAQ and HowTo rich results, for example, are now shown only for a small set of sites or not at all. The markup still describes your content accurately, but don't expect every type to change how your listing looks. Check Google's Search Gallery documentation for what's currently supported.
Generating FAQ Schema from Frontmatter
If posts have a list of questions in frontmatter, you can turn them into FAQPage data without duplicating text:
// src/lib/faq-schema.ts
import type { FAQPage, WithContext } from "schema-dts";
export function faqSchema(
faqs: { question: string; answer: string }[],
): WithContext<FAQPage> {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: { "@type": "Answer", text: faq.answer },
})),
};
}
Add a matching faqs array to your collection schema with z.array(z.object({ question: z.string(), answer: z.string() })).optional() and render the FAQ on the page from the same data, so the visible content and the structured data never disagree.
Rules That Keep Structured Data Valid
Search engines have guidelines for structured data, and violating them can make a page ineligible for rich results:
- Match the visible content: everything you describe must appear on the page. Don't add reviews, prices or questions that visitors can't see.
- Use absolute URLs for
url,image,logoanditem. Build them withnew URL(path, Astro.site). - Use ISO 8601 dates:
toISOString()produces the right format. - Keep it accurate: update
dateModifiedonly when the content meaningfully changes. - One topic per page: describe the main entity of the page, not everything on the site.
Generating the data from the same frontmatter that renders the page, as in the examples above, is the easiest way to follow these rules.
Validating Your JSON-LD
- Inspect the built HTML: open the page in
dist/and check the script contains valid JSON. Pasting it intoJSON.parse()in the browser console is a quick test. - Schema Markup Validator: validator.schema.org checks the markup against the full schema.org vocabulary.
- Rich Results Test: Google's tool shows which rich results the page is eligible for, and any missing required or recommended properties.
- Search Console: after deployment, the Enhancements reports list errors and warnings across your whole site.
Both online tools accept either a URL or a pasted code snippet, so you can test before deploying.
Putting It Together with Meta Tags
JSON-LD complements, rather than replaces, your meta tags. A typical article page has a <title>, description and canonical link, Open Graph tags for link previews, and JSON-LD for search engines' understanding of the content. If you've built a Seo component as described in adding SEO meta tags in Astro, you can add an optional schema prop to it and render JsonLd from there, so every page's head data lives in one place.
FAQ: JSON-LD in Astro
Build the schema object in the component frontmatter and render it with a script element whose type is application/ld+json, using set:html with JSON.stringify of the object. Astro treats the script as inline and outputs it unchanged.
No. Google reads JSON-LD anywhere in the HTML, including the body. Putting it in the head alongside your meta tags is a tidy convention, but placing it inside the page content works too.
JSON.stringify leaves the less-than character as it is, so a value containing a closing script tag would end the script element early and could allow injected HTML. Replacing it with the Unicode escape keeps the JSON valid and safe.
It's a package of TypeScript type definitions for the whole schema.org vocabulary, published by Google. Typing your objects with it lets astro check and your editor catch misspelled properties and wrong value types.
Not directly. It helps search engines understand the page and can make it eligible for rich results such as breadcrumbs or article details, which can improve how your listing looks and how often it's clicked.
Yes. Output an array of objects in one script tag, or use several script tags. A blog post commonly has both a BlogPosting and a BreadcrumbList.
Conclusion
Adding JSON-LD in Astro is straightforward because the page is rendered on the server: build a schema.org object in the frontmatter, then render it with set:html={JSON.stringify(schema)} inside a <script type="application/ld+json"> tag. A small JsonLd component that escapes < makes this safe and reusable, and typing the data with schema-dts turns misspelled properties into build-time errors instead of silent failures.
Start with BlogPosting and BreadcrumbList for content pages and Organization and WebSite on the home page, generate everything from the same content collection data that renders the page, and use absolute URLs and ISO dates throughout. Validate with the Rich Results Test and Search Console, and your structured data will stay accurate as your site grows.


