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:
- 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’surl.origininstead. - 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.