# The route registry

> defineSite takes values rather than reading the environment, site.routes declares every URL served, and a locale policy belongs to a route, not the site.

Two calls. `defineSite` describes the site, `site.routes` enumerates what it
serves. Everything else is derived from those.

## `defineSite`

```ts
export const site = defineSite({
  baseUrl: "https://example.com",
  name: "Example",
  locales: ["en", "fr", "pt"],
  defaultLocale: "en",
  indexable: process.env.APP_ENV === "production",
});
```

| Field           | What it does                                                                              |
| --------------- | ----------------------------------------------------------------------------------------- |
| `baseUrl`       | Public origin. A trailing slash is dropped, a path is refused.                            |
| `name`          | `og:site_name`, and the default document title.                                           |
| `locales`       | Every locale served, in the order alternates should be listed.                            |
| `defaultLocale` | Where `x-default` points.                                                                 |
| `indexable`     | Drives `robots.txt` **and** the `robots` meta tag, together.                              |
| `localeTags`    | Optional per-locale overrides, for the cases where deriving would guess.                  |
| `twitter`       | The card type and the site handle. Left out, every page still gets `summary_large_image`. |

Declare the shortest tag that is justified. `pt`, not `pt-BR`, unless you serve
more than one Portuguese. Every tag is checked against ICU, at compile time and
again at run time, and the derived forms follow: `hreflang` and `<html lang>`
take the tag's canonical form, `og:locale` comes from ICU's likely subtags. That
last one answers `pt_BR` for `pt`, so a Brazilian unfurl still works without
your site declaring a territory it does not target.

