Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

SEO and discovery

One seo() helper per route, a site-identity leaf, robots, sitemap, structured data and the preview noindex.

Markdown

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: true adds <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: true emits one canonical and no hreflang alternates. The developer docs are English only and use it, with an article type 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 from PUBLIC_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):

RequestAnswer
/privacy?lang=de, ?lang=de-DE, ?lang=de_AT301 /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: de302 /fr or /de, Vary: Accept-Language, Cookie
/ from a crawler, without Accept-Language, on POST200, 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

Edit on GitHubDocumentation is in English.