Tutorial
Astro Structured Data: The JSON-LD Google Still Uses
· 13 min read
On this page
Structured data is the one SEO task that’s genuinely deterministic: you emit a JSON object, Google parses it, and either your page qualifies for a search feature or it doesn’t. No guessing about ranking factors.
The catch is that most tutorials describe a version of Google that no longer exists. HowTo
markup was retired in 2023. FAQPage limped on for gov and health sites until May 7, 2026,
when FAQ rich results stopped appearing in Google Search entirely; Google deleted the
documentation the following month. Course info, estimated salary, learning video, special
announcement, and vehicle listing all went the same way in 2025, and practice problems in
January 2026. This isn’t speculation; it’s a
documented pattern of removals
that Google frames as simplifying the results page.
Marking up a dead type produces valid JSON-LD that buys you exactly nothing. (It also doesn’t hurt: Google is explicit that there’s no need to rip out old markup, and other search engines may still read it. Just don’t write new markup for these.)
So let’s start from the other end: what still does something, then how to emit it from Astro without hand-writing a JSON blob per page.
This post covers, in order:
- Which schema types still produce something in 2026 — and which are dead
- One JSON-LD slot in your base layout, instead of a
<script>per page BlogPostingbuilt from your content collection schema — one source of truthPerson/Organization/WebSite, defined once and referenced by@idBreadcrumbList, the type with an actual visible payoff- Validation, and what Search Console will and won’t tell you
What’s worth marking up in 2026
| Type | What you actually get |
|---|---|
BreadcrumbList | ✅ Controls the trail Google shows in place of your URL path (desktop) |
Product | ✅ Price, availability, review stars — with the right fields |
Article / BlogPosting | ⚠️ No rich result card of its own — but it’s how Google reads author and dates |
Organization / WebSite | ⚠️ Knowledge panel, logo, and your site name |
HowTo | ❌ Retired in 2023 |
FAQPage | ❌ Removed from Search entirely, May 2026 |
Three of those deserve a note.
Most people over-invest in BlogPosting. There is no Article rich result card — nothing
about your listing renders differently because you shipped it, and you won’t find Article among
the reported types in Search Console’s Enhancements list. It isn’t a ticket into Top Stories or
Discover either: Google states plainly that there’s no markup requirement for Top Stories, and
that Discover needs no special tags or structured data (what Discover actually wants is
max-image-preview:large and an image at least 1200px wide). What it buys you is that Google
reads your headline, image, author, and dates unambiguously instead of inferring them. Emit it,
keep it accurate, don’t expect fireworks.
BreadcrumbList gets skipped, and it’s the type here that reliably changes what your
listing looks like — especially when your URL path doesn’t already tell that story. Google will
infer a trail from your URL structure regardless; the markup is how you decide what it says.
Note it’s a desktop feature.
WebSite is where stale tutorials do the most damage. If a guide tells you to add a
SearchAction for the sitelinks search box, close it: that feature was removed from Google
Search in November 2024. WebSite still matters, but for a different reason — see below.
Wire JSON-LD into your Astro layout once
The mistake I see in Astro projects is a <script type="application/ld+json"> copy-pasted into
each page template. Put one slot in your base layout instead, and let pages supply the object:
---
// src/layouts/BaseLayout.astro
interface Props {
title?: string;
description?: string;
ogType?: 'website' | 'article';
/** schema.org structured data, rendered as JSON-LD in <head>. */
jsonLd?: Record<string, unknown>;
}
const { title, description, ogType = 'website', jsonLd } = Astro.props;
---
<head>
<!-- …title, meta, canonical… -->
{jsonLd && (
<script
type="application/ld+json"
set:html={JSON.stringify(jsonLd).replace(/</g, '\\u003c')}
/>
)}
</head>
Three details in that one line matter, and the first one is a trap I’d never seen written down until I hit it:
set:html, not{JSON.stringify(...)}as a child.<script>is a raw-text element, so Astro doesn’t treat its contents as a template at all. Write the expression as a child and the literal string{JSON.stringify(jsonLd)}is what lands in your HTML — silently, with no build error. (In a normal element the expression is evaluated and then HTML-escaped, which would break the JSON in a different way.)set:htmlis the escape hatch that writes a value in verbatim.- Writing raw means you own the escaping — that’s what the
.replaceabove is for.JSON.stringifydoesn’t escape<, so a post whose title contains a literal</script>closes the block early and the rest of your JSON gets parsed as markup. Escaping<as\u003cis still valid JSON, parses identically, and costs nothing. JSON.stringifyon an object, not a template literal. Hand-written JSON inside a template string is how you end up with a trailing comma that silently kills the whole block.
Build BlogPosting JSON-LD from Astro content collections
Here’s the part I’d copy straight into a project. Because Astro’s content collections are typed, the post frontmatter is your structured-data source — no second place to keep in sync.
The schema, with the fields structured data cares about:
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/[^_]*.{md,mdx}' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
category: z.enum(['tutorial', 'comparison', 'build-in-public']),
tags: z.array(z.string()).default([]),
// Cross-posts elsewhere point here; only set if the canonical source is NOT this site.
canonicalUrl: z.string().url().optional(),
ogImage: z.string().optional(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
Two schema details do real work downstream. .default([]) on tags means the field is always an
array, so the conditional spread below never throws on a post that declared no tags. And
z.coerce.date() is cheap insurance rather than a conversion: an unquoted 2026-07-29 is
already parsed into a Date by the YAML frontmatter parser, but quote it and you get a string
back. coerce normalizes both, which is what lets you call .toISOString() downstream without a
second thought: schema.org wants ISO 8601, and a bare 2026-07-29 says nothing about the time
zone.
Then the page builds the node. This is the live code from this site with the i18n plumbing taken
out — the real file is src/pages/[...locale]/blog/[slug].astro and adds inLanguage and
articleSection. The @id references and the graph() wrapper are the subject of the next
section, so read past them for now:
---
// src/pages/blog/[slug].astro
import { SITE_URL } from '../../consts';
import { ORGANIZATION_ID, PERSON_ID, WEBSITE_ID, baseNodes, graph } from '../../schema';
const { post } = Astro.props;
const { title, description, pubDate, updatedDate, tags, canonicalUrl } = post.data;
const ogImage = post.data.ogImage ?? `/og/${post.id}.png`;
const canonical = canonicalUrl ?? new URL(`/blog/${post.id}/`, Astro.site).href;
const jsonLd = graph(
{
'@type': 'BlogPosting',
'@id': `${canonical}#article`,
headline: title,
description,
url: canonical,
mainEntityOfPage: canonical,
image: new URL(ogImage, SITE_URL).href,
datePublished: pubDate.toISOString(),
dateModified: (updatedDate ?? pubDate).toISOString(),
author: { '@id': PERSON_ID },
publisher: { '@id': ORGANIZATION_ID },
isPartOf: { '@id': WEBSITE_ID },
...(tags.length > 0 && { keywords: tags.join(', ') }),
},
...baseNodes
);
---
<BaseLayout {title} {description} {canonicalUrl} {pubDate} {updatedDate} {ogImage} ogType="article" {jsonLd}>
<!-- … -->
</BaseLayout>
Four things in it are easy to get wrong:
url and mainEntityOfPage must match your <link rel="canonical">. Note canonicalUrl
going through to the layout as well — that’s what keeps the canonical tag and the JSON-LD
pointing at the same place. If the post is cross-posted and the canonical points elsewhere, the
structured data has to point there too. Contradicting yourself in two places is worse than
omitting the field. (mainEntityOfPage isn’t in Google’s recommended properties for Article, so
this is a consistency rule rather than a requirement — but an inconsistent one is actively
confusing.)
image should be absolute. Google’s actual requirement is that the URL be crawlable and
indexable; a relative /og/my-post.png technically resolves against the page’s base URL, but
it’s fragile and easy for tooling to mishandle. Emit the absolute form. If you generate those
images per post at build time, I covered that setup in
Auto-Generate Open Graph Images in Astro with Satori — the same
path feeds both the og:image tag and this field.
dateModified should fall back to datePublished, not to today. Some templates stamp
new Date() there, which tells Google every page changed on every build. Falling back to the
publish date is honest and stable.
Spread conditional fields, don’t emit empty ones. JSON.stringify drops properties whose
value is undefined, so a stray author: undefined disappears on its own — but an empty string
doesn’t. keywords: '' ships as "keywords":"" — you declaring the field empty, rather than
never making a claim about it. ...(cond && { field }) omits it outright.
Person vs. Organization vs. WebSite
Short version for a one-person site:
Person— the author. Give it aurlpointing at a profile that establishes the same identity elsewhere (GitHub, a personal site). A name with nourlis a string, not an entity.Organization— the publisher, i.e. the site itself. A one-person site still has a publisher: the site is the publisher, you’re the author. It also influences whichlogoGoogle shows.WebSite— its practical use today is your site name:nameplusurlis the primary signal Google reads when deciding the label shown above your URL in results. That’s a visible change, and it’s the reason to keepWebSiteeven though theSearchActionhalf of every older tutorial is now dead weight.
One thing markup can not do: sitelinks. Those are fully automated, and no structured data affects them.
All three describe the same entities on every page, which is the case @graph and @id exist
for. Define them once in a module, give each a stable @id, and let pages reference them instead
of repeating the fields:
// src/schema.ts
type Node = Record<string, unknown>;
export const PERSON_ID = `${SITE_URL}/about/#person`;
export const ORGANIZATION_ID = `${SITE_URL}/#organization`;
export const WEBSITE_ID = `${SITE_URL}/#website`;
export const person: Node = {
'@type': 'Person',
'@id': PERSON_ID,
name: AUTHOR.name,
url: `${SITE_URL}/about/`,
knowsAbout: ['Astro', 'Cloudflare Workers', 'Static site generation', 'Technical SEO'],
// What lets a consumer merge this with the GitHub / Gumroad profiles into one entity.
sameAs: [AUTHOR.github, AUTHOR.gumroad, AUTHOR.astroBuildProfile],
};
export const organization: Node = {
'@type': 'Organization',
'@id': ORGANIZATION_ID,
name: SITE_TITLE,
url: `${SITE_URL}/`,
founder: { '@id': PERSON_ID },
sameAs: [AUTHOR.github, AUTHOR.gumroad],
};
export const website: Node = {
'@type': 'WebSite',
'@id': WEBSITE_ID,
name: SITE_TITLE,
url: `${SITE_URL}/`,
publisher: { '@id': ORGANIZATION_ID },
};
/** Nodes shared by every page. Spread last so page-specific nodes read first. */
export const baseNodes: Node[] = [website, organization, person];
/** Wrap nodes in the single graph a page emits. */
export function graph(...nodes: Node[]): Node {
return { '@context': 'https://schema.org', '@graph': nodes };
}
Two consequences worth naming. The @ids are absolute URLs with a fragment, and they are the
only thing tying the nodes together — a typo doesn’t throw; it just produces an orphan
reference. Route them through exported constants instead of string literals in templates. And the entities
ship on every page rather than only the homepage; that isn’t duplication, because a shared
@id says “one entity, described again” rather than “another entity that happens to match”.
On a small site, don’t hand-assemble that graph per template or invent types to fill it out. One
module plus a graph() call is the whole pattern.
Add BreadcrumbList — the type with a visible payoff
Since this is the type that actually changes your listing, here’s the whole thing — a helper, so
pages describe their trail as a list of pairs instead of hand-written ListItem objects:
// src/schema.ts
/**
* BreadcrumbList from [name, path] pairs, e.g.
* breadcrumb([['Home', '/'], ['Articles', '/blog/'], [title]])
* The final entry is the current page and omits `item`, per Google's guidance.
*/
export function breadcrumb(items: Array<[string, string?]>): Node {
return {
'@type': 'BreadcrumbList',
itemListElement: items.map(([name, path], i) => ({
'@type': 'ListItem',
position: i + 1,
name,
...(path ? { item: new URL(path, SITE_URL).href } : {}),
})),
};
}
Called from the post page, the trail is one more node in the same graph:
---
// src/pages/blog/[slug].astro
const jsonLd = graph(
article,
breadcrumb([['Home', '/'], ['Articles', '/blog/'], [title]]),
...baseNodes
);
---
You need at least two ListItems for Google to use it, and the last entry drops item on
purpose — Google uses the page’s own URL. Note what didn’t change to ship a second type: not
the layout, not the prop type, not the render line. That’s the practical payoff of one @graph
over a <script> per type. (A top-level array of separate objects is valid JSON-LD too, if you’d
rather skip @graph — but then the prop widens to
Record<string, unknown> | Record<string, unknown>[].)
There’s one rule that matters here: the trail has to reflect navigation that actually exists. Home → Articles → post matches this site’s header links and URL structure, so the markup describes something real. A visible breadcrumb component is the surest way to keep yourself honest there, and it’s worth building — but the requirement is that the hierarchy be real, not that it be rendered. Inventing a hierarchy your site doesn’t have is a spam signal, not a shortcut.
Validate your structured data with two tools
Two different tools, and you want both:
- Rich Results Test — checks whether the page
qualifies for a Google feature. This is also the fastest way to confirm everything above: feed
it a page with
FAQPagemarkup and watch it report nothing. - Schema Markup Validator — checks whether your JSON-LD is valid schema.org, independent of Google. Useful for types Google doesn’t consume, and for catching misspelled property names the Google tool silently ignores.
Then, after a couple of weeks, check Search Console → Enhancements (product snippets and
merchant listings live under Shopping instead). Only types with a supported rich result get a
report there. Breadcrumbs will show up; BlogPosting will not, because there’s no Article rich
result card to report on. That absence is the clearest confirmation of the point at the top of
this post: BlogPosting is plumbing, not a rich result.
And on a new domain, Google has to crawl the page before any of it shows up. Structured data won’t get you indexed faster — it’s what pays off once you already are. (If you’re not live yet, the Cloudflare Workers deployment guide covers getting the site deployed and its production URL configured, which is the prerequisite for every absolute URL above.)
Recap
- Skip
HowTo,FAQPage, andSearchAction. All three are dead in 2026. Leave existing markup alone if you have it, but don’t write new. BreadcrumbListis the highest-value type for most sites. Keep the trail honest: it has to match navigation your site actually has.BlogPostingwon’t get you a card; it gets your authorship and dates read correctly. KeepurlandmainEntityOfPageidentical to your canonical, andimageabsolute.WebSiteis how you claim your site name — the half of it worth keeping.- Define
Person/Organization/WebSiteonce with stable@ids and reference them from the article node, so every page ships one@graphinstead of repeating entity fields. - Build the object from your content collection schema so there’s one source of truth, pass it
through the layout as a prop, and render with
set:html(escaping<).
If you’re building something with listings rather than posts — a directory, a catalog — the same
pattern extends to ItemList and Product, which do still produce visible results. The data
source changes, not the shape: frontmatter for a blog, a database for a directory. Which of
those you end up with is a theme-level decision, and I went through
the Astro directory themes worth considering separately.
Either way the rule is the same: emit the types that still do something, generate them from data you already maintain, and let the ones Google retired stay retired.