Your URLs are untouched. Declaring `pt-br` gives canonicals on `/pt-br/` and a
cluster keyed `pt-BR`, which is the same tag written the way each place expects
it. See [what it refuses to build](/docs/next/guarantees#two-things-it-gets-right-that-hand-written-versions-usually-do-not).

`indexable` is one flag on purpose. A site that forbids crawling while its pages
ask to be indexed is exactly the `robots.conflict` finding, and leaving no way
to set the two apart is the only way to make it unsatisfiable.

### It reads no environment variable

`process.env.APP_ENV` above is your site's convention, on your side of the line.
Four sites, four different names for the same idea, and a library that picked
one would impose it on the other three. It would also be untestable without
mutating a global.

So `defineSite` takes values. One line each in your site, and the library stays
a pure function of its configuration.

## `site.routes`

```ts
export const routes = site.routes({
  home: { path: "" },
  changelog: { path: "/changelog" },
  legal: collection(allLegals, {
    path: (doc) => `/${doc.slug}`,
    locale: (doc) => doc.locale,
  }),
  docs: collection(allDocs, {
    path: (doc) => `/docs/${doc.slug}`,
    locale: "en",
    ogType: "article",
  }),
});
```

Adding a page means adding it here. A page that renders a canonical without a
registry entry fails the build, instead of shipping absent from the sitemap.

## A locale policy belongs to a route

This is the constraint that reading a real site surfaced, and it breaks any API
that assumes one cluster per site. Documentation lives outside the locale
segment and is English only. A legal notice is translated into whatever has
actually been translated. Both are on the same site.

The rule is short: **a fixed `locale` means the route stands alone, a derived or
absent one means it clusters.**

| Declaration                   | Cluster                       | `x-default`                                                       |
| ----------------------------- | ----------------------------- | ----------------------------------------------------------------- |
| nothing                       | every locale the site serves  | `defaultLocale`                                                   |
| `locales: ["en", "fr"]`       | only those                    | `defaultLocale` if the route serves it, else its first locale     |
| `locale: "en"`                | itself, self-referential      | itself                                                            |
| `locale: (doc) => doc.locale` | whatever the collection holds | `defaultLocale` if the collection holds it, else its first locale |

The fallback is the point. Aiming `x-default` at the site default unconditionally
would make a page translated into two languages that exclude it advertise an
`x-default` that 404s.

A route that declares it exists in two locales is not a translation hole. It is
an intention, and goflag stops reporting it as one.

## `collection`

For a family of pages built from content, availability per locale is **derived**
from the entries present, never declared a second time. Adding a translation
updates the hreflang cluster, the sitemap and the alternates together, because
all three read the same rows.

```ts
collection(allLegals, {
  path: (doc) => `/${doc.slug}`,
  locale: (doc) => doc.locale,
  lastModified: (doc) => doc.updatedAt,
  changeFrequency: "monthly",
  priority: 0.5,
});
```

`lastModified` is per entry and per locale, because a translation is edited on
its own day.

### When your slugs are translated

Entries group into one cluster by their **path**, so `/pricing` and `/tarifs`
become two routes, each advertising a cluster containing only itself. That is
correct if they really are two pages — and wrong, invisibly, if they are one
page in two languages. goflag then reports a translation hole on a pair that is
fully translated, and nothing in your output says otherwise.

Nothing in a path can tell those two cases apart, so `key` is how you say it:

```ts
collection(allDocs, {
  path: (doc) => `/${doc.slug}`, // /pricing in en, /tarifs in fr
  locale: (doc) => doc.locale,
  key: (doc) => doc.translationId, // …and these are the same page
});
```

Entries sharing a key become one route. Each locale keeps its own path, the
cluster names them all, and `x-default` points at the site's default locale when
the route serves it — the same rule every other route follows, so adding a
locale does not rename anything.

Two entries with the same key and the same locale fail the build: picking either
would make the canonical and the cluster describe different URLs under one page.

Leave `key` out and grouping is by path, exactly as before. Every collection
written before this option existed produces the same sitemap, byte for byte.

## Keeping a page out of the sitemap

Every route is listed by default. `sitemap: false` takes one out, and takes
nothing else:

```ts
search: { path: "/search", sitemap: false },
```

On a collection it can be a predicate, evaluated per entry:

```ts
versions: collection(allVersions, {
  path: (entry) => `/docs/${entry.version}`,
  locale: (entry) => entry.locale,
  sitemap: (entry) => entry.version === LATEST,
});
```

**An excluded route keeps its canonical, its hreflang cluster and every refusal
the registry makes.** It is still in its siblings' `alternates`, because
reciprocity is a property of the pages and not of the sitemap. The flag says
_not an entry point_, never _not a page_ — it cannot produce an orphan.

### It does not hide anything

Worth being blunt, because the field invites the opposite belief: **a sitemap
aids discovery, it does not gate indexing.** A page that is linked and crawlable
is indexed whether or not any sitemap mentions it, and a listed page is not
guaranteed to be indexed either.

| Belief                                         | Reality                                                             |
| ---------------------------------------------- | ------------------------------------------------------------------- |
| Out of the sitemap means out of the index      | False when the page is linked. The crawl follows links              |
| A large sitemap dilutes crawl budget           | The per-file ceiling is 50,000 URLs. A few thousand is not a volume |
| Omitting it protects against duplicate content | No. `canonical` and `noindex` do that; an omission does neither     |

So reach for it when the page should not be an **entry point** — a search
result page, a faceted variant, a print view, a markdown mirror. Not to bury a
page: if the goal is to keep something out of an index, the instruments are
`noindex` and `canonical`, and this flag is neither.

## What a route says about its card

`routes.metadata()` takes `title` and `description`, and two optional fields for
the pages that name their own preview image.

```ts
routes.metadata({
  path: docsHref(doc.slug),
  title: doc.title,
  description: doc.description,
  image: { url: `/og/docs/${doc.slug}`, width: 1200, height: 630, type: "image/png" },
  imageAlt: "The title “Install” on a dark goflag preview card.",
});
```

**Leave both unset wherever `opengraph-image.tsx` applies.** Next renders one per
segment at build time and injects the tag itself, so naming an image here
overrides a file that is already correct — and Next's merge rule tests for the
_presence_ of the key, not its value, which means setting it at all takes over.
These fields exist for the routes that cannot use the convention: Next will not
place a metadata image under a catch-all segment, so a `[...slug]` tree has to
name its card.

`image` accepts a bare path or a described one. **A bare path declares a URL and
nothing else**, and that is deliberate. This used to attach `width: 1200,
height: 630` to whatever it was handed, having never looked at the file. On a
site whose cards really are that shape the numbers happened to be right; on one
naming cover art they were not — 1024×1024 artwork and a 337-byte 1×1
placeholder, both declared 1200×630 by this library.

Worse than wrong: [`og.image.ratio`](/docs/rules/og.image.ratio) reads those two
numbers and refuses to fetch, so an invented 1200×630 scored 1.9 and passed. The
auditor was blinded by the library, which is the one direction that loop must
never run. [`og.image.sizes-mismatch`](/docs/rules/og.image.sizes-mismatch) is
what catches it when a wrong size is declared anyway.

So the shape is yours to state, because you are the only one who knows it. Say
nothing and [`og.image.dimensions`](/docs/rules/og.image.dimensions) asks you to
measure, which is the honest verdict and a better one than a confident lie.

`imageAlt` has no default for the same reason.
[`og.image.alt`](/docs/rules/og.image.alt) checks presence, so falling back to
the page title satisfied it while saying nothing about the picture — and ogp.me
is explicit that the field is a description of what is in the image, not a
caption. A library cannot describe a card it did not draw. Omit it and the rule
fires, which is the rule doing its job.

## What the registry gives back

| Call                                                        | Returns                                              |
| ----------------------------------------------------------- | ---------------------------------------------------- |
| `routes.metadata({ path, locale, ...content })`             | `Metadata` — the content fields are below            |
| `routes.sitemap({ lastModified })`                          | `MetadataRoute.Sitemap`                              |
| `routes.robots({ disallow })`                               | `MetadataRoute.Robots`                               |
| `site.rootMetadata({ description })`                        | `Metadata` for the root layout                       |
| `routes.family("docs")`                                     | that family's routes                                 |
| `routes.all` / `routes.find(path)` / `routes.require(path)` | every route, a lookup, a lookup that fails the build |
| `site.resolveLocale(segment)`                               | the served locale a segment means, or `undefined`    |
| `site.servesLocale(segment)`                                | a type guard — exact match on a declared locale      |
| `site.lang(locale)` / `site.bcp47(locale)`                  | `<html lang>` and the `hreflang` tag                 |

### What a page contributes

`routes.metadata` takes the route's identity — `path`, and `locale` for a
localized route — plus the words the registry cannot know:

| Field           |                                                                                        |
| --------------- | -------------------------------------------------------------------------------------- |
| `title`         | required                                                                               |
| `description`   | required                                                                               |
| `absoluteTitle` | skip the layout's title template, for a title that already names the product           |
| `keywords`      |                                                                                        |
| `image`         | an explicit `og:image`, as a site-absolute path                                        |
| `imageAlt`      | what is _in_ that image — see below                                                    |
| `og`            | `{ title, description, type, publishedTime, modifiedTime }`, each overriding a default |

`image` exists for the routes that cannot use the file convention: Next refuses
to place a metadata image under a catch-all segment, so a `[[...slug]]` docs
tree has to name its card. Everywhere `opengraph-image.tsx` applies, leave it
unset — naming one here overrides a file that is already correct.

**`imageAlt` has no default, and that is the point.** The obvious one is the
title, and it is the only description that adds nothing: the protocol asks for
what is _in_ the image and says in the same breath that it is not a caption.
A repeated title also satisfies `og.image.alt`, which checks presence — so the
defect passes the audit and keeps passing. Left out, the tag is omitted, the
rule fires, and it tells you what to write. Left wrong, nothing ever does.

Both mistakes have names in the catalogue: [`og.image.alt`](/docs/rules) for the
missing tag, `og.image.alt.caption` for the one that repeats the title.

`resolveLocale` is RFC 4647 Lookup and folds case, so `/pt-BR/` and `/PT/` both
find a site that serves `pt`; it never falls back to the default locale, which is
what lets an unserved language 404 instead of answering 200 in the wrong one.
`servesLocale` is the narrower guard: an exact match, for a segment you already
trust.

`routes.family` exists because the shape of a dynamic segment, `[slug]` against
`[[...slug]]`, is a property of the filesystem route and not of the registry.
Deriving `generateStaticParams` would be guessing, and a wrong guess there
produces missing pages rather than an error. So the registry hands back the
routes and your site turns them into params in three visible lines.

`robots({ disallow })` adds paths while the site is indexable, and is ignored
when it is not: a site that forbids everything has nothing to add to that. The
non-standard `Host:` directive is off by default, because only Yandex ever read
it and Google ignores it; `robots({ host: true })` emits it where you serve
Yandex. A library has no business producing output its own auditor will warn
about once the robots.txt rules land.
