Back to Blog
Guides14 min read20 August 2026

Schema Markup in Astro: SSR, Escaping & Per-Route Setup

How to add JSON-LD schema markup to Astro correctly: the set:html escaping rule, static vs server output, content collections, and per-route generation.

By AI Schema Gen Team

Schema Markup in Astro: A Complete Guide

Astro is one of the best-suited frameworks for structured data, for a simple reason: it defaults to shipping fully-rendered HTML, which is exactly what schema markup needs. Unlike a client-side SPA where content can be invisible to crawlers until JavaScript runs, Astro's default static output means your JSON-LD is sitting right there in the HTML a crawler receives, no extra work required to get that part right.

That said, Astro has its own specific rules for how JSON-LD has to be written, one easy-to-miss escaping requirement, and real decisions to make once you're pulling schema from content collections or mixing static and server-rendered routes. This guide covers all of it: how JSON-LD actually renders in Astro, the security detail that trips up a lot of implementations, how to generate schema per-route from your content collections, what changes if you're using server output, and how to validate the result. Let's build it correctly.

Why Astro is a good fit for schema markup

Astro's core model is "islands architecture": it renders your pages to static HTML by default and only ships JavaScript for the specific components that need interactivity. For schema markup, this is close to ideal: your JSON-LD doesn't depend on hydration or client-side execution at all. It's written into the page during the build (or the request, in server mode) exactly like your other HTML, and it's in the document a crawler fetches from the very first response.

This matters because, as covered in our Next.js schema guide and our headless WordPress schema guide, the single non-negotiable rule for structured data in any modern framework is that it has to be in the server-rendered or statically-generated HTML, not painted in only after client-side JavaScript runs. Astro's default output mode satisfies that rule automatically for the majority of sites: content sites, blogs, marketing pages, documentation, which is exactly the kind of project Astro is built for. You still have to write the JSON-LD correctly, but you're not fighting the framework to get it seen.

Where JSON-LD lives: the set:html requirement

Here's the detail that catches nearly every Astro implementation at least once, and it's worth understanding precisely rather than copy-pasting around it.

Astro escapes expressions by default when you interpolate them into your markup. That's a good security default for regular HTML content, but it's the wrong behavior for a <script type="application/ld+json"> block: if Astro escapes your JSON, the quotes and braces get HTML-entity-encoded and the script tag no longer contains valid JSON. Your schema silently breaks. It's present in the HTML, but it doesn't parse.

The fix is Astro's set:html directive, combined with JSON.stringify() to safely serialize your data:

---
const schema = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": title,
  "description": description,
  "datePublished": pubDate,
  "author": {
    "@type": "Person",
    "name": author,
  },
};
---

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

Two things are doing the real work here. JSON.stringify() produces valid JSON with proper escaping of the string content itself. set:html tells Astro to insert that string as raw HTML rather than escaping it a second time. Skip set:html and use plain curly-brace interpolation instead, and you'll get double-escaped, broken JSON that looks fine in your editor and fails the moment Google, or anything else, tries to parse it.

One additional caution worth calling out explicitly: never build the JSON-LD block with string concatenation or template-literal interpolation of raw content ("headline": "${title}"). If a title or description ever contains a quote character, that approach breaks the script tag and can create an XSS vector. JSON.stringify() handles this correctly by design, so always route dynamic values through it rather than hand-assembling the string.

A complete example: Article schema in a layout

Most Astro sites render schema through a shared layout component that every page or post passes its data into. Here's a complete, correct pattern for a blog post layout:

---
// src/layouts/BlogPost.astro
const { title, description, pubDate, updatedDate, author, image, canonicalURL } = Astro.props;

const schema = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "@id": `${canonicalURL}#article`,
  "headline": title,
  "description": description,
  "datePublished": pubDate.toISOString(),
  "dateModified": (updatedDate ?? pubDate).toISOString(),
  "image": image ? [new URL(image, Astro.site).href] : undefined,
  "author": {
    "@type": "Person",
    "name": author,
  },
  "publisher": {
    "@type": "Organization",
    "name": "Your Company",
    "logo": {
      "@type": "ImageObject",
      "url": new URL("/logo.png", Astro.site).href,
    },
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": canonicalURL,
  },
};
---

<html lang="en">
  <head>
    <title>{title}</title>
    <link rel="canonical" href={canonicalURL} />
    <script type="application/ld+json" set:html={JSON.stringify(schema)} />
  </head>
  <body>
    <slot />
  </body>
</html>

