SEO
Metadata, JSON-LD, sitemaps, robots.txt, feeds and responsive images, each rule an assert* that
throws a coded SeoError naming the route file and the edit. Package @ultimat3/seo (tier 1) —
the full reference is
packages/seo/README.md.
Route metadata itself is declared on defineRoute({ meta }) (Routes and render
modes).
export const config = defineRoute({
render: 'isr',
meta: ({ post }) => ({
title: post.title,
titleTemplate: '%s — Ultimate',
description: post.excerpt,
og: { type: 'article', image: post.cover, publishedTime: post.publishedAt },
ld: [ld.Article({ headline: post.title, datePublished: post.publishedAt, author: post.author })],
}),
});What the rules refuse
| Code | Trigger |
|---|---|
X_SEO_META_MISSING | a site/ route with no meta.title or meta.description |
X_SEO_DUPLICATE_META | two routes share a title or a description |
X_SEO_META_TOO_LONG | title over 60 characters, description over 160 |
X_SEO_CANONICAL_MISMATCH | meta.canonical does not resolve to the route's own URL |
X_LD_INVALID | a JSON-LD node missing a required schema.org field — and a required field is required in the builder's input type, so ld.Article without datePublished does not compile. A CMS null in one is the same coded refusal |
X_SITEMAP_TOO_LARGE | the sitemap index past 50,000 files |
X_SITEMAP_ENTRY_INVALID | a page's lastmod that is not a Date or ISO 8601 with a zone, or a locales name the app does not route — names the route file |
X_SEO_LINK_INVALID | a meta.links entry whose href is not a path or an http(s) URL (javascript:, data: …), a preload with no as, or a font preload with no crossorigin. Thrown while the page renders (SSR and prerender) and reported at the route file by the seo step |
x verify's seo step runs validateMeta over every site/ route: missing, duplicate and
over-long meta fail the gate. Canonical checks are skipped there, because an app declares no base
URL; JSON-LD, sitemaps and robots are enforced where they are built — each builder throws. Performance
budgets are not here: they are the budgets step and X_BUDGET_EXCEEDED.
Head links
meta.links puts <link> tags in <head> — a font preload, a preconnect, a feed — typed, in
declaration order, for SSR and prerender alike. canonical and hreflang keep their own fields, and
the surface stylesheet is the renderer's, so neither rel is spellable here.
export const config = defineRoute({
render: 'static',
meta: () => ({
title: 'Acme',
description: 'Notificaciones electrónicas con constancia de entrega',
links: [
{
rel: 'preload',
href: asset('assets/fonts/inter-var.woff2'),
as: 'font',
type: 'font/woff2',
crossorigin: 'anonymous',
},
{ rel: 'alternate', href: '/feed.xml', type: 'application/rss+xml' },
],
}),
});Fields: rel, href, as, type, crossorigin, media. Two links of one rel are two tags:
a link's href is part of its identity when the head is deduped.
The social card
A document that may not be indexed carries no card — no og:*, no article:*, no twitter:* tag,
declared or derived — unless the route declares socialCard: true. meta.robots: { index: false }
says so, and so does a 4xx or 5xx, which @ultimat3/render marks noindex by construction. A
document that may be indexed carries its card whatever the field says. As of 2026-10.
meta.socialCard: true is the route's own opt-in, and the only one. With it, a noindex document
carries the card exactly as an indexable one does: declared values win, and the derived ones come
with it — og:type, og:title and og:description from the page's own title and description,
og:url from the canonical, og:locale, and the twitter:card default. The robots directives
never change: renderMeta() still answers <meta name="robots" content="noindex,follow">, and the
route stays noindex.
import { defineRoute } from '@ultimat3/render';
export const config = defineRoute({
render: 'isr',
revalidate: { ttl: '5m' },
offline: 'network-only',
meta: () => ({
title: 'Pricing',
description: 'What the plan costs.',
robots: { index: false },
socialCard: true,
}),
});A card the build photographs: og.capture
og: { capture: '/og/home' } names a site/ page — one you write, laid out at exactly 1200 x 630,
robots: { index: false } — and x build --target static photographs it into /_og/og/home.png and
stamps og:image, og:image:width and og:image:height on every exported document that declares
it. image and capture are one or the other; both together is a type error. As of 2026-11.
| Fact | Rule |
|---|---|
| the path | a concrete page path. A dynamic card (/og/post/[slug]) lists its pages in its own prerender() |
| no such page | X_SEO_CARD_ROUTE_UNKNOWN — from x verify's seo step, and again from the build for a path only a dynamic route resolves |
| the browser | the CLI's own CDP driver: CHROME_PATH, PUPPETEER_EXECUTABLE_PATH or SCRAPE_CDP_URL. None: X_SEO_CARD_BROWSER_MISSING |
x build --no-capture | stamps the same tags, takes no picture, needs no browser. x verify's own build and the reference-app gate pass it, and only they do |
| a served document | carries no og:image for a capture, on purpose: the PNG exists only in the export, the container holds no browser to take one (axiom 7), and an og:image naming a file nothing serves is a broken preview a crawler caches. Deploy site/ as the static export, or set og.image — twitter:card falls back to summary |
import { defineRoute } from '@ultimat3/render';
export const config = defineRoute({
render: 'static',
offline: 'network-only',
meta: () => ({
title: 'Postly',
description: 'A team blog that behaves itself.',
og: { capture: '/og/home' },
}),
});A card for a ROW published at runtime is the app's job, not the build's: scrape() over the row's
card page, page.screenshot(), disk.put(), then og: { image } with the stored object's URL —
Scraping.
The pieces
| Call | Answers |
|---|---|
renderMeta() | the head tags: title (a $ in a title is kept verbatim), canonical, robots, og:*, twitter:*, hreflang + x-default, theme-color per scheme, links. A document that may not be indexed carries no og:*/article:*/twitter:* tag unless the route declares socialCard: true — The social card |
buildSitemap() | from the route table and each route's prerender(), per-locale alternates; one urlset, an index of named sections, or a split past a cap (50,000 URLs, 50 MB) |
buildRobots() | fail-closed: only the literal production environment opts into indexing; anything else — staging, a laptop, an unset variable — is Disallow: / with no sitemap line. In production disallow is added to every group, and a User-agent: * group is emitted to carry it when none is declared |
buildFeed() | RSS 2.0, Atom and JSON Feed from one item list. The channel's author, copyright and icon reach Atom as <author>, <rights> and <icon>; an item's author is <author> in Atom and, in RSS, <author> with an email or <dc:creator> without one; an item's image is an Atom enclosure link and an RSS <media:content>. An item date must be ISO-8601 with an offset or Z; one that is not is treated as absent (and an offsetless one is logged as seo.feed.date_offsetless), never read through the server's zone |
builtinImageDriver({ read }) | resize and blur placeholder over core's pipeline — decodes png, jpeg, webp, gif; encodes png, jpeg, webp. avif needs a CDN driver (X_IMAGE_UNSUPPORTED), so responsiveImage() offers webp only unless given formats, and no width past 8192 (MAX_IMAGE_WIDTH). As of 2026-10 |
parseImageQuery() | the one reader of the ?w=&f=&q= a responsive image URL carries (X_IMAGE_QUERY_INVALID) |
setStaticExport() | the static build's seam: while it renders, responsiveImage() names /_img/<source hash>-<width>.<ext> files and og.capture names /_og/<path>.png. Unset in a served process — Static assets |
Every X_SEO_* and image code is in Error codes.
robots.txt and sitemap.xml
One answer, served two ways. siteSeo() from @ultimat3/cli builds both from the route table. It
takes the public site/ routes (no policy) and expands a dynamic route through its prerender().
| Where | How |
|---|---|
| static export | the scaffolded apps/web/prerender.ts writes siteSeo()'s files into .x/static (writeSiteSeo). A dynamic route lists exactly the pages the build emitted |
web role (x dev, runRole) | GET /robots.txt, GET /sitemap.xml and its parts GET /sitemaps/:file, application/xml, public, max-age=3600, built once per origin and kept for that same hour. Every file shares one answer: a refresh that fails or hangs keeps the previous index and its parts. Absolute against APP_URL, else SITE_ORIGIN, else the request's own origin |
Production robots.txt rules come from app.config.ts: seo.robots.disallow (every crawler, in
every group) and seo.robots.groups (per User-agent) —
Configuration. A crawler obeys only the group naming it most
specifically, so a group for GPTBot replaces *'s rules for it. Any other environment emits
Disallow: / alone, whatever either says.
What is left out
| Left out | Decided by |
|---|---|
a page whose meta says robots: { index: false } | the route's own meta, read once per routed locale: noindex in English only lists the other locales and drops English from their hreflang cluster. Read for a route with no params and no load — the only meta that resolves without a request |
a page of a dynamic route, or of a route with load | its prerender() entry: locales: ['es-co'] lists it there only, locales: [] nowhere. Its meta is not read — that would be one load per page per refresh |
a URL seo.robots.disallow matches (a seo.robots.groups rule does not — it binds only the crawlers it names) | the rule, read as a crawler reads it (prefix, *, trailing $). A locale copy it blocks leaves the cluster. As of 2026-10: such a URL used to be listed — "submitted URL blocked by robots.txt" |
a gated route, an app/ page not in extra | policy, seo.sitemap.extra |
A page's own lastmod and locales
prerender() answers a bare string, a params object, or an entry — the three mix freely:
import { defineRoute } from '@ultimat3/render';
declare const posts: {
published(): Promise<readonly { slug: string; updatedAt: Date; englishReviewed: boolean }[]>;
};
export const config = defineRoute({
render: 'isr',
revalidate: { ttl: '10m' },
offline: 'network-only',
prerender: async () =>
(await posts.published()).map((post) => ({
params: { slug: post.slug },
lastmod: post.updatedAt, // a Date, or ISO 8601 with a zone
locales: post.englishReviewed ? ['es-co', 'en'] : ['es-co'],
})),
meta: ({ params }) => ({ title: params['slug'] ?? '', description: 'A note from the blog.' }),
});| Key | Rule |
|---|---|
params | the page. The build renders it in every routed locale whatever locales says: locales is about listing, not about which documents exist |
lastmod | written as W3C Datetime in UTC (2026-10-01T15:04:05.000Z). Wins over seo.sitemap.lastmod. A time with no zone (2026-10-01T10:00:00) or free text is X_SITEMAP_ENTRY_INVALID, never read in the server's zone. A moment in the future is clamped to now: refusing it would fail the whole sitemap for one row stamped by a clock a second ahead, and writing it teaches a crawler to distrust every date in the file |
locales | one <url> per named locale, hreflang among those only; x-default only when the default locale is one of them; a page left in one locale carries no alternates. Omitted: every routed locale. A name the app does not route is X_SITEMAP_ENTRY_INVALID |
A route with no params may answer prerender() too — () => [{ params: {}, lastmod, locales }]
— which is how a page with a load (a blog index whose date is its newest post's) says either.
A key that is none of the three (lastMod) is X_PRERENDER_FAILED. The static export still lists
only the pages it emitted; it asks prerender() again for what each says of itself.
The <head> is separate: a page's hreflang tags come from meta.alternates, or from every routed
locale when it declares none. A page listed in one locale declares its own:
alternates: [{ hreflang: 'es-CO', href: url }].
Sections: an index by configuration
import { defineConfig } from '@ultimat3/core';
export const config = defineConfig({
name: 'acme',
seo: {
sitemap: {
pageSize: 1000,
sections: [
{ name: 'blog', match: '/blog/**' },
{ name: 'ciudades', match: ['/juzgados/**', '/ciudades/*'] },
],
},
},
});/sitemap.xml becomes a <sitemapindex>; every listed page goes to /sitemaps/<name>-<n>.xml.
| Rule | |
|---|---|
| match | against the page's path without its locale prefix, so /en/blog/a files with /blog/a and a cluster stays in one section. /precios that page · /blog/* one segment below · /blog/** /blog and everything below. First section that matches wins, in declaration order |
| unmatched | the default section, pages — a reserved name |
| order | pages first, then sections as declared; inside one, pages sorted by path, so the bytes depend on which pages exist and never on the order a query returned them |
| pagination | pageSize URLs per file (default and ceiling 50,000); a file past 50 MB splits further. Numbered from 1 even when there is one: blog-1.xml keeps its URL when blog-2.xml appears |
| empty | a section with no page writes no file and is not in the index; a site with no page at all answers one empty urlset |
index <lastmod> | per child, the newest <lastmod> inside it; none when no URL in it has one |
robots.txt | names /sitemap.xml alone |
| refused at boot | a name that is not a lower-case slug (a-z, 0-9, single hyphens), a duplicate, pages, a match that is not a path pattern, a pageSize outside 1–50,000 — X_CONFIG_INVALID |
With no sections the output is byte-for-byte what it was: one urlset at /sitemap.xml, and past
a cap (50,000 URLs, or 50 MB) the index with parts /sitemaps/1.xml, /sitemaps/2.xml, …
(SITEMAP_PARTS_DIR). A part the index does not name is a 404, no-store. As of 2026-10.
llms.txt
As of 2026-11-06. A third file of the same answer, on by default like the sitemap.
/llms.txt lists the indexable site/ pages a sitemap lists, by their meta title and
description, in path order, under the home page's title and description:
# Postly
> Posts for teams.
- [Pricing](https://postly.example/pricing): Plans, per seat.| Rule | |
|---|---|
| served | GET /llms.txt from the web role (text/markdown, the sitemap's hour of cache); the static export writes the same bytes (writeSiteSeo) |
| listed | public, indexable site/ pages, in the default locale, less seo.robots.disallow. A dynamic route (or one with a load) is expanded through prerender() page by page, each line the title and description that page's own meta gives it through its load — the export lists only the pages it emitted, as the sitemap does; a page whose meta says robots: { index: false } is left out. prerender() runs once per answer for the sitemap and this file together |
| outside production | an empty document, for robots.txt's reason |
| opt out | seo: { llms: false } in app.config.ts: the framework mounts no GET /llms.txt and the static export writes none — the path is the app's own route to serve, or a 404 |
| build error | while on, a listed page with no meta.description is X_SEO_LLMS_DESCRIPTION_MISSING in the seo step. The step reads meta without a database, so it checks the pages whose meta needs no load; an expanded page with no description is listed by its title alone |
The repository's own llms.txt is a separate, hand-kept file and is not this.
Beyond site/, and <lastmod> (As of 22.10) — seo.sitemap in app.config.ts:
seo: { sitemap: { extra: ['/verificar', '/estado'], lastmod: 'git' } },| Key | Does |
|---|---|
extra | lists public app/ pages beside the site/ ones — each path must be answered by an app/ route declaring no policy, and is listed per routed locale with the same hreflang cluster and x-default. A path no route answers, an api/ route, a gated page or a site/ page (listed already) is X_SITEMAP_EXTRA_INVALID — refused, never skipped |
lastmod | 'none' (default, no <lastmod>), 'git' (the route file's last commit; its mtime without a work tree), 'mtime', 'build' (one timestamp) |
The scaffolded prerender.ts passes sitemap and root from loadSiteSettings(root); an app with
its own prerender.ts adds sitemap: settings.sitemap, root to its siteSeo({ … }) call.
The parts of a split were /sitemap-N.xml once, which no running process could serve
(Upgrading).