# SEO and discovery

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

## The head of a page [#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.

```ts
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 [#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](/docs/features/i18n) 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 [#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](/docs/development/setup).

## Structured data [#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-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](/docs/features/agent-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 [#verify]

```sh
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](/docs/features/og-images)).

## Key files [#key-files]

- [`apps/web/src/platform/seo/seo.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/seo/seo.ts)
- [`apps/web/src/platform/seo/site-identity.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/seo/site-identity.ts)
- [`apps/web/src/platform/seo/structured-data.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/seo/structured-data.ts)
- [`apps/web/src/platform/seo/request-policy.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/seo/request-policy.ts)
- [`apps/web/src/platform/seo/locale-redirects.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/seo/locale-redirects.ts)
- [`packages/intl/src/locale.config.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/intl/src/locale.config.ts)
- [`apps/web/src/routes/{-$locale}/route.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/routes/{-$locale}/route.tsx)
- [`apps/web/src/platform/app/root-document.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/app/root-document.tsx)
- [`apps/web/src/routes/robots[.]txt.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/routes/robots[.]txt.tsx)
- [`apps/web/src/routes/sitemap[.]xml.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/routes/sitemap[.]xml.tsx)
- [`apps/web/src/routes/site[.]webmanifest.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/routes/site[.]webmanifest.ts)