A few things worth noticing. new URL(image, Astro.site).href and new URL("/logo.png", Astro.site).href build absolute URLs from Astro's configured site value: schema properties like image and logo should always be absolute URLs, not relative paths, and Astro.site is the reliable way to get that right regardless of environment. The @id on both the BlogPosting and the mainEntityOfPage connects the article to its own canonical page rather than leaving two disconnected descriptions of the same thing, the same connected-graph principle that matters in any framework. And JSON.stringify will happily drop undefined values (like image when none is set), which keeps you from emitting empty or null fields Google doesn't expect.

Generating schema from content collections

This is where Astro's specific architecture creates a genuine advantage. Content collections give you typed, validated frontmatter for every piece of content, which means the fields your schema needs (title, description, publish date, author) are already structured data before you touch JSON-LD at all. You're not scraping meaning out of prose; you're reading fields Astro has already validated against a schema you defined.

A typical collection definition:

// src/content/config.ts
import { defineCollection, z } from "astro:content";

const blog = defineCollection({
  type: "content",
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    author: z.string(),
    image: z.string().optional(),
  }),
});

export const collections = { blog };

And then, in your dynamic route, that validated data flows straight into your JSON-LD:

---
// src/pages/blog/[slug].astro
import { getCollection } from "astro:content";
import BlogPost from "../../layouts/BlogPost.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();
---

<BlogPost {...post.data} canonicalURL={new URL(`/blog/${post.slug}`, Astro.site).href}>
  <Content />
</BlogPost>

Because getStaticPaths generates one page per collection entry, and each entry's frontmatter is already schema-validated by Zod, your JSON-LD generation isn't a separate maintenance task bolted onto your content, it's a direct mapping from the content model you already built. Add a field to your collection schema, thread it into your layout's JSON-LD object, and every post picks it up automatically.

Static output vs. server output: what changes

Astro gives you two output modes, and the distinction matters for how (and when) your schema gets generated, though it doesn't change the escaping rules at all.

output: 'static' (the default) prerenders every page to HTML at build time. Your JSON-LD is computed once, during the build, and served as a static file from a CDN afterward. This is the right mode for the overwhelming majority of content sites: blogs, marketing pages, documentation, portfolios, and it's the simplest mental model for schema: whatever data was available at build time is what's in your markup until the next build.

output: 'server' renders on-demand by default, with an SSR adapter (Vercel, Cloudflare, Node, and others). In this mode, individual pages opt back into static prerendering with export const prerender = true. If you're running mostly-dynamic routes (a dashboard, personalized content, live pricing) but still want your blog and marketing pages fast and cacheable, mark those specific routes prerender = true and let the rest render per-request. This is the pattern that replaced Astro's older output: 'hybrid' mode, which was folded into static in Astro 5. If you're following an older tutorial that references hybrid, know that it's been superseded and the two-mode system above is current.

For schema markup specifically, the practical implication is this: on a prerendered route, your JSON-LD reflects the content as of the last build, which is exactly why keeping build-time content (via content collections or a build-time data fetch) fresh matters. On a server-rendered route, your JSON-LD is computed fresh on every request, so live data (current price, current availability, current review count) can flow directly into the markup without waiting for a rebuild. If you're marking up something that changes often, like Offer availability or AggregateRating, that's a real reason to consider prerender = false for that specific route rather than accepting build-time staleness on a schema field that's supposed to be current.

Per-template coverage: don't stop at blog posts

A common gap in Astro schema implementations mirrors the one we see across every framework: teams add JSON-LD to their blog post layout and stop there, leaving the homepage, category or tag pages, product or service pages, and any custom collection types without markup entirely.

Because Astro layouts are composable, the cleanest fix is a small, reusable JSON-LD component rather than hand-writing a schema object in every page file:

---
// src/components/JsonLd.astro
const { schema } = Astro.props;
---

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

Then each page type builds its own schema object: Organization for the homepage, CollectionPage or ItemList for a tag archive, Product for a product page, BlogPosting for a post, and passes it to the same component. This keeps the escaping logic in exactly one place (so it can't be gotten wrong twice) while letting every template emit the schema type that actually matches its content.

Building one connected graph, not scattered blocks

As with any framework, the strongest structured data on a page isn't several disconnected blocks, it's a single, cross-referenced picture. Astro doesn't require anything special to do this; the pattern is the same @id cross-referencing that matters everywhere, just expressed through Astro's props and layout composition.

A page's Organization, its BlogPosting, and its author's Person entity should reference each other by @id rather than each block repeating a full, separate description:

---
const orgId = new URL("/#organization", Astro.site).href;

const schema = {
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": orgId,
      "name": "Your Company",
      "url": Astro.site,
    },
    {
      "@type": "BlogPosting",
      "@id": `${canonicalURL}#article`,
      "headline": title,
      "publisher": { "@id": orgId },
      "author": { "@type": "Person", "name": author },
    },
  ],
};
---

