The head of a page
Every route with a component builds its head through seo() in apps/web/src/platform/seo/seo.ts: title and description (a literal or a locale key), canonical, hreflang alternates, og:*, twitter:* and the share image URL. The root route calls it with siteWide: true for the defaults; leaf routes own og:url, the canonical and the alternates.
head: ({ params }) =>
seo({
path: "/privacy",
locale: localeFromParam(params.locale),
titleKey: "SEO_PRIVACY_TITLE",
descriptionKey: "SEO_PRIVACY_DESC",
}),path is always the canonical, unprefixed page; seo() builds the URL of each language from it. A public page (/, /privacy, /terms, /posts/*, /u/*) gets a self canonical in its own language (/de/privacy), one hreflang per language plus x-default (the unprefixed page), og:locale and the locale in the share image URL. Pages with one URL (sign-in, the app, the docs) take resolveRequestLocale() (the cookie) and emit no hreflang.
Three options change the shape:
noindex: trueadds<meta name="robots" content="noindex">. Sign-in, sign-up, password, two-factor, consent and invitation pages exist for one visitor and set it; the canonical and the share tags stay.monolingual: trueemits one canonical and no hreflang alternates. The developer docs are English only and use it, with anarticletype and the default card showing the page title.markdown: "/docs/features/seo.md"names the page's Markdown copy for the<link rel="alternate" type="text/markdown">; the landing and legal pages get theirs fromPUBLIC_MARKDOWN_PAGES.
og:locale:alternate is one tag per other locale. TanStack's HeadContent keeps one meta per name/property, so RootDocument renders those tags directly into <head> (localeAlternates()), outside the route head.
One URL per language
Every public page exists once per language, the default language at the clean path and every other language under its slug: /privacy, /de/privacy, /fr/privacy. Separate URLs are separate indexable pages, share links carry their language, and each has its own card. The route files are not duplicated: they live under apps/web/src/routes/{-$locale}/, TanStack Router's optional path segment, so routes/{-$locale}/privacy.tsx answers both forms and params.locale is the slug (or undefined). The layout route {-$locale}/route.tsx turns an unknown first segment (/foo, /foo/privacy) into a 404.
The languages, their slugs and which pages are public live in one leaf, packages/intl/src/locale.config.ts (Internationalization shows how a project maps pt-BR to /br). server.ts keeps one URL per page (platform/seo/locale-redirects.ts):
| Request | Answer |
|---|---|
/privacy?lang=de, ?lang=de-DE, ?lang=de_AT | 301 /de/privacy, the rest of the query kept |
/privacy?lang=en, ?lang=pt-BR (not a language here) | 301 /privacy |
/en/privacy, /DE/privacy, /de/ | 301 /privacy, /de/privacy, /de |
/ with locale cookie fr, or Accept-Language: de | 302 /fr or /de, Vary: Accept-Language, Cookie |
/ from a crawler, without Accept-Language, on POST | 200, the default language |
/login?lang=de, /docs?lang=de, /api/* | untouched: these pages have one URL |
The first-visit redirect is rootLocaleRedirect in the config, on by default: a human whose cookie or browser says another language lands on it, a crawler always sees the default page at / and follows the hreflang alternates. The served / says Vary: Accept-Language, Cookie whenever it was negotiated. Picking a language in the switcher sets the cookie, so a visitor who chose English at / is not redirected on the next visit.
Links between public pages stay in the page's language through useLocalePath() (platform/i18n/use-locale-path.ts): <Link to="/{-$locale}/privacy" params={{ locale: slug }}>, or href("/privacy") for a plain anchor. Links to sign-in and the app stay unprefixed.
Site identity
apps/web/src/platform/seo/site-identity.ts is a leaf module with the name, short name, X handle and logo path of the deployment. og:site_name, twitter:site, the web manifest (/site.webmanifest, a route), the llms.txt heading, the share-image frame and the Organization JSON-LD read it. pnpm run setup --brand-name 'My App' --brand-short-name MyApp --twitter myapp rewrites the leaf and replaces the template brand in the locales and the docs; see Local setup.
Structured data
The root document emits an Organization (with @id … /#organization) and a WebSite whose publisher points at it. Pages add their own graph through the route head's scripts with jsonLdScript() from platform/seo/structured-data.ts: SoftwareApplication + FAQPage on the landing, BlogPosting on posts, ProfilePage on profiles, TechArticle + BreadcrumbList on every docs page (dateModified from the frontmatter date or the git date).
Robots, sitemap and previews
/robots.txt allows / and /api/og/ (share images) and disallows the API and the authenticated and single-visitor pages. /sitemap.xml lists every language's URL of each public HTML page (/privacy, /de/privacy, /fr/privacy), each with the same xhtml:link alternates for every language and x-default, then /llms.txt, /llms-full.txt and every docs page; <lastmod> appears only where a real date exists (posts, the legal pages, docs from frontmatter or git). Markdown copies are not in the sitemap: agents reach them through /llms.txt, the alternate link and content negotiation (Agent-readable pages).
Every response from an origin other than VITE_SITE_URL carries X-Robots-Tag: noindex, so a PR preview Worker or a workers.dev host never enters an index. The comparison happens in apps/web/src/server.ts around the framework handler (platform/seo/request-policy.ts); without VITE_SITE_URL nothing can be compared and the header is not sent.
Verify
curl -s http://localhost:3005/de/privacy | grep -o '<link rel="alternate" hrefLang="[^"]*" href="[^"]*"'
curl -s http://localhost:3005/docs/features/seo | grep -o '<meta property="og:[a-z:]*" content="[^"]*"'
curl -sI 'http://localhost:3005/privacy?lang=de-DE' | grep -i '^HTTP\|^location'
curl -sI -H 'Accept-Language: de' -A 'Mozilla/5.0 Chrome/128.0' http://localhost:3005/ | grep -i '^HTTP\|^location\|^vary'
curl -s http://localhost:3005/login | grep -o '<meta name="robots"[^>]*>'
curl -sI -H 'Accept: text/markdown' http://localhost:3005/de/privacy | grep -i 'content-type\|vary\|content-location'Social cards and the X-Robots-Tag are only proven on the built Worker (wrangler dev -c dist/server/wrangler.json --local, see Open Graph images).
Key files
apps/web/src/platform/seo/seo.tsapps/web/src/platform/seo/site-identity.tsapps/web/src/platform/seo/structured-data.tsapps/web/src/platform/seo/request-policy.tsapps/web/src/platform/seo/locale-redirects.tspackages/intl/src/locale.config.tsapps/web/src/routes/{-$locale}/route.tsxapps/web/src/platform/app/root-document.tsxapps/web/src/routes/robots[.]txt.tsxapps/web/src/routes/sitemap[.]xml.tsxapps/web/src/routes/site[.]webmanifest.ts