Serve translations as collections of their own
Add a locale dimension to a source rather than a locale segment to the content path.
Context
The layer translated its interface in seven locales and served one set of pages
to all of them: useDuxtPath() stripped the locale segment before every content
lookup, so /de-DE/guides/deploying and /guides/deploying resolved to the same
file. Content v3 has no notion of a locale — a collection is one tree — so
translated pages needed a decision rather than a setting.
What the comparable generators do was read rather than assumed. Starlight,
VitePress, Docusaurus and MkDocs all put translations in a folder per language;
only Starlight has a documented fallback for a page a language is missing.
Beyond a certain size the translation leaves the tool entirely: React runs
de.react.dev as its own repository, Vue an entire vuejs-translations org,
because translators work to their own schedule and review. OpenCode built an
agent that translated its docs in CI, ran it, and switched it off; seventeen
languages have stood still since.
Cost was measured before the shape was chosen: builds over 1 … 200 collections scale linearly at roughly 2.2 s and 0.63 MB of database each, with no knee. The matrix does not have a ceiling that would force the design's hand.
Decision
A source gains a locales list, and so does a ref, resolved as status already
is (ref.locales ?? source.locales). A string is a folder inside the source's
path; an object moves that language to its own folder, repository or ref.
The default locale is the tree in path itself, without a folder, so adding
the key moves no URL a site already serves.
The locale is not part of the content path. It belongs to i18n's routing, which puts it in front of the path anyway. Original and translation therefore live under identical content paths in separate collections.
A page missing from a language falls back along a chain — the locale, its base
language, a sibling region, vue-i18n's fallbackLocale, the untranslated
original — and the reader is told, in a banner, which language they are being
shown.
Consequences
Every path comparison in the theme is untouched: navigation, redirects, the breadcrumb, the 404's nearest-page scoring and the language switcher all keep working on a path that never carried a locale. The fallback is one more query for the same path rather than a redirect or a second resolution scheme.
useDuxtNavigation and the search follow useDuxtCollection, so both became
locale-aware without being changed.
A site that sets nothing gets exactly what it had: one collection named docs,
one entry in the manifest, one query per page.
Two configurations can now disagree in a way that produces an empty page rather
than an error — content.config.ts resolves the default locale without access
to the Nuxt config. The duxt module therefore checks sourceOptions.defaultLocale
against i18n.defaultLocale and fails the build when they differ, instead of
injecting one into the other and leaving Content computing the other answer.
Translations multiply collections, and the build pays for each linearly. The figure belongs in the documentation, because a consumer decides the matrix.
Partials follow the pages. _partials/ is one collection per LANGUAGE, named
the way the page collections are — duxt_partials for the original,
duxt_partials_de beside it — and :partial{name} walks the same fallback
chain the page did. It has to be the same chain: a page and the blocks it
includes falling back to different languages is how a page ends up half
translated, and nothing on it says so.
The first version of this decision shared ONE untranslated partials collection
across every language. It was defensible while nothing translated a partial,
and it stopped being defensible the moment a translation pass produced
docs/de/_partials/ — files no collection read, and a German page rendering an
English install note with no sign that it had happened.
Alternatives considered
A locale segment in the content prefix. Symmetric with repo and version,
and it would have forced every path comparison in the theme to learn about
locales — for a URL that i18n already prefixes, which would then be spelled
twice.
A file suffix — installation.de-DE.md beside the original. No collection
multiplication, and it fails the case the big projects actually have: it forces
the translation into the same repository and the same ref as the original.
404 for a missing translation. What VitePress does by omission. It punishes the reader for a gap the writer left, and it hides from everyone else that the translation is incomplete.