SEO
Medice’s SEO surface is owned, not vendored. Every tag is native, every artifact is generated from typed config, and there is no SEO package, no robots package and no schema package — the same stance as the motion catalog and the icon set.
src/layouts/BaseHead.astro owns the whole <head>. src/js/schema.ts builds the structured data.
One value feeds everything
site in astro.config.mjs reads SITE_URL, and canonical, og:url, JSON-LD, the sitemap, robots.txt and llms.txt all derive from it. Six things one wrong value poisons at once, none of them visibly broken in review.
It defaults to https://example.com so a fresh clone builds, and a production deploy throws on that placeholder. See Deployment for how the gate detects a production build.
Meta, OG and Twitter
Canonical and og:url both derive from one new URL(Astro.url.pathname, Astro.site), so they cannot disagree.
The social image resolves to an absolute URL with a siteData.defaultImage fallback, and OG carries og:image:width, height and alt — real dimensions from a bundled ImageMetadata where there is one, else the 1200×630 convention.
og:locale is normalised to language_TERRITORY from the siteLocale constant, so en-US becomes en_US.
An optional article prop flips og:type to article and emits article:published_time, article:modified_time and article:author. The health library’s post route passes it.
No hreflang is emitted — the site is single-language, and hreflang is only meaningful with two or more locales.
The robots meta
BaseHead emits a robots tag only when noindex is set. An indexable page gets no directive and takes the crawler’s defaults.
That is a reasonable default and it does leave something on the table. Saying the positive case out loud — index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1 — opts into full-text snippets and large previews in Discover, image search and AI Overviews, and it is one line in BaseHead if you want it.
Two routes opt into noindex today: /404 and the dev-only /examples/ui catalog.
Structured data
src/js/schema.ts holds dependency-free JSON-LD builders. Each returns a plain JsonLdNode — inputs typed precisely, node shape kept loose because schema.org is open-ended.
| Builder | Emits |
|---|---|
getOrganizationSchema |
Organization |
getWebSiteSchema |
WebSite |
getSiteSchema |
both, composed into the site-level graph and linked by @id |
getArticleSchema |
BlogPosting |
getBreadcrumbSchema |
BreadcrumbList |
getTrailSchema |
a BreadcrumbList adapted from the project’s own TrailItem[] |
getFaqSchema |
FAQPage, or undefined for an empty list |
getPhysicianSchema |
Physician |
serializeJsonLd |
the <script> body |
Stable @ids let nodes cross-reference inside a @graph, so WebSite and BlogPosting point at the Organization by @id rather than duplicating it.
Every page gets the site-level graph for free. BaseHead imports only getSiteSchema, builds it from siteData, merges any page-specific nodes passed through the schema prop, and serialises once into a single inline <script type="application/ld+json">.
Page authors never hand-write JSON-LD:
<BaseLayout schema={[getArticleSchema(…), getTrailSchema(…)]} article={{ published, modified }}>
serializeJsonLd escapes < as <, so a value containing </script> cannot break out of the inline tag. schema.test.ts covers it.
One trail, rendered twice
Every page with a visible breadcrumb passes the same array to both the markup and the schema:
const trail = getTrailSchema(hero.trail, Astro.url.pathname, Astro.site);
<PageHero> draws it and getTrailSchema describes it. A second hand-written list would be a second place for it to drift from what the reader actually sees.
getTrailSchema is the one function in schema.ts with an import, and it is type-only — erased before the module runs, so schema.test.ts still loads the file as plain Node with nothing to resolve.
Two schema decisions worth keeping
getPhysicianSchema deliberately emits no aggregateRating. Review rich results are the one type search engines penalise for unverifiable self-published numbers. The rating still renders on the page; it just does not claim to be a structured review aggregate.
getFaqSchema ships under the rule that a FAQPage node is only emitted where the page visibly draws the questions and their answers. Marking up content a visitor cannot see is a structured-data violation and one of the easier ways to earn a manual action.
Crawlability
robots.txt is a dynamic endpoint rather than a file in public/, so its Sitemap: line resolves against site and cannot drift. It allows everything except /api/ — the one path worth disallowing, because POST /api/book/ answers a GET with nothing and a crawler that finds it spends budget on a dead end. It prerenders to a static file.
llms.txt emits a small markdown content map for AI crawlers, built from siteData and site. It is a curated map rather than an auto-generated sitemap, and it opens with the practice description from siteData — which is why that description matters beyond the meta tag.
sitemap-index.xml comes from @astrojs/sitemap with a filter dropping /examples/ and 404s, so the sitemap never contradicts a page’s own noindex.
rss.xml is hand-rolled, escaped RSS 2.0 over the same getPosts() the index uses, so the feed and the index agree about what “published” means.
Trailing slashes are "always", agreeing with the directory build, canonical and OG.
Two things you might add
Neither is built here, and both are one-line additions to BaseHead if you want them: an apple-touch-icon.png, and optional prev/next props emitting <link rel="prev"> and <link rel="next"> for paginated series, which Bing still honours.
A larger and higher-value addition would be a build-time head check — a script walking every indexable page in dist/client/ asserting exactly one non-empty <title>, a non-empty meta description, exactly one <h1>, and an alt on every <img>, failing the build on any of those. Everything above is a convention someone must follow; the head is exactly where a silent regression stays invisible until a crawler finds it.
Two notes if you write it: the checks must parse attributes the way the production HTML compressor emits them — unquoted and reordered — and this repo’s test runner discovers *.test.ts under src/, so a dist/-scanning check needs either a home under src/ or a widened discovery glob.
The pre-launch SEO list
SITE_URLset to the real domain.siteData.json.ts— name, description,sameAs,twitterCreator. The description ships in the JSON-LD on every page and opensllms.txt; a check fails the build if it still describes a starter template.public/og.jpgreplaced with a real 1200×630 image.- Favicons replaced.
- The thirteen
PLANNED_ROUTESeither built or unlinked — a chrome full of internal links to 404s is a real crawl cost. See Routing.