Skip to main content
Amends 0165 with decisions 6 and 7: code that client route code calls, such as alternates and localePath, stays out of .server modules, and a route’s metadata follows the SEO chain of decision 7. The note on 0165 records the change.

Context

Public pages need the metadata that search engines and crawlers read: sitemaps, robots rules, structured data, canonical URLs, hreflang alternates and the locale path of each page. Two problems came up. Some of this code sat in .server modules, but route code that runs in the browser also needs the alternates and the locale path, so those modules reached the client bundle. And structured data written into a page without escaping can close its script element. The metadata of a route also comes from several places, so it needs one defined order. 0127 lists an seo package in the cross-cutting category as planned; this ADR decides what it holds.

Decision

  1. One package, @stackra/seo, in the cross-cutting category. It owns sitemaps, robots rules, the structured-data escaper, canonical redirects, alternates and locale paths.
  2. Sitemaps are paged. A site publishes a sitemap index that points to pages of URLs. Each page stays within the sitemap protocol limits of 50,000 URLs and 50 MB uncompressed.
  3. Robots rules are groups, one per crawler. A rule written for one crawler does not apply to another.
  4. Structured data goes through the escaper. JSON-LD is serialised so that no value can close the script element or start markup. A page does not write structured data raw.
  5. Canonical redirects depend on the URL alone. ctx.redirectToCanonical answers with a permanent redirect to the page’s canonical URL. It never depends on the visitor, as 0297 requires for locale paths.
  6. Alternates and locale paths are ordinary functions. alternates and localePath move out of .server modules into @stackra/seo, so client route code can call them. The route adapter takes the clients it needs from the load context.
  7. One SEO chain per route. Metadata is built in this order: the app’s defaults, the route’s static seo, the route’s meta() method, then the tenant’s SEO record (0116). The canonical URL, alternates, site name and Open Graph image are derived, so a route passes only the fields that differ.

Consequences

  • A route author writes only what differs from the defaults.
  • Structured data cannot break a page, and robots rules and sitemaps are generated from one place, not written by hand.
  • A route that needs a locale path imports it like any other function, from client or server code.
  • A large site has a sitemap index and several sitemap pages, not one file.

Enforcement

planned — tests in @stackra/seo for the escaper (values that try to close the script element or add markup), the sitemap paging at the protocol limits, the robots groups, the redirect status and target, and the order of the SEO chain. Linear: STA-341. planned — a no-restricted-imports pattern that refuses a *.server module in a file that is not a server file, which keeps the move of decision 6 from coming back. No such rule exists yet in @stackra/oxlint-config or .oxlintrc.jsonc. Linear: STA-340 (0294 Enforcement).

Supersedes and sources

Supersedes: none. Amends: 0165 (decisions 6 and 7). Related: 0116 (the SEO fields of a content type), 0127 (the cross-cutting category), 0129, 0232, 0270 and 0297. Sources: the programming-model v3 wave of 2026-10-22, Linear STA-334.