Canonicals and hreflang
A multi-locale site publishes the same page several times — once per language,
sometimes on several domains — and a search engine has to be told that those
URLs are one page in different languages rather than duplicates of each other.
Two link types do it: rel="canonical", which says which URL a document is at,
and rel="alternate" hreflang="…", which says which other URLs are the same
page elsewhere.
The build writes both, from the locale map you already declared. There is no per-page field for them and no callback to implement. What it needs from you is one thing your config cannot know: where the site is served from.
Declaring the origin
build: {
outDir: "./site",
origin: "https://example.com",
// ...
}origin is the scheme and host of the default output tree, and nothing else —
no path, no trailing slash, no query, no fragment. A locale with its own
domain still uses that domain; the origin supplies the scheme, and the port if
it has one.
Declare no origin and no links are written. The documents are exactly what
they would be without this feature. An origin is a decision about the site's
public identity, and no default can make it for you.
What a page gets
Take a site with en and fr on the default tree and de on example.de:
<link rel="canonical" href="https://example.com/en/pricing">
<link rel="alternate" hreflang="de" href="https://example.de/pricing">
<link rel="alternate" hreflang="en" href="https://example.com/en/pricing">
<link rel="alternate" hreflang="fr" href="https://example.com/fr/pricing">The set includes the page's own locale. That is what hreflang asks for — a set
that omits the page carrying it is ignored rather than reported — and it is what
makes the relation symmetric: every one of these documents lists the same four
URLs, so if one names another, the other names it back.
The URLs are the URLs of files this build emitted. Your trailingSlash policy
decides how they end, and the locale's own tree decides the host, so a canonical
and the address it is served at cannot disagree.
x-default
x-default names the version an unmatched reader should get — someone whose
language is none of yours.
build: {
outDir: "./site",
origin: "https://example.com",
xDefault: "en",
// ...
}It takes a locale code, and it has to be one your locale map declares. Leave it
out and no x-default link is written: pointing at the default tree instead
would be the build choosing which language a stranger reads.
The link goes last in the block, and it repeats the URL of the locale it names:
<link rel="alternate" hreflang="x-default" href="https://example.com/en/pricing">xDefault needs origin, because the link it names is an absolute URL. Declare
it alone and the build refuses the config rather than doing nothing quietly.
Pages that are not translated everywhere
A locale that has no page at a path is not listed. No alternate ever points
at a URL this build did not write. If your German site has an /impressum and
nobody else does, its English alternate does not exist, so it is not named — and
neither is its x-default, even when xDefault: "en".
A page with no other version gets a canonical and nothing else. An hreflang
set of one states no relationship, so a single-locale site — and a page a
multi-locale site translated into one language only — carries the canonical
alone.
A page filled in by a locale's fallback chain is a version like any other.
If fr has no /pricing and falls back to en, French readers get
that content at https://example.com/fr/pricing, and that is the URL the page
canonicalizes to — its own, not the English one. Sending a French reader to the
English page is not deduplication; it is the wrong page. The two are related as
alternates instead, which is exactly what they are.
The 404 page
The page a notFound rule names gets no canonical and no hreflang links.
It carries <meta name="robots" content="noindex"> in their place, and no other
page lists it as an alternate. A canonical names the address a page is to be
indexed at, and a 404 page is not to be indexed at any. The noindex is
written whether or not you declared an origin, because the page's own address
answers 200 on every host. See Routing.
A page at the same path in another locale that no rule names is an ordinary
page. It keeps its canonical, and the 404 pages are not in its hreflang set.
One limit worth knowing
Pages are matched by path. If you translate your slugs — /pricing in
English, /preise in German — the build sees two different pages and neither
lists the other. Nothing in the framework relates the two paths; the only thing
that knows they are one page is the route callback that wrote them both.
Keep one path across locales and let the prefix or the domain carry the language, and the alternates come out right.
Faults
An origin that is not a bare scheme and host:
Config "/site/pagedeck.config.ts": "build.origin" is not a site origin — write the scheme and host the site is served from and nothing else, as origin: "https://example.com":
"https://example.com/shop" — the origin holds the path "/shop", and this build appends each page's own path to it
An xDefault naming a locale the map does not declare:
Config "/site/pagedeck.config.ts": "build.xDefault" names a locale that is not declared — declare the locale, or point xDefault at a declared locale:
"fr" — the declared locales are "en", "de"
An xDefault with no origin to compose its link from:
Config "/site/pagedeck.config.ts": "build.xDefault" is declared without "build.origin", and the x-default link it names is an absolute URL — declare origin: "https://example.com", or remove xDefault