Skip to content

ADR SEO split ​

yml
status: accepted
supersedes: ./2026-05-17.adapter-split.md
created: 2026-08-25
deciders:
- marvin-brouwer

SEO was spread across three packages with no single owner. @rooted/adapter held the SeoApi contract and resolveRouteSeo; @rooted/application held the plugins that implement it; @rooted/localization extended it with hreflang. None of those is the SEO package.

The clearest symptom was in @rooted/adapter. It walked the route manifest, resolved each route's SEO metadata, and passed the result back to the SEO plugin through injectRouteHtml(html, seo, staticPath) -- a call that already carried staticPath and could have looked the metadata up itself. Route SEO logic was living in the deployment package, and the deployment package had to know what a route was to do its job.

That coupling also showed up in the type system. AdapterContext exposed manifestApi: RouteManifestApi and seoApi: SeoApi, which pulled the router's declarations into @rooted/adapter's own .d.mts as a second copy. Because the router brands routes with a unique symbol, two copies of those declarations are not assignable to each other, which is #245.

Decision ​

Extract @rooted/seo, split across two entry points.

  1. @rooted/seo -- meta tag and Open Graph injection, canonical links, sitemap generation, robots.txt, and the SeoApi seam other plugins register against. It knows nothing about routing and works in an app with no router installed.

  2. @rooted/seo/router -- the part that reads the route manifest, supplying the pages and their metadata to the first. @rooted/router is an optional peer, and the two come as a pair: importing this entry point without the router doesn't type check. The plugin no-ops when the manifest plugin isn't in the config, and warns when the router isn't installed at all.

rootedManifest wires up the first and not the second. @rooted/application names neither @rooted/router nor @rooted/seo/router, so apps that don't route never load either, and adding routeSeoPlugin() is something you do yourself next to generateRouteManifest(). An earlier version had rootedManifest add it behind a resolve check; that was wrong twice over. The check ran from @rooted/application's own scope, where the router was only a devDependency, so it would have reported "not installed" for every published consumer and silently disabled route SEO. And the static import of @rooted/seo/router happened regardless, so the guard never prevented the coupling it existed for.

A subpath rather than a second package, because the route-aware half needs both the router's types and the SEO contract. As its own package it would depend on both and neither would depend on it, which works, but it is a published package's worth of overhead for what is one plugin and one resolver.

Route SEO now reaches the SEO plugin the same way hreflang always has: through registered providers. injectRouteHtml drops its metadata argument and asks addRouteSeoProvider; the pages themselves arrive through addPageProvider. One page seam rather than one per consumer, because sitemap.xml and llms.txt want different subsets of the same list: a page flagged excludeFromSitemap is still worth telling a model about. AdapterContext loses manifestApi and seoApi, neither of which any of the 17 @rooted-adapters/* packages used.

Prerendering stays in @rooted/adapter. It is part of deployment, not a separate concern.

Consequences ​

One owner. SEO behaviour changes in one package. Previously a sitemap change could mean touching @rooted/application for the plugin, @rooted/adapter for the contract, and @rooted/localization for the hreflang extension.

@rooted/adapter no longer knows about routing or SEO. Its declarations name neither package's types. It calls injectRouteHtml(html, staticPath) and lets the SEO plugin decide what that means, which closes the @rooted/adapter half of #245.

Routing stays optional, and so does SEO. @rooted/seo works without the router, @rooted/adapter works without either, and @rooted/application names neither.

One line of wiring for apps that route. routeSeoPlugin() has to be added by hand, because the alternative was @rooted/application knowing about the router. That is a small cost paid by routing apps to keep the option open for everyone else, and it puts the wiring next to generateRouteManifest() where it reads as what it is.

One more package to install. Apps that configure SEO types directly now import from @rooted/seo instead of @rooted/application. The compat re-export was removed rather than kept, so there is one import path per type.

Two breaking changes to AdapterContext in one release. Both fields were unused in this repo, but a third-party adapter reading context.manifestApi or context.seoApi has to move to context.resolvedRoutes or to the SeoApi seam.

A deliberate type copy. PageSeoMetadata in @rooted/seo mirrors RouteSeoMetadata in @rooted/router. They are structurally identical plain object types, so values pass between them freely. Keeping a copy is what lets the routing-free half stay routing-free. The same applies to the route manifest plugin's name, which @rooted/seo cannot import as a value without depending on the router. Both are cross-referenced in TSDOC.