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.
Cookie settings
We use essential cookies to make our app work. With your consent, we also use optional cookies to improve your experience.

Read our Cookie Policy and Privacy Policy.