AstroCraft — home
AstroCraft
Buy a licence
All documentation

SEO and the published site

Where an author edits search and social metadata, the four machine-readable files the build emits, and the one rule the draft/preview split turns on.

Last updated

The CMS does not own your site’s <head> — your own layout does. What it owns is the surface an author edits, and the guarantee that the CMS’s own routes never leak into anything a crawler reads.

Where an author edits it

Two places, one panel, one declaration shape.

In the entry editor, the SEO tab renders the five roles your collection named in src/admin.config.ts — search title, search description, social image, and the two social overrides. See Details, SEO and History for how the search preview and the override model behave.

In the page composer, the same panel renders over a layout’s props, using the same five key names. A layout declares them in components.overrides, and it has to be a declaration rather than a derivation because the shipped layout spells its picture prop image and yours will not. See The component library.

The four machine-readable files

Emitted by your site’s build, not by the CMS:

File How it is produced
sitemap-index.xml the sitemap integration
robots.txt a prerendered endpoint
rss.xml a prerendered endpoint
llms.txt a prerendered endpoint, an editorial content map rather than an auto-sitemap

All four read one statement of what “published” means. That rule lives in a single module with four callers — the list pages, the entry pages, RSS and llms.txt — so they cannot disagree about whether a draft is public.

robots.txt derives its policy from the PREVIEW flag: the staging deploy serves Disallow: /, and production allows everything but /admin/, naming the sitemap.

Settings → General SEO shows all four URLs as readouts, computed from your committed host. There is nothing to enable there, because these regenerate on every build.

The sitemap needs a filter for /admin/. The sitemap integration enumerates non-dynamic on-demand routes too, so without an explicit filter every admin screen lands in your sitemap, sign-in included. “Excluded by construction” was measured and found false. The shipped astro.config.mjs has the filter; if you wrote your own, add it.

The draft rule and the two builds

One flag: PREVIEW=1.

Your staging branch builds with it and renders drafts. Your production branch builds without it and never does. That is the whole preview mechanism, and it is why publishing is a fast-forward of a branch rather than a deploy API call — the branch your production build watches is the thing that changed.

An entry marked draft: true still blocks a publish if its frontmatter fails your schema. It rides the branch even though nothing renders it.

The build stamp

Every page stamps the commit it was built from:

<meta name="build-rev" content={import.meta.env.BUILD_REV} />

After a publish, the review screen polls your production site until that stamp is a descendant of the commit it pushed. Keep the line in whatever page answers /; without it, publishing works and you simply never get told it landed.

The stamp is read server-side, because your site is usually another origin and CORS bars a browser from reading it.

The CMS is not indexed

Every admin route renders on demand and is behind the session guard, robots.txt disallows the tree, and the sitemap filter keeps it out. Three independent mechanisms, which is the right number for something whose failure mode is a sign-in page in Google.

Troubleshooting

Admin routes appear in my sitemap. You are missing the /admin/ filter in the sitemap integration’s config.

Drafts show on production. That build has PREVIEW=1 set. It should only ever be on the staging deploy.

Drafts do not show on staging. That build is missing PREVIEW=1.

A social image does not render on a shared link. Crawlers run no JavaScript and often will not follow a redirect, so the image must be a plain file at a stable absolute URL. That is why the shipped schema types social images as z.string() rather than Astro’s image().

og: tags do not match my search title. Expected, if the social overrides are set. Empty means “same as search”; a value means an override. The JSON-LD headline always stays your real title.