SEO
SEO in rooted has two halves:
- Per-route metadata. Each route declares its
title,description, and a few sitemap fields. The router applies these at runtime as the page changes. - Build-time tooling. When you build for production, rooted writes
sitemap.xml,robots.txt, andllms.txtbased on the routes it finds. It also injects per-route meta tags into the static HTML shipped for each route.
Most of it is wired through @rooted/application's rootedManifest helper, with one plugin you add yourself if you use the router.
The build-time half lives in @rooted/seo, split across two entry points. @rooted/seo itself knows nothing about routing: meta tag injection, sitemap generation, robots.txt. @rooted/seo/router supplies the parts that read your routes: each page's metadata and the pages themselves.
rootedManifest wires up the first for you. It doesn't wire up the second, because @rooted/application knows nothing about routing, and that's what keeps routing optional. If you use the router, add the plugin next to generateRouteManifest():
import { generateRouteManifest } from '@rooted/router/manifest'
import { routeSeoPlugin } from '@rooted/seo/router'
plugins: [
generateRouteManifest({ glob: './src/**/_routes.mts', routeManifestPath: './src/_routes.g.mts' }),
routeSeoPlugin(),
]@rooted/seo/router and @rooted/router come as a pair; importing the one without the other won't type check.
Per-route metadata
Add a seo field next to resolve when you declare a route.
import { route, token } from '@rooted/router/routes'
export const RecipeRoute = route`/recipe/${token('id', Number)}/`({
resolve: ({ create, tokens }) => create(Recipe, {
id: tokens.id
}),
seo: {
title: 'Recipe',
description: 'A recipe from the rooted recipe book.',
image: '/og/recipe.png',
changeFrequency: 'weekly',
priority: 0.8,
},
})The full set of fields:
| Field | What it does |
|---|---|
title | Sets <title> and og:title. |
description | Sets <meta name="description"> and og:description. |
image | Absolute URL or root-relative path for og:image. Falls back to the generated PWA icon. |
noIndex | If true, injects <meta name="robots" content="noindex">. |
excludeFromSitemap | If true, the route is left out of sitemap.xml. |
changeFrequency | Sitemap <changefreq>. One of always, hourly, daily, weekly, monthly, yearly, never. |
priority | Sitemap <priority>. A number between 0.0 and 1.0. |
Routes without a seo field are still listed in the sitemap, just without per-route metadata.
A route only counts as static when its pattern has no dynamic segments, with one exception: constant-values tokens like token('plan', ['free', 'pro', 'team']) unroll into one prerendered page and one sitemap entry per listed value. Routes with a typed token or wildcard stay dynamic and are skipped.
Lazy metadata
seo also accepts a function. It runs once per navigation at runtime and once per generated page at build time, and receives the same typed tokens a resolver gets. Ignore the argument when you don't need it.
export const PlanRoute = route`/plans/${token('plan', ['free', 'pro', 'team'])}/`({
resolve: ({ create, tokens }) => create(PlanPage, {
plan: tokens.plan
}),
seo: ({ tokens }) => ({
title: `The ${tokens.plan} plan`,
}),
})At build time the function runs as if the browser were at the page being generated: location points at the concrete path. That's what makes URL-dependent values, like localized text, come out right per page. Keep seo functions pure and cheap; they may also be async.
How metadata reaches the page
Two paths:
At build time, the SEO plugin renders one HTML file per static route. Each file gets the route's
<title>, description, canonical link, and Open Graph tags injected. Crawlers see the right tags without running JavaScript.The plugin doesn't read your routes to do this. It asks whoever registered a page and metadata provider, and
routeSeoPluginis what registers them from the route manifest. That's why the routing-free half works on its own, and it's the seam to use if your pages come from somewhere other than routes.At runtime, when the router navigates to a new route, it updates
document.titleand the existing meta tags in place. This happens inside the router; you don't have to wire it up.A route that doesn't set a
seo.titleorseo.descriptionshows the title and description from yourindex.html, the same as its pre-rendered page. Tags that only belong to the previous route, like its Open Graph title and description or anoindex, are removed when you navigate away.og:imageonly changes when the new route ordefaultOgImagesets one, so setdefaultOgImageif your routes don't all have an image.
You can pass router-specific SEO options through the router itself:
create(Router, {
seo: {
deploymentUrl: 'https://example.com/',
titleSuffix: ' | My App',
defaultOgImage: '/og/site.png',
},
})titleSuffix is appended to every runtime title that comes from a route's seo.title. deploymentUrl is used to build absolute canonical URLs for dynamic routes. defaultOgImage is the fallback when a route has no seo.image.
Build-time tooling
Wire up the manifest in vite.config.mts:
import { rootedManifest } from '@rooted/application'
import { generateRouteManifest } from '@rooted/router/manifest'
import { routeSeoPlugin } from '@rooted/seo/router'
import { seo } from './src/seo.mts'
export default rootedManifest({
webManifest: {
id: 'my-app',
url: 'https://example.com/',
name: 'My App',
short_name: 'App',
description: 'A rooted demo app.',
theme_color: '#ffffff',
background_color: '#faf7f2',
display: 'standalone',
},
seo,
plugins: [
generateRouteManifest({
glob: './src/**/_routes.mts',
routeManifestPath: './src/_routes.g.mts',
}),
routeSeoPlugin(),
],
})The webManifest is the standard PWA manifest. webManifest.url is the deployment origin. Both are reused by the SEO plugin.
seo options
import type { SeoOptions } from '@rooted/seo'
export const seo: SeoOptions = {
titleSuffix: ' | My App', // appended to every route title
defaultOgImage: '/og/site.png', // used when a route has no seo.image
robots: { /* see RobotsOptions */ }, // or false to skip robots.txt
llmsTxt: { /* see below */ }, // or false to skip llms.txt
}llms.txt
llms.txt is a Markdown file that summarises the site for language models. The default behaviour writes a list of every static route with a title. You can override the section structure when you have a real content backend:
import type { SeoOptions } from '@rooted/seo'
export const seo: SeoOptions = {
llmsTxt: {
intro: 'My app is a recipe book. Browse by category or search by ingredient.',
sections: [
{
title: 'Recipes',
entries: [
{ title: 'Pasta carbonara', url: 'https://example.com/recipe/1/', description: 'Classic Roman pasta.' },
{ title: 'Chicken tikka', url: 'https://example.com/recipe/2/' },
],
},
{
title: 'Categories',
entries: [
{ title: 'Italian', url: 'https://example.com/categories/italian/' },
{ title: 'Indian', url: 'https://example.com/categories/indian/' },
],
},
],
},
}In a real app, generate entries from your data source.
Set llmsTxt: false to skip the file entirely.
Adding extra sitemaps
The SEO plugin exposes a small inter-plugin API for registering additional sitemaps (for example one for icons or a content sitemap generated from a CMS). You retrieve it through the resolved Vite config:
import { seoPluginName, type SeoApi } from '@rooted/seo'
const plugin = resolvedConfig.plugins.find(p => p.name === seoPluginName)
const seoApi = (plugin as { api?: SeoApi } | undefined)?.api
seoApi?.addSitemap({
name: 'icons',
entries: [
{ loc: 'https://example.com/pwa-512x512.png' },
],
})This is most useful from another Vite plugin's configResolved or buildStart hook. Plugins find each other by name rather than by importing, so seoPluginName is the contract; use the constant instead of writing the string.
SeoApi has a few other seams, all registered the same way:
| Method | What it does |
|---|---|
addSitemap | Registers an extra sitemap, listed in sitemap-index.xml. |
addPageProvider | Supplies the pages, by static path. Feeds both sitemap.xml and llms.txt. |
addRouteSeoProvider | Supplies a page's metadata for injectRouteHtml. |
getPages / getPageSeo | Read back what the providers supplied. |
addRouteHeadLinks | Adds <link> tags to a page's head, used by hreflang. |
addSitemapAlternates | Adds xhtml:link hreflang alternates to a page's sitemap.xml entry. |
addRouteHtmlTransform | Free-form HTML transform, after meta tags and head links. |
addPrepareTask | Async work that must finish before any metadata is read. |
gitLastModified(file, root) is exported too. It's what the sitemap uses for lastmod: the newest git log date for a file, falling back to its mtime when it isn't tracked.
What you don't have to do
- You don't write
sitemap.xmlyourself. It is generated from the routes discovered by the manifest plugin. - You don't write
robots.txtyourself. The defaults are sensible. Passseo.robotsto override them. - You don't have to update
<meta>tags by hand. The router does it when navigating.
If you need behaviour rooted's tooling doesn't support, the surface area is small. The relevant code is in packages/seo/plugins.