SEO & Structured Data
8-BitQuest’s SEO layer is owned, not vendored. Every meta tag is emitted natively by BaseHead, every artifact — sitemap, robots, RSS, llms — is generated, and there is no SEO, robots or schema package anywhere in the dependency list. The whole surface derives from one setting (site) and typed config, so it is small to keep correct. The <head> mechanics also cover the theme bootstrap and view transitions (Colors and Motion); this page is the SEO that rides on top.
What BaseHead emits
src/layouts/BaseHead.astro owns the entire <head> with native tags. On every page it emits:
- Document basics — charset, viewport, generator, the two above-the-fold font preloads, the favicons, the sitemap link, and the RSS
alternatelink. - Title, description and canonical. The canonical URL and
og:urlboth come from onenew URL(Astro.url.pathname, Astro.site), so they can never disagree. - Open Graph —
og:type(website, orarticlewhen anarticleprop is passed), title, description, url,site_name,locale, and the image with itsalt,widthandheight. The image resolves to an absolute URL with asiteData.defaultImagefallback; a bundled image contributes its real dimensions, otherwise the 1200×630 convention is emitted.og:localeis normalised from the BCP-47siteLocaleto thelanguage_TERRITORYform OG wants (en-US→en_US). - Twitter — a
summary_large_imagecard, plustwitter:creatorfromsiteData.author.twitter(omitted entirely if the handle is empty). - Article tags — when a page passes the
articleprop,article:published_time, andarticle:modified_time/article:authorwhen present. noindex— a page can setnoindex, which flips on therobotsnoindex, nofollowtag. The 404 and the dev-only catalog are the two routes that do.
Structured data
The JSON-LD builders live in src/js/schema.ts — dependency-free functions that each return a plain node. getSiteSchema composes an Organization + WebSite graph, emitted on every page, the two linked by stable @id so they cross-reference rather than duplicate. BaseHead builds it from siteData and serialises it, along with any page-specific nodes, into a single inline <script type="application/ld+json">.
Page authors never hand-write JSON-LD — they call a builder and pass the result up through the schema prop:
<BaseLayout
schema={[articleSchema, breadcrumbSchema]}
article={{ published: pubDate, modified: updatedDate, author: name }}
>
serializeJsonLd escapes < to < so a value containing </script> cannot break out of the inline tag, and the builder logic carries a runnable self-check (schema.test.ts, run by pnpm test). sameAs — the social/profile URLs in siteData — feeds the Organization node, which is why keeping that list current matters for how search engines connect your brand.
The blog post nodes
A blog article passes two extra nodes: a BlogPosting (with the hero as its image, the author’s authorLink as author.url, and a publisher reference to the same site Organization @id that BaseHead emits everywhere) and a BreadcrumbList. The breadcrumb schema is paired with a visible Home › Blog › <title> breadcrumb on the page — the rule the theme holds is that a breadcrumb schema is only ever emitted alongside a visible one, so markup and structured data always agree. dateModified appears only when a post carries an updatedDate; it is never invented.
Crawlability and indexation
- Sitemap —
@astrojs/sitemapemits/sitemap-index.xml, linked inBaseHead. Afilterdrops thenoindexroutes (the dev catalog and 404) so the sitemap never contradicts a page, and because the on-demand/contact/route emits no static file, it is added by hand through the sitemap’scustomPages. - robots.txt — a dynamic endpoint (
src/pages/robots.txt.ts), not a/publicfile, so itsSitemap:line resolves againstsiteand never drifts from the real domain. It allows everything and prerenders to a static/robots.txt. - llms.txt — a dynamic endpoint emitting a small, curated markdown content map for AI crawlers (the core pages, the blog and project indexes, the feed and legal pages), with every URL absolute. It is a hand-maintained index, not an auto-sitemap, and it is not a ranking factor — add new top-level entry points here as the site grows.
- Trailing slashes — one URL shape, enforced by
trailingSlash: "always", agreeing with the directory build, the canonical link and OG.
The omissions that are rules
Two things this layer does not do are deliberate, not gaps:
- No hreflang. 8-BitQuest is single-language, and hreflang is only meaningful with two or more locales. The block was removed with the i18n layer; the theme’s
wiki/records the shape to restore if a project reintroduces locales. - No breadcrumb schema without a visible breadcrumb. The blog post is the one place breadcrumb structured data is emitted, precisely because it is the one place a visible breadcrumb is rendered. If you add a breadcrumb schema elsewhere, add the visible navigation first.
The one setting that drives it all
The site value in astro.config.mjs — the SITE_URL env var, defaulting to the https://example.com placeholder — feeds canonical, OG, the sitemap, robots, llms and RSS. Setting it once before deploy fixes every absolute URL at the same time, and a production deploy throws if it is still the placeholder. See Deployment.