SEO & Structured Data
Olsa’s SEO layer is owned, not vendored. BaseHead emits every meta and Open Graph tag natively, structured data comes from typed builders in src/js/schema.ts, and robots.txt and llms.txt are dynamic endpoints. There is no astro-seo, no robots package and no schema library.
One value feeds all of it: site in astro.config.mjs. It is still https://example.com. Setting it fixes the canonical link, og:url, og:image resolution, the sitemap, the RSS feed, the Sitemap: line in robots.txt, every link in llms.txt and the JSON-LD @ids — at once.
What every page gets
A route passes title and description to BaseLayout, which forwards them plus four optional props to BaseHead. From that, every page emits:
Document basics — charset, viewport, generator, a <link rel="preload"> for the variable font, both favicons, the sitemap link, and an RSS <link rel="alternate">.
Core SEO — <title>, the meta description, and a canonical link. When noindex is set, a robots meta with noindex, nofollow.
Open Graph — og:type (flipping to article when the article prop is present), og:title, og:description, og:url, og:site_name, og:locale, og:image, og:image:alt, og:image:width and og:image:height.
Twitter — summary_large_image, title, description, image, image alt, and twitter:creator when siteData.author.twitter is set.
Article meta, when the article prop is present — article:published_time, article:modified_time and article:author.
JSON-LD — the site graph plus any page-specific nodes, in one <script type="application/ld+json">.
Four details in there are decisions rather than defaults:
- Canonical and
og:urlderive from one expression,new URL(Astro.url.pathname, Astro.site), so they cannot disagree. That is the single most common SEO bug in hand-rolled heads. og:imagedimensions are real when they can be. Pass anImageMetadataas theimageprop and the width and height are the actual ones; otherwise they fall back to the 1200×630 convention, which is whypublic/og.jpgshould match it. The blog and integration detail routes both pass their entry’s image.og:localeis normalised. Open Graph wantslanguage_TERRITORY, sositeLocale’sen-USbecomesen_US.- No hreflang is emitted, because the site is single-language. That is a rule, not a gap: hreflang is only meaningful with two or more locales, and emitting a self-referential one is noise.
Passing SEO props from a route
<BaseLayout
title={`${title} — ${siteData.name}`}
description={description}
image={heroImage}
schema={[article, getBreadcrumbSchema(trail, Astro.site)]}
article={{ published: pubDate, modified: updatedDate, author: author?.data.name }}
>
That is src/pages/blog/[id].astro, and it is the fullest example. image sets the social image, schema takes one node or an array of them, and article drives both og:type and the article:* meta.
The prop types live in @js/schema (SeoProps, ArticleMeta) and are shared by both layouts, so the two cannot drift.
The JSON-LD builders
src/js/schema.ts exports six builders and one serializer. Each returns a plain JsonLdNode — inputs are typed precisely, node shape stays loose, because schema.org is open-ended and over-typing it just fights the vocabulary.
| Builder | Emits |
|---|---|
getOrganizationSchema |
Organization, with a stable @id |
getWebSiteSchema |
WebSite, linked to its publisher by @id |
getSiteSchema |
both of the above, wired together |
getArticleSchema |
BlogPosting |
getBreadcrumbSchema |
BreadcrumbList |
organizationId |
the stable @id string, derived from any absolute site URL |
serializeJsonLd |
the document string, ready to inline |
Stable @ids are the point. organizationId(url) returns <origin>/#organization, and the WebSite and BlogPosting nodes reference it by @id rather than duplicating the Organization inline. That is what makes a @graph coherent to a consumer instead of three unrelated blobs.
BaseHead emits the site graph on every page. It imports only getSiteSchema, builds it from siteData, merges any page nodes, and serialises once. Page authors never hand-write JSON-LD — they call a builder and pass the result.
serializeJsonLd escapes < to <. A value containing </script> would otherwise break out of the inline tag, and every value in this graph originates in config or content. A single node inlines directly; multiple nodes wrap in @graph.
The module has no imports, deliberately. That is what lets schema.selfcheck.ts run it under plain node --experimental-strip-types, which knows nothing about tsconfig path aliases. It is also why ContactDetails — the input shape for the Organization’s contact channels — is defined here and imported by the config types rather than the other way round. The dependency points from config toward the util, never the reverse.
The Organization node
Worth calling out because two config fields feed it and both ship empty or placeholder.
sameAs is the array of social and profile URLs that identify the same entity. It ships as [], and without it a search engine has no way to connect your Organization node to your accounts elsewhere. Fill it in, and mirror the same URLs into navData.social so the footer icons and the structured data agree.
siteData.contact supplies email, telephone and a structured PostalAddress. The same block renders the three channel cards on /contact/, so the page and the structured data are the same fact rather than two lists that happen to match. It ships with placeholders — an example.com inbox, a reserved-for-fiction 555 number and the mock’s Austin address.
The Organization logo currently points at siteData.defaultImage.src, marked ponytail: in BaseHead. Swap it for a real brand logo before deploy.
Breadcrumbs: one trail, two consumers
The two nested routes each declare one array of crumbs and use it twice:
const trail = [
{ name: "Home", href: "/" },
{ name: "Blog", href: "/blog/" },
{ name: title, href: Astro.url.pathname },
];
It goes to getBreadcrumbSchema(trail, Astro.site) for the BreadcrumbList node and down to Sections/Global/Breadcrumbs.astro for the visible navigation. The visible markup and the structured data are therefore the same data by construction, rather than two lists that agree today.
Crumb.href is site-relative on purpose, and resolving it to an absolute URL is the builder’s job. Neither route open-codes a URL join.
If you add a nested route, follow the pattern. A BreadcrumbList that disagrees with the visible navigation is worse than none.
Crawlability
Sitemap. @astrojs/sitemap emits /sitemap-index.xml, linked from BaseHead. A filter in astro.config.mjs drops /examples/, /404/, /signin/ and /signup/, because all four set noindex in their markup and a sitemap entry would contradict the directive. Keep the two in step when you add a noindexed route.
robots.txt is a dynamic endpoint (src/pages/robots.txt.ts) rather than a file in public/, so the Sitemap: line resolves against site and can never drift from the real domain. It allows everything and prerenders to a static file at build.
Note that the noindexed pages stay crawlable — there is no Disallow for them. That is deliberate: a crawler has to fetch a page to see its noindex, so blocking it in robots.txt would prevent the directive from ever being read.
llms.txt is a dynamic endpoint emitting a small markdown content map for AI retrieval systems, following the llmstxt.org convention. It is a curated editorial map, not an auto-generated sitemap, and it is not a ranking factor. It ships listing the ten core pages and the feed, and carries a ponytail: note to expand as the site grows — if you add a section worth pointing an agent at, add it there by hand.
Trailing slashes. trailingSlash: "always" gives one canonical URL shape, agreeing with the directory build, the canonical tag, the RSS item links and every internal href.
What each page type emits
| Page | Structured data |
|---|---|
| Every page | Organization + WebSite, linked by @id |
/blog/<id>/ |
plus BlogPosting and BreadcrumbList |
/integrations/<id>/ |
plus BreadcrumbList |
Marketing pages carry the site graph only, which is correct — inventing a Product or Service node for placeholder copy would be worse than nothing. If your pricing page describes real offerings, getOrganizationSchema is the model to copy for an Offer builder.
The rules to keep
Do not add an SEO package. Every tag is already native and every artifact is generated from typed config; a package would add a second source of truth for the same tags.
Do not hand-write JSON-LD in a page. Call a builder — that is what keeps the @id wiring correct and the escaping applied.
Do not set a canonical by hand. It derives from Astro.site and the path, and a hand-set one is how a page ends up canonicalising to a URL that does not exist.
When you change something here, node --experimental-strip-types src/js/schema.selfcheck.ts runs the builders’ check directly, or pnpm test runs all seven checks. Neither astro build nor astro check executes them.