SEO & Structured Data
Urengi’s <head> is owned, not vendored. src/layouts/BaseHead.astro emits every meta, Open Graph, Twitter and structured-data tag with native tags and no SEO dependency. There is no astro-seo, no robots plugin, no schema package — @astrojs/sitemap is the only integration involved, and it only generates the sitemap.
What every page gets
A page passes title, description, and optionally image, noindex, schema and article to BaseLayout, which forwards them. From those, BaseHead emits:
<title>and<meta name="description"><link rel="canonical">, resolved absolutely againstAstro.site<meta name="robots" content="noindex, nofollow">, only whennoindexis set- the Open Graph set —
og:type,title,description,url,site_name,locale,image,image:alt,image:width,image:height article:published_time,article:modified_timeandarticle:author, when the page passesarticle- the Twitter set —
summary_large_image, title, description, image, image alt, andtwitter:creatoronly ifsiteData.author.twitteris filled <link rel="sitemap">and<link rel="alternate" type="application/rss+xml">- a preloaded, self-hosted variable font
- the JSON-LD graph
- the pre-paint theme script, and
ClientRouterwhen view transitions are on
Three of those deserve a note.
Canonical and og:url are the same value, derived once:
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
They cannot disagree, which is the point. trailingSlash: "always" means that value has one shape site-wide.
og:image carries real dimensions. When a page passes a bundled ImageMetadata — a post’s heroImage, a story’s — its actual width and height are emitted; otherwise it falls back to the 1200×630 convention, which is what public/og.jpg should be.
og:locale is not siteLocale. Open Graph wants language_TERRITORY with an underscore, while siteLocale holds BCP-47, so BaseHead converts: en-US becomes en_US.
The feed is advertised on every page, not only on /blog/. That is what rel="alternate" is for — a reader subscribes from wherever it found the site.
The JSON-LD graph
Structured data is built by typed helpers in @js/schema. Every page emits a site-level graph of two linked nodes:
const siteNodes = getSiteSchema({
name: siteData.name,
url: canonicalURL.href,
description: siteData.description,
inLanguage: siteLocale,
logo: new URL(siteData.defaultImage.src, canonicalURL).href,
sameAs: siteData.sameAs,
});
const jsonLd = serializeJsonLd([...siteNodes, ...(schema ?? [])]);
Organization and WebSite, linked by a stable @id — https://yoursite.com/#organization and /#website — so the WebSite’s publisher points at the Organization rather than duplicating it. The ids are derived from the origin by organizationId(), which is what makes them stable across pages.
sameAs is omitted entirely when the array is empty, rather than emitted as []. Same for description and publisher: a node says nothing rather than saying nothing loudly.
On top of that, a page can pass its own nodes through schema:
| Builder | Used by |
|---|---|
getArticleSchema |
blog posts and customer stories |
getBreadcrumbSchema |
anything with a visible breadcrumb trail |
That second condition is a rule rather than a suggestion. BreadcrumbList without an on-screen trail is markup and schema disagreeing, so /signin/ and /signup/ deliberately emit none — their frames draw no breadcrumb.
The trail is built once and used twice. On a post route, postCrumbs produces the array; PostHeader renders it as the visible nav and getBreadcrumbSchema turns the same array into JSON-LD. It was written out in both places before, which meant the two agreed by coincidence.
Neither auth page emits a second Organization either. Two Organization nodes with different @ids in one graph is worse than none.
The three generated endpoints
Each is a route file that prerenders to a static file at build, rather than a hand-written file in public/. The reason is the same in all three cases: the URLs inside them must resolve against site and stay absolute, and a static file cannot.
robots.txt — User-agent: *, Allow: /, and a Sitemap: line built from site. The line is emitted only when site is set, so an unconfigured clone produces a valid file rather than one pointing at undefined.
llms.txt — a machine-readable content map for AI retrieval systems, in the llmstxt.org shape. It is an editorial index rather than an auto-generated sitemap: the section headings and the entry points are curated, and the posts and customer stories are enumerated, because a reading list whose whole point is “what is worth reading here” would be wrong the day after every publish if it were hand-written.
rss.xml — the blog’s feed. Nothing in it formats a date or escapes a string by hand; that is @js/rss, which carries its own self-check. Drafts are excluded by publishedPosts, the same call the listing routes make.
The sitemap
@astrojs/sitemap, with a filter that drops the three noindex routes:
sitemap({
filter: (page) => !["/examples/", "/404/", "/signin/"].some((p) => page.includes(p)),
})
One list, so “noindex” and “not in the sitemap” cannot drift apart. They did once, for exactly as long as it took to grep the built sitemap for a new route.
/signup/ is deliberately not in that list. It is an indexable acquisition landing page, and that asymmetry between the two auth routes is intentional — Pages & Routing explains it.
There is a second list beside it, customPages, which is empty of meaning in the stock static build and becomes load-bearing the moment you connect a form. @astrojs/sitemap enumerates the built tree, so it cannot see an on-demand route; the six pages that would receive a form POST have to be listed there by hand. Those two lists — the prerender = false routes and customPages — are the same six pages seen from opposite ends.
The rules that keep it honest
Every route gets a unique description. Two URLs sharing a snippet is two pages competing for one result, and the one that loses is the one that copied. The paginated listings make this explicit: page two’s meta description appends “Page 2 of 3” even though the visible masthead does not.
noindex is a page decision, not a nav decision. /signin/ is linked from the navbar and still noindexed; robots decides indexing, the navbar decides reachability.
Empty is better than invented. author.twitter and sameAs ship empty, and the tags that would carry them are guarded so nothing renders. Fill sameAs and navData.social together — they must name the same URLs, and one deploy-checklist step covers both.
One value feeds six things. SITE_URL reaches canonical, Open Graph, JSON-LD, the sitemap, robots.txt and llms.txt. That is why a production deploy on the placeholder domain throws rather than shipping.
What the layer does not do
There is no automatic WebPage node, because it says nothing a crawler cannot already see. There is no FAQ or Product schema on the pricing and contact pages — those would be claims about your business, and inventing them for a demo would be worse than omitting them. And security.csp is deliberately not enabled in astro.config.mjs: it is incompatible with ClientRouter, which BaseHead mounts.
If you want any of those, the builders in @js/schema are the pattern to copy — each returns a plain node, and serializeJsonLd takes an array.
Adding schema to a page
---
import { getArticleSchema, getBreadcrumbSchema, organizationId } from "@js/schema";
const canonical = new URL(Astro.url.pathname, Astro.site).href;
const crumbs = postCrumbs(post);
---
<BaseLayout
title={…}
description={…}
image={post.data.heroImage}
schema={[
getArticleSchema({ … }),
getBreadcrumbSchema(crumbs),
]}
article={{ published: post.data.pubDate, modified: post.data.updatedDate }}
>
Derive the canonical the same way BaseHead does — from Astro.url.pathname against Astro.site — so the schema’s url and the <link rel="canonical"> cannot disagree. And render the crumbs you pass.