SEO & Metadata

How sitemap.xml, robots.txt, and every page's title/description/OG image are generated — and what a new page needs to do to get them automatically.

Every page renders through BaseLayout.astro, which owns <title>, the meta description, the canonical link, every og:*/twitter:* tag, and the favicon links — a new page sets a few props and gets the rest for free:

<BaseLayout
  title="Blog"
  description="News, updates, and stories."
  ogImage={post.image}
  ogType="article"
>

The brand suffix — one editable place, not thirty hardcoded ones

title is the page-specific part only — BaseLayout appends " — {header.brand}" automatically, where header.brand is the same editable value that already drives the nav logo and og:site_name (The Content Model’s DEFAULT_HEADER, or whatever a site owner has published through the CMS). Every page used to hardcode its own " — Gravity Node" suffix; rebranding meant editing ~30 files by hand. Now the brand lives in exactly one place, and every page’s title, og:title, and twitter:title follow it automatically.

The one deliberate exception is this documentation site itself (DocsLayout.astro’s rawTitle prop) — /docs documents the Gravity Node framework, not whatever a deployed site is branded as, so it always says “Gravity Node” regardless of header.brand.

OG images — real content images first, one static default otherwise

ogImage is converted to an absolute URL automatically (src/lib/seo.ts’s toAbsoluteUrl()) — og:image/twitter:image must be absolute for Facebook/Twitter/LinkedIn/Slack to render a preview at all; a relative path silently fails on all of them.

Pages with real content pull their own image automatically instead of the generic default:

Page Image source
Blog post post.image (optional field)
Event event.image
Shop item item.images[0]
Podcast the feed’s own artwork
Home / About / Features the published hero block’s image field, when one exists (content.ts’s heroSeoFrom())
Everything else public/img/og-image.png (the site default)

No dynamic per-page image compositing (rendering the page title onto a template image at request time) is built — that needs a raster renderer (satori + resvg or similar) that behaves differently across this project’s four deploy targets (Node has no constraint, Cloudflare Workers has real WASM/binary-size limits). A real cross-runtime risk for a template whose whole point is deploying cleanly everywhere, so it was deliberately left as a documented extension point rather than guessed at. Replacing the default assets for a rebrand: public/img/og-image.png (1200×630), public/favicon.svg, public/favicon.ico, public/img/apple-touch-icon.png (180×180) — same filenames, so nothing in BaseLayout needs to change.

sitemap.xml & robots.txt — generated per request, not at build time

Both (src/pages/sitemap.xml.ts, src/pages/robots.txt.ts) are generated at request time, not via @astrojs/sitemap or a static file in public/. Two reasons that’s the right call for this project specifically, not just a style choice:

  1. The deployed domain isn’t known at build time. This is a template deployed to a different domain per install — a fixed site: URL baked in at build time would be wrong for everyone except whoever built it. Both endpoints derive absolute URLs from the actual request’s url.origin instead.
  2. The content is dynamic. Blog posts, events, and shop items can change without a rebuild — especially once Adhara is connected, where content can change on Adhara’s side with no deploy at all. A sitemap generated once at build time would silently go stale.

If you add a new public, indexable route — a new top-level page, or a new dynamic content type with its own listing — add it to sitemap.xml.ts’s STATIC_ROUTES array or its dynamic-content loop (matching the existing blog/events/shop/docs pattern); it does not appear automatically. If you add a new private, internal, or transactional route (anything under /admin, /portal, /api, or a checkout/booking flow), add it to robots.txt.ts’s DISALLOW list — that’s the real signal search engines respect; simply omitting a route from the sitemap isn’t enough on its own.