Skip to content
AstroCraft Docs
On this theme

SEO

Urbic has no SEO package. Every meta, Open Graph and Twitter tag is emitted natively by src/layouts/BaseHead.astro, the JSON-LD is built by typed helpers in src/js/schema.ts, and robots.txt, llms.txt, the RSS feed and the sitemap are generated endpoints. That is the same “owned, not vendored” stance as the icons and the motion catalog, and it means every tag on the page is one you can read and change.

The one variable

SITE_URL feeds the canonical URL, the OG URLs, the JSON-LD, the sitemap, robots.txt and llms.txt. Six things one wrong value poisons at once, none of them visibly broken in review.

A fresh clone builds on the https://example.com placeholder so you can see the site before owning a domain. A production deploy throws rather than ship it — the gate at the top of astro.config.mjs reads Netlify’s CONTEXT, Vercel’s VERCEL_ENV, and DEPLOY_ENV anywhere else. Local builds and deploy previews are unaffected, so previews still build freely.

What BaseHead emits

The title, description and canonical link; a noindex, nofollow robots meta when a page asks for it. Open Graph with og:type flipping to article on note pages, plus og:title, og:description, og:url, og:site_name, og:locale, and the image with its alt text, width and height. On article pages it adds article:published_time, article:modified_time and article:author. Twitter gets summary_large_image with title, description, image and alt, plus twitter:creator when siteData.author.twitter is set.

Canonical and og:url are the same value by construction — they are both canonicalURL — so the two cannot drift.

The social image resolves to an absolute URL against Astro.site, falling back to siteData.defaultImage when a page passes none. Its dimensions are the real ones for a bundled image and the 1200×630 convention otherwise, which is why your public/og.jpg replacement should match that size.

og:locale wants language_TERRITORY with an underscore while siteLocale holds BCP-47, so BaseHead converts. Set siteLocale once in siteSettings.json.ts and it feeds Intl date formatting, og:locale and the JSON-LD inLanguage together.

Also in the head: both variable fonts preloaded, the favicon pair, a sitemap link, an RSS alternate link titled for the journal, and <meta name="build-rev"> — the commit the build came from, which is how the CMS confirms a change is live. See Publishing.

JSON-LD

src/js/schema.ts is dependency-free and builds plain nodes. getSiteSchema composes the site-level graph — an Organization and a WebSite — which BaseHead emits on every page, and merges any page-specific nodes into one @graph.

A page passes extra nodes through BaseLayout’s schema prop:

<BaseLayout title={} description={} schema={[articleNode, breadcrumbNode]}>

schema is always an array, never a Node | Node[] union — a union buys a call site one saved pair of brackets and costs BaseHead a normalizing ternary on every render.

Nodes carry a stable @id so they can cross-reference inside the graph; organizationId derives the Organization’s from the site origin. Serialization goes through serializeJsonLd, which escapes < so the JSON is safe to inline without a </script> breakout.

The sameAs list in siteData.json.ts is what disambiguates your Organization — the social and profile URLs identifying the same entity. It ships empty; filling it is one of the higher-value five minutes you can spend.

One placeholder to fix before launch is flagged in the code: the Organization’s logo currently points at the default OG image. Swap it for a real brand logo.

The four endpoints

All four derive absolute URLs from site, and all four prerender to static files.

robots.txt is an endpoint rather than a public/ file so its Sitemap: line resolves against the real domain. It carries one Disallow, for /admin/ — the CMS routes are auth-guarded, absent from the sitemap, linked from no public page, and there is nothing in them worth crawl budget.

sitemap-index.xml lists 31 URLs: everything indexable, including /contact/, excluding /admin/, /examples/ and /404. The /admin/ clause is not redundant — the sitemap integration knows a non-dynamic route’s URL whether or not it prerenders, so without it every admin screen including sign-in would be enumerated.

rss.xml is the journal’s feed, titled and linked from the head.

llms.txt is a curated content map in the llmstxt.org shape — not a ranking factor, an index of what is worth reading. Its internal split is the interesting part: the core pages are hand-written, because which pages matter is an editorial judgement; the journal and the work archive are generated from their collections, because “every note” and “every project” are facts about a collection that a hand-kept list would get wrong the first time someone added a folder; and services stays hand-shaped, because those four entries are editorial content that happens to live in config.

The ceiling is recorded in the file itself: the core-pages list is hand-kept, so a fifth route family means adding a line there. It went stale once already — it mapped Home, Journal, Terms and Privacy while /work/, /services/, /about/ and /contact/ had already shipped.

Per-page SEO

Every route owns its own title and description, because a route is the shell that owns BaseLayout. Two patterns are worth copying.

Headlines in config carry authored \n line breaks for the SplitFlip primitive, and the route flattens them for the <title> tag — a title is one line. And a description role points at whatever the page actually passes: /work/<slug>/ uses the project’s statement as its description, which is why admin.config.ts maps statement rather than inventing a field. The search description is whatever the page says it is, not whatever a field is spelled.

Article pages pass article metadata to flip og:type and emit the article:* tags. noindex is a BaseLayout prop; the 404 and the dev catalog set it.

The gap

One thing this layer does not have, and the repo says so rather than pretending otherwise: there is no dist/-scanning head guard — nothing that checks the built HTML for a missing canonical or a duplicated tag. The tags are emitted in one place, which makes drift unlikely, but it is not verified.

NEXT STEPContact Form