Using a single @graph array with shared @id references, generated once from a shared layout, means every page on the site describes the same organization consistently rather than each template re-declaring it slightly differently, which is exactly the kind of drift that confuses search engines and AI systems trying to resolve who's actually publishing the content. This is the same entity profile thinking that matters regardless of framework: a connected graph is worth more than the sum of its isolated pieces.

Common mistakes to avoid

Plain interpolation instead of set:html + JSON.stringify(). This produces double-escaped, invalid JSON that looks correct in the source view but fails to parse. It's the single most common Astro-specific schema bug.

String concatenation for dynamic values. Building JSON-LD by hand-inserting variables into a template string breaks the moment a value contains a quote character, and it's a real XSS risk. Route everything through JSON.stringify().

Relative URLs in image, logo, or url fields. Schema properties expecting URLs need absolute ones. Use new URL(path, Astro.site).href rather than a bare relative path.

Schema only on the blog layout. Homepage, archive pages, and custom collection types get left out. Build a shared JSON-LD component and give every template its own accurate schema.

Stale content-collection fields feeding schema on prerendered routes. If a static-output page's data comes from an external source rather than your content collections, remember its JSON-LD only refreshes on the next build. Plan your rebuild cadence (webhooks, scheduled builds) around that if the underlying data changes.

Mismatched output-mode assumptions. Assuming a page is statically prerendered when output: 'server' actually makes it on-demand by default (or vice versa) leads to confusion about why schema does or doesn't reflect recent changes. Be explicit about prerender on routes where it matters.

Forgetting Astro.site in astro.config.mjs. Building absolute URLs with new URL(path, Astro.site) requires site to be set in your config. Without it, you'll get relative or malformed URLs in fields that need to be absolute.

Implementing schema across an Astro site at scale

Hand-building a JSON-LD object per layout works well for a handful of page types, and the patterns above will get you correct, valid markup. Where it gets harder is scale and drift: a growing content-collection site adds page types faster than someone remembers to extend the shared JSON-LD component, frontmatter fields get added without the schema being updated to include them, and, because content collections live in your repo rather than a CMS with a plugin watching for changes, nothing automatically flags a page that's shipping incomplete or outdated structured data.

This is where generating schema from your actual content, rather than maintaining hand-written objects per template, holds up better as a site grows. AI Schema Gen reads your page content and generates complete, entity-first structured data across 827+ schema types, connected into your site's broader entity profile rather than a series of disconnected per-page blocks, and because it generates from your live content, new pages and new fields don't require someone to remember to update a layout component by hand. For a content-heavy Astro site with many collection types, that's the difference between schema that covers what existed at launch and schema that stays complete as the site grows.

Whichever route you take (hand-written layouts, a community integration like astro-structured-data, or a content-generated approach) the test is the same: is every page type covered, is the JSON-LD actually valid (not just present), and does it stay accurate as your content changes.

Validating your Astro schema

View source, not the DOM inspector. Because Astro's static and server output both produce real HTML, view-source: on a live page should show your <script type="application/ld+json"> block exactly as rendered. If it's missing there, it never shipped, regardless of what a browser dev tools panel shows after any client-side changes.

Run it through Google's Rich Results Test on the live URL, not just a local build, to catch anything that only breaks in production (a missing Astro.site value, an environment variable that didn't get set on deploy). It's also worth keeping an eye on how Google's own structured data requirements shift over time, our 2026 roundup covers the changes worth knowing about.

Run the Schema Markup Validator at validator.schema.org for broader schema.org validity beyond just the types with a dedicated rich result.

Test each distinct template, not just one page (a blog post, a tag archive, the homepage, a product page if you have one), since a bug in a shared layout component affects every page using it, and catching it on one template tells you to check the others.

Watch Search Console after any content-collection schema change or migration, since a schema field silently going missing across a whole content type is exactly the kind of gap that surfaces there before anywhere else.

Frequently Asked Questions

Generate perfect schema in 30 seconds

AI Schema Gen handles everything automatically, free to start.

Get Started Free