Skip to content
AstroCraft Docs
On this theme

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 against Astro.site
  • <meta name="robots" content="noindex, nofollow">, only when noindex is 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_time and article:author, when the page passes article
  • the Twitter set — summary_large_image, title, description, image, image alt, and twitter:creator only if siteData.author.twitter is 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 ClientRouter when 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 @idhttps://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.txtUser-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.

NEXT STEPForms & Email