Open a translated docs site, switch to German, and click a page nobody has translated yet. One common answer is the English page under a German URL. Another is the German home page with no explanation. Then search for something in German. If the site keeps one index for every language, the results come back in English.
None of that is a translation problem. The words were fine. The structure around them wasn't.
To translate a documentation site, put each language under its own URL prefix with the default language at the root, decide what happens when a page has no translation, tell search engines which pages are translations of each other with hreflang, keep search and AI chat inside the reader's language, and translate the site's own interface text, not only the pages. The translation itself can be done by people or by a model, as long as someone reviews it before it publishes. Since version 0.0.159, the free Doccupine CLI does the structural part from one config file and a folder per language. Here is each decision and the tradeoff I picked.
Give each language its own folder and URL prefix
The cleanest model I know is that a language is a folder, and the folder name is the URL prefix. Your default language stays where it is, so none of your existing links break. German goes in docs/de/, and docs/de/guides/intro.mdx publishes at /de/guides/intro.
With the Doccupine CLI you declare the languages in a languages.json next to doccupine.json:
[
{ "code": "en", "label": "English", "default": true },
{ "code": "de", "label": "Deutsch" }
]Then mirror the default layout inside the language folder:
- docs
- index.mdx
- guides
- intro.mdx
- de
- index.mdx
- guides
- intro.mdx
A page is the translation of another when it sits at the same path inside its language folder. That is the entire matching rule. No IDs, no mapping file, nothing to keep in sync except the file names.
The generated site gets a language dropdown in the sidebar footer, and a sidebar that lists only the current language's pages. If you have built a docs site from Markdown before, nothing else about authoring changes. A translated page is an ordinary MDX file.
The file is opt-in. A site without languages.json generates the same output it always did.
Decide what happens to a page you haven't translated
The tempting answer is a fallback: if there's no German version, show the English one. It looks complete. It also means a German reader can't tell which pages are translated, your German sitemap is full of English content, and nobody on your team can see what's left to do.
I went the other way. In a Doccupine site, a page that doesn't exist in a language is a 404 under that language's prefix. The language switcher takes the reader to the same page when a translation exists, and to that language's home page when it doesn't.
That is a real tradeoff. A half-translated language has dead ends, and if you publish German with a third of the pages done, German readers will notice. I still think it's the honest version. Translate the pages that matter most and publish a smaller German site, or wait. A German URL that serves English is a promise the page doesn't keep.
Tell search engines which pages belong together
Without hints, a search engine sees /guides/intro and /de/guides/intro as two unrelated pages that happen to look alike. The fix is hreflang: each page lists its translations and which one is the default.
The generated site emits these for you. Every translated page carries alternate links to its other languages plus an x-default pointing at the default language, the sitemap lists the same alternates, and the page's content is marked with its language. Each language also gets its own llms.txt under its prefix, so /de/llms.txt lists the German pages for AI tools.
hreflang helps a search engine show each reader the right language. It doesn't make a page rank.
Keep search and AI chat in the reader's language
This one is easy to miss. The pages are translated, but search runs over one combined index, so a German query returns German and English results mixed together. An AI assistant fails the same way and is harder to catch, because it answers a German question from English pages.
In a Doccupine site, search results and AI chat answers come only from the language the reader is currently looking at. The MCP server's search and list tools take a language parameter too, so a coding assistant connected to your docs can ask for one language explicitly. It defaults to the default language.
The flip side: a German reader searching for something that exists only in English won't find it. One more reason to translate the most-visited pages first.
Translate the interface text too
A German page inside an English interface still feels like a translated page dropped into someone else's product. The search box, "On this page", the previous and next links, the copy button, and the chat greeting are all text, and all of it is English by default.
Each language in languages.json can carry a strings object that overrides those labels:
{
"code": "de",
"label": "Deutsch",
"strings": {
"searchPlaceholder": "Dokumentation durchsuchen...",
"onThisPage": "Auf dieser Seite",
"chatGreeting": "Wie kann ich helfen?"
}
}Keys you leave out keep their English default, and an unknown key is reported when the site is generated, so a typo doesn't fail silently. The full list of keys is on the Languages docs page.
How to translate the pages themselves
Everything so far is structure. If you self-host with the CLI, the translating is up to you: a translator, a teammate who speaks the language, or a model you run yourself. Whatever writes the files, somebody who reads the language reviews each page before it ships. Machine translation of technical writing is good enough that the review is usually quick, and not good enough to skip.
On the hosted platform, the Languages page under project settings does the folder work and the first draft. Adding a language can start empty or from a copy of every default-language page. With two or more languages configured, the editor offers Translate to on any default-language page, and the Languages page has Translate missing pages, which translates the pages a language doesn't have yet, up to 25 per run. The model is told to translate the prose and leave code blocks, inline code, component tags, links, and frontmatter keys exactly as they are, and the result is checked to make sure it still compiles before it's staged.
Staged is the important word. Every translation lands as a pending change, so you read it, fix it, and publish when you're happy, the same way you would review any other edit. Translation uses the project's AI setting and is billed like any other AI feature, from the included monthly budget or your own provider key.
Two limits worth knowing. A page longer than 40,000 characters has to be split before it can be translated in one go. And an API reference generated from an OpenAPI spec is published in the default language only.
The part no tool does for you
Translations drift. You change the English page on Tuesday and the German page still describes last week's behavior. Today nothing in Doccupine flags a translation as stale when its source page changes, so this is a process question, not a tool question. The habits that work are the same ones that keep documentation up to date in a single language: tie doc changes to code changes in review, and when the English page changes, re-translate the matching page in the same pull request rather than "later".
That is also the test of whether to translate at all. If nobody on your team reads German, a German site will drift out of date and you won't find out. Two well-kept languages beat a long dropdown of stale ones.
Older releases work the same way. A versions.json and a folder per version give you /v1/ next to the current docs, and languages and versions combine as docs/de/v1/. The Versions docs page covers it.
Checklist
- One folder per language, default language at the root so existing URLs don't change.
- Decide on missing pages. A 404 tells the truth, a fallback hides the gap.
- hreflang, an
x-default, and sitemap alternates on every translated page. - Search, AI chat, and
llms.txtscoped to the reader's language. - Interface strings translated, not only page content.
- A human who reads the language reviews every page before it publishes.
- When a source page changes, the translation changes in the same pull request.
Back to that German reader on the untranslated page. With the structure right, they get a clear answer instead of an English page in disguise, and search that stays in German. The words were never the hard part.
If you're publishing docs in more than one language and something about this setup doesn't fit how your team works, email [email protected]. I'd like to hear which language you're adding and what's getting in the